New base
This commit is contained in:
@@ -1,137 +0,0 @@
|
||||
import { ChatAnthropic } from '@langchain/anthropic'
|
||||
import { HumanMessage, SystemMessage } from '@langchain/core/messages'
|
||||
import { CHATBOT_CONFIG } from './config'
|
||||
import {
|
||||
SYSTEM_PROMPT,
|
||||
formatContextFromSources,
|
||||
formatConversationHistory,
|
||||
} from './prompts'
|
||||
import {
|
||||
retrieveRelevantDocuments,
|
||||
documentsToSources,
|
||||
type RetrievedDocument,
|
||||
} from './retriever'
|
||||
import type { ChatMessage, SourceReference } from '@/types/chat'
|
||||
|
||||
// Initialize the LLM
|
||||
function getChatModel() {
|
||||
return new ChatAnthropic({
|
||||
modelName: CHATBOT_CONFIG.model,
|
||||
maxTokens: CHATBOT_CONFIG.maxTokens,
|
||||
temperature: CHATBOT_CONFIG.temperature,
|
||||
anthropicApiKey: process.env.ANTHROPIC_API_KEY,
|
||||
})
|
||||
}
|
||||
|
||||
export interface ChatResult {
|
||||
content: string
|
||||
sources: SourceReference[]
|
||||
}
|
||||
|
||||
export async function generateChatResponse(
|
||||
userMessage: string,
|
||||
conversationHistory: ChatMessage[]
|
||||
): Promise<ChatResult> {
|
||||
// 1. Retrieve relevant documents
|
||||
const relevantDocs = await retrieveRelevantDocuments(userMessage)
|
||||
|
||||
// 2. Format context from retrieved documents
|
||||
const context = formatContextFromSources(
|
||||
relevantDocs.map((doc) => ({
|
||||
content: doc.content,
|
||||
title: doc.title,
|
||||
section_title: doc.section_title,
|
||||
source_file: doc.source_file,
|
||||
}))
|
||||
)
|
||||
|
||||
// 3. Format conversation history (last N messages)
|
||||
const recentHistory = conversationHistory.slice(
|
||||
-CHATBOT_CONFIG.maxHistoryMessages
|
||||
)
|
||||
const historyText = formatConversationHistory(
|
||||
recentHistory.map((msg) => ({
|
||||
role: msg.role,
|
||||
content: msg.content,
|
||||
}))
|
||||
)
|
||||
|
||||
// 4. Build the system prompt with context
|
||||
const systemPrompt = SYSTEM_PROMPT.replace('{context}', context).replace(
|
||||
'{history}',
|
||||
historyText
|
||||
)
|
||||
|
||||
// 5. Generate response
|
||||
const model = getChatModel()
|
||||
const response = await model.invoke([
|
||||
new SystemMessage(systemPrompt),
|
||||
new HumanMessage(userMessage),
|
||||
])
|
||||
|
||||
// 6. Extract content and sources
|
||||
const content =
|
||||
typeof response.content === 'string'
|
||||
? response.content
|
||||
: JSON.stringify(response.content)
|
||||
|
||||
return {
|
||||
content,
|
||||
sources: documentsToSources(relevantDocs),
|
||||
}
|
||||
}
|
||||
|
||||
export async function* streamChatResponse(
|
||||
userMessage: string,
|
||||
conversationHistory: ChatMessage[]
|
||||
): AsyncGenerator<{ type: 'content' | 'sources'; data: string | SourceReference[] }> {
|
||||
// 1. Retrieve relevant documents first
|
||||
const relevantDocs = await retrieveRelevantDocuments(userMessage)
|
||||
|
||||
// 2. Format context from retrieved documents
|
||||
const context = formatContextFromSources(
|
||||
relevantDocs.map((doc) => ({
|
||||
content: doc.content,
|
||||
title: doc.title,
|
||||
section_title: doc.section_title,
|
||||
source_file: doc.source_file,
|
||||
}))
|
||||
)
|
||||
|
||||
// 3. Format conversation history
|
||||
const recentHistory = conversationHistory.slice(
|
||||
-CHATBOT_CONFIG.maxHistoryMessages
|
||||
)
|
||||
const historyText = formatConversationHistory(
|
||||
recentHistory.map((msg) => ({
|
||||
role: msg.role,
|
||||
content: msg.content,
|
||||
}))
|
||||
)
|
||||
|
||||
// 4. Build the system prompt
|
||||
const systemPrompt = SYSTEM_PROMPT.replace('{context}', context).replace(
|
||||
'{history}',
|
||||
historyText
|
||||
)
|
||||
|
||||
// 5. Stream the response
|
||||
const model = getChatModel()
|
||||
const stream = await model.stream([
|
||||
new SystemMessage(systemPrompt),
|
||||
new HumanMessage(userMessage),
|
||||
])
|
||||
|
||||
for await (const chunk of stream) {
|
||||
const content =
|
||||
typeof chunk.content === 'string'
|
||||
? chunk.content
|
||||
: JSON.stringify(chunk.content)
|
||||
if (content) {
|
||||
yield { type: 'content', data: content }
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Yield sources at the end
|
||||
yield { type: 'sources', data: documentsToSources(relevantDocs) }
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
// AI Chatbot configuration
|
||||
|
||||
export const CHATBOT_CONFIG = {
|
||||
// LLM settings
|
||||
model: 'claude-haiku-4-5-20251001',
|
||||
maxTokens: 2048,
|
||||
temperature: 0.3,
|
||||
|
||||
// Retrieval settings
|
||||
retrievalK: 5,
|
||||
similarityThreshold: 0.7,
|
||||
|
||||
// Embedding settings
|
||||
embeddingModel: 'text-embedding-ada-002',
|
||||
|
||||
// Chunking settings for ingestion
|
||||
chunkSize: 1000,
|
||||
chunkOverlap: 200,
|
||||
|
||||
// Rate limiting
|
||||
rateLimitPerMinute: 10,
|
||||
|
||||
// Conversation history
|
||||
maxHistoryMessages: 10,
|
||||
} as const
|
||||
@@ -1,25 +0,0 @@
|
||||
import { OpenAIEmbeddings } from '@langchain/openai'
|
||||
import { CHATBOT_CONFIG } from './config'
|
||||
|
||||
// Singleton instance for embeddings
|
||||
let embeddingsInstance: OpenAIEmbeddings | null = null
|
||||
|
||||
export function getEmbeddings(): OpenAIEmbeddings {
|
||||
if (!embeddingsInstance) {
|
||||
embeddingsInstance = new OpenAIEmbeddings({
|
||||
modelName: CHATBOT_CONFIG.embeddingModel,
|
||||
openAIApiKey: process.env.OPENAI_API_KEY,
|
||||
})
|
||||
}
|
||||
return embeddingsInstance
|
||||
}
|
||||
|
||||
export async function generateEmbedding(text: string): Promise<number[]> {
|
||||
const embeddings = getEmbeddings()
|
||||
return embeddings.embedQuery(text)
|
||||
}
|
||||
|
||||
export async function generateEmbeddings(texts: string[]): Promise<number[][]> {
|
||||
const embeddings = getEmbeddings()
|
||||
return embeddings.embedDocuments(texts)
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
// Swedish system prompt for bookkeeping AI assistant
|
||||
|
||||
export const SYSTEM_PROMPT = `Du är en expert AI-assistent som hjälper svenska företagare med skatt, moms, bokföring och företagsekonomi. Du arbetar inom en ekonomiplattform för småföretag.
|
||||
|
||||
## Dina kunskapsområden:
|
||||
- Svensk skattlagstiftning för enskild firma och aktiebolag
|
||||
- Moms och momsdeklaration
|
||||
- Bokföring enligt BAS-kontoplanen
|
||||
- Egenavgifter och socialförsäkring
|
||||
- Avdrag för utrustning, resor, hemmakontor och liknande
|
||||
- Fakturering och kundhantering
|
||||
- NE-bilaga och inkomstdeklaration
|
||||
|
||||
## Viktiga tröskelvärden att komma ihåg:
|
||||
- Momsregistrering: 120 000 kr omsättning under 12 månader
|
||||
- Direktavdrag vs inventarier: 26 250 kr (halvt prisbasbelopp)
|
||||
- SGI-gräns för sjukpenning: 13 500 kr/år minsta inkomst
|
||||
- Karensavdrag: 20% av sjuklönen
|
||||
- Friskvårdsbidrag max: 6 000 kr/år (ej skattepliktigt)
|
||||
- Representationsavdrag mat: 90 kr exkl moms per person
|
||||
|
||||
## Instruktioner:
|
||||
1. Svara alltid på svenska med korrekt terminologi
|
||||
2. Var konkret och ge specifika exempel när möjligt
|
||||
3. Referera till relevanta tröskelvärden och regler
|
||||
4. Om du är osäker, säg det och rekommendera att användaren konsulterar en revisor
|
||||
5. Använd information från de tillhandahållna källorna för att ge korrekta svar
|
||||
6. Formatera svaren tydligt med punktlistor när det passar
|
||||
7. Om frågan gäller något utanför dina kunskapsområden, hänvisa till rätt instans
|
||||
|
||||
## Kontext från kunskapsbasen:
|
||||
{context}
|
||||
|
||||
## Tidigare konversation:
|
||||
{history}
|
||||
|
||||
Svara på användarens fråga baserat på din expertkunskap och den tillhandahållna kontexten. Om kontexten inte innehåller relevant information, använd din allmänna kunskap om svenska skatteregler för företagare.`
|
||||
|
||||
export const RETRIEVAL_PROMPT = `Baserat på följande fråga, hitta relevant information från kunskapsbasen.
|
||||
|
||||
Fråga: {question}
|
||||
|
||||
Sök efter information som hjälper att besvara frågan korrekt och fullständigt.`
|
||||
|
||||
export function formatContextFromSources(
|
||||
sources: Array<{
|
||||
content: string
|
||||
title: string
|
||||
section_title: string | null
|
||||
source_file: string
|
||||
}>
|
||||
): string {
|
||||
if (sources.length === 0) {
|
||||
return 'Ingen specifik kontext hittades i kunskapsbasen.'
|
||||
}
|
||||
|
||||
return sources
|
||||
.map((source, index) => {
|
||||
const sectionInfo = source.section_title
|
||||
? ` > ${source.section_title}`
|
||||
: ''
|
||||
return `[Källa ${index + 1}: ${source.title}${sectionInfo}]\n${source.content}`
|
||||
})
|
||||
.join('\n\n---\n\n')
|
||||
}
|
||||
|
||||
export function formatConversationHistory(
|
||||
messages: Array<{ role: 'user' | 'assistant'; content: string }>
|
||||
): string {
|
||||
if (messages.length === 0) {
|
||||
return 'Ingen tidigare konversation.'
|
||||
}
|
||||
|
||||
return messages
|
||||
.map((msg) => {
|
||||
const role = msg.role === 'user' ? 'Användare' : 'Assistent'
|
||||
return `${role}: ${msg.content}`
|
||||
})
|
||||
.join('\n\n')
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import { createServiceClient } from '@/lib/supabase/server'
|
||||
import { generateEmbedding } from './embeddings'
|
||||
import { CHATBOT_CONFIG } from './config'
|
||||
import type { SourceReference } from '@/types/chat'
|
||||
|
||||
export interface RetrievedDocument {
|
||||
id: string
|
||||
source_file: string
|
||||
title: string
|
||||
section_title: string | null
|
||||
content: string
|
||||
metadata: Record<string, unknown>
|
||||
similarity: number
|
||||
}
|
||||
|
||||
export async function retrieveRelevantDocuments(
|
||||
query: string,
|
||||
matchCount: number = CHATBOT_CONFIG.retrievalK,
|
||||
matchThreshold: number = CHATBOT_CONFIG.similarityThreshold
|
||||
): Promise<RetrievedDocument[]> {
|
||||
const supabase = await createServiceClient()
|
||||
|
||||
// Generate embedding for the query
|
||||
const queryEmbedding = await generateEmbedding(query)
|
||||
|
||||
// Call the match_documents function
|
||||
const { data, error } = await supabase.rpc('match_documents', {
|
||||
query_embedding: queryEmbedding,
|
||||
match_count: matchCount,
|
||||
match_threshold: matchThreshold,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
console.error('Error retrieving documents:', error)
|
||||
throw new Error('Failed to retrieve relevant documents')
|
||||
}
|
||||
|
||||
return (data || []) as RetrievedDocument[]
|
||||
}
|
||||
|
||||
export function documentsToSources(
|
||||
documents: RetrievedDocument[]
|
||||
): SourceReference[] {
|
||||
return documents.map((doc) => ({
|
||||
id: doc.id,
|
||||
source_file: doc.source_file,
|
||||
title: doc.title,
|
||||
section_title: doc.section_title,
|
||||
similarity: doc.similarity,
|
||||
}))
|
||||
}
|
||||
@@ -1,345 +0,0 @@
|
||||
/**
|
||||
* Knowledge Base Ingestion Script
|
||||
*
|
||||
* Loads markdown files from dev_docs/ai_knowledge_base/,
|
||||
* chunks them by sections, generates embeddings, and stores in Supabase.
|
||||
*
|
||||
* Run with: npx tsx lib/ai/ingestion/ingest.ts
|
||||
*/
|
||||
|
||||
import * as dotenv from 'dotenv'
|
||||
dotenv.config({ path: '.env.local' })
|
||||
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import { OpenAIEmbeddings } from '@langchain/openai'
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
import * as crypto from 'crypto'
|
||||
|
||||
// Configuration
|
||||
const DOCS_DIR = path.join(process.cwd(), 'dev_docs', 'ai_knowledge_base')
|
||||
const CHUNK_SIZE = 1000
|
||||
const CHUNK_OVERLAP = 200
|
||||
const EMBEDDING_MODEL = 'text-embedding-ada-002'
|
||||
|
||||
// Initialize clients
|
||||
const supabase = createClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.SUPABASE_SERVICE_ROLE_KEY!
|
||||
)
|
||||
|
||||
const embeddings = new OpenAIEmbeddings({
|
||||
modelName: EMBEDDING_MODEL,
|
||||
openAIApiKey: process.env.OPENAI_API_KEY,
|
||||
})
|
||||
|
||||
interface DocumentChunk {
|
||||
source_file: string
|
||||
title: string
|
||||
section_title: string | null
|
||||
content: string
|
||||
content_hash: string
|
||||
metadata: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface Section {
|
||||
title: string
|
||||
content: string
|
||||
level: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a markdown file into sections based on headings
|
||||
*/
|
||||
function parseMarkdownSections(content: string): Section[] {
|
||||
const lines = content.split('\n')
|
||||
const sections: Section[] = []
|
||||
let currentSection: Section | null = null
|
||||
let contentBuffer: string[] = []
|
||||
|
||||
for (const line of lines) {
|
||||
// Check for headings (H1, H2, H3)
|
||||
const h1Match = line.match(/^# (.+)$/)
|
||||
const h2Match = line.match(/^## (.+)$/)
|
||||
const h3Match = line.match(/^### (.+)$/)
|
||||
|
||||
if (h1Match || h2Match || h3Match) {
|
||||
// Save previous section
|
||||
if (currentSection && contentBuffer.length > 0) {
|
||||
currentSection.content = contentBuffer.join('\n').trim()
|
||||
if (currentSection.content) {
|
||||
sections.push(currentSection)
|
||||
}
|
||||
}
|
||||
|
||||
// Start new section
|
||||
const title = h1Match?.[1] || h2Match?.[1] || h3Match?.[1] || ''
|
||||
const level = h1Match ? 1 : h2Match ? 2 : 3
|
||||
currentSection = { title, content: '', level }
|
||||
contentBuffer = []
|
||||
} else {
|
||||
contentBuffer.push(line)
|
||||
}
|
||||
}
|
||||
|
||||
// Don't forget the last section
|
||||
if (currentSection && contentBuffer.length > 0) {
|
||||
currentSection.content = contentBuffer.join('\n').trim()
|
||||
if (currentSection.content) {
|
||||
sections.push(currentSection)
|
||||
}
|
||||
}
|
||||
|
||||
return sections
|
||||
}
|
||||
|
||||
/**
|
||||
* Split text into chunks while preserving context
|
||||
*/
|
||||
function chunkText(text: string, maxSize: number, overlap: number): string[] {
|
||||
if (text.length <= maxSize) {
|
||||
return [text]
|
||||
}
|
||||
|
||||
const chunks: string[] = []
|
||||
let start = 0
|
||||
|
||||
while (start < text.length) {
|
||||
let end = start + maxSize
|
||||
|
||||
// Try to break at a natural point (paragraph, sentence, or word)
|
||||
if (end < text.length) {
|
||||
// Look for paragraph break
|
||||
const paragraphBreak = text.lastIndexOf('\n\n', end)
|
||||
if (paragraphBreak > start + maxSize / 2) {
|
||||
end = paragraphBreak
|
||||
} else {
|
||||
// Look for sentence break
|
||||
const sentenceBreak = text.lastIndexOf('. ', end)
|
||||
if (sentenceBreak > start + maxSize / 2) {
|
||||
end = sentenceBreak + 1
|
||||
} else {
|
||||
// Look for word break
|
||||
const wordBreak = text.lastIndexOf(' ', end)
|
||||
if (wordBreak > start + maxSize / 2) {
|
||||
end = wordBreak
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
chunks.push(text.slice(start, end).trim())
|
||||
start = end - overlap
|
||||
if (start < 0) start = 0
|
||||
if (end >= text.length) break
|
||||
}
|
||||
|
||||
return chunks.filter((c) => c.length > 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract metadata from content (trigger words, categories, etc.)
|
||||
*/
|
||||
function extractMetadata(
|
||||
content: string,
|
||||
sectionTitle: string
|
||||
): Record<string, unknown> {
|
||||
const metadata: Record<string, unknown> = {}
|
||||
|
||||
// Extract scenario IDs (e.g., "Scenario 001")
|
||||
const scenarioMatches = content.match(/Scenario\s+(\d{3})/gi)
|
||||
if (scenarioMatches) {
|
||||
metadata.scenarios = scenarioMatches.map((m) =>
|
||||
m.replace(/Scenario\s+/i, '')
|
||||
)
|
||||
}
|
||||
|
||||
// Extract mentioned thresholds/amounts
|
||||
const amountMatches = content.match(/(\d+[\s\d]*)\s*(kr|SEK|kronor)/gi)
|
||||
if (amountMatches) {
|
||||
metadata.amounts = amountMatches.slice(0, 5) // Limit to first 5
|
||||
}
|
||||
|
||||
// Extract platform mentions
|
||||
const platforms = [
|
||||
'YouTube',
|
||||
'Twitch',
|
||||
'Instagram',
|
||||
'TikTok',
|
||||
'Patreon',
|
||||
'Spotify',
|
||||
'Adtraction',
|
||||
]
|
||||
const mentionedPlatforms = platforms.filter((p) =>
|
||||
content.toLowerCase().includes(p.toLowerCase())
|
||||
)
|
||||
if (mentionedPlatforms.length > 0) {
|
||||
metadata.platforms = mentionedPlatforms
|
||||
}
|
||||
|
||||
// Extract categories based on keywords
|
||||
const categories: string[] = []
|
||||
if (/moms|vat/i.test(content)) categories.push('moms')
|
||||
if (/skatt|deklaration/i.test(content)) categories.push('skatt')
|
||||
if (/avdrag/i.test(content)) categories.push('avdrag')
|
||||
if (/bokför|konto|bas/i.test(content)) categories.push('bokföring')
|
||||
if (/sgi|sjuk|föräldra|pension/i.test(content))
|
||||
categories.push('socialförsäkring')
|
||||
if (/ef|enskild firma/i.test(content)) categories.push('enskild_firma')
|
||||
if (/ab|aktiebolag/i.test(content)) categories.push('aktiebolag')
|
||||
|
||||
if (categories.length > 0) {
|
||||
metadata.categories = categories
|
||||
}
|
||||
|
||||
return metadata
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate content hash for deduplication
|
||||
*/
|
||||
function generateHash(content: string): string {
|
||||
return crypto.createHash('sha256').update(content).digest('hex').slice(0, 16)
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a single markdown file
|
||||
*/
|
||||
function processFile(filePath: string): DocumentChunk[] {
|
||||
const content = fs.readFileSync(filePath, 'utf-8')
|
||||
const fileName = path.basename(filePath)
|
||||
const sections = parseMarkdownSections(content)
|
||||
|
||||
const chunks: DocumentChunk[] = []
|
||||
|
||||
// Get document title from first H1
|
||||
const documentTitle =
|
||||
sections.find((s) => s.level === 1)?.title || fileName.replace('.md', '')
|
||||
|
||||
for (const section of sections) {
|
||||
// Skip empty sections
|
||||
if (!section.content || section.content.length < 50) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Chunk large sections
|
||||
const textChunks = chunkText(section.content, CHUNK_SIZE, CHUNK_OVERLAP)
|
||||
|
||||
for (let i = 0; i < textChunks.length; i++) {
|
||||
const chunkContent = textChunks[i]
|
||||
const sectionTitle =
|
||||
section.level === 1
|
||||
? null
|
||||
: textChunks.length > 1
|
||||
? `${section.title} (del ${i + 1}/${textChunks.length})`
|
||||
: section.title
|
||||
|
||||
chunks.push({
|
||||
source_file: fileName,
|
||||
title: documentTitle,
|
||||
section_title: sectionTitle,
|
||||
content: chunkContent,
|
||||
content_hash: generateHash(chunkContent),
|
||||
metadata: extractMetadata(chunkContent, section.title),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return chunks
|
||||
}
|
||||
|
||||
/**
|
||||
* Main ingestion function
|
||||
*/
|
||||
async function ingest() {
|
||||
console.log('Starting knowledge base ingestion...')
|
||||
console.log(`Reading files from: ${DOCS_DIR}`)
|
||||
|
||||
// Get all markdown files
|
||||
const files = fs
|
||||
.readdirSync(DOCS_DIR)
|
||||
.filter((f) => f.endsWith('.md'))
|
||||
.sort()
|
||||
|
||||
console.log(`Found ${files.length} markdown files`)
|
||||
|
||||
// Process all files
|
||||
const allChunks: DocumentChunk[] = []
|
||||
for (const file of files) {
|
||||
const filePath = path.join(DOCS_DIR, file)
|
||||
console.log(`Processing: ${file}`)
|
||||
const chunks = processFile(filePath)
|
||||
allChunks.push(...chunks)
|
||||
console.log(` -> ${chunks.length} chunks`)
|
||||
}
|
||||
|
||||
console.log(`Total chunks: ${allChunks.length}`)
|
||||
|
||||
// Clear existing documents (optional - comment out for incremental updates)
|
||||
console.log('Clearing existing documents...')
|
||||
const { error: deleteError } = await supabase
|
||||
.from('knowledge_documents')
|
||||
.delete()
|
||||
.neq('id', '00000000-0000-0000-0000-000000000000') // Delete all
|
||||
|
||||
if (deleteError) {
|
||||
console.error('Error clearing documents:', deleteError)
|
||||
// Continue anyway
|
||||
}
|
||||
|
||||
// Generate embeddings in batches
|
||||
const BATCH_SIZE = 20
|
||||
let processed = 0
|
||||
|
||||
for (let i = 0; i < allChunks.length; i += BATCH_SIZE) {
|
||||
const batch = allChunks.slice(i, i + BATCH_SIZE)
|
||||
const contents = batch.map((c) => c.content)
|
||||
|
||||
console.log(
|
||||
`Generating embeddings for batch ${Math.floor(i / BATCH_SIZE) + 1}/${Math.ceil(allChunks.length / BATCH_SIZE)}...`
|
||||
)
|
||||
|
||||
// Generate embeddings
|
||||
const embeddingVectors = await embeddings.embedDocuments(contents)
|
||||
|
||||
// Prepare records for insertion
|
||||
const records = batch.map((chunk, idx) => ({
|
||||
source_file: chunk.source_file,
|
||||
title: chunk.title,
|
||||
section_title: chunk.section_title,
|
||||
content: chunk.content,
|
||||
content_hash: chunk.content_hash,
|
||||
embedding: embeddingVectors[idx],
|
||||
metadata: chunk.metadata,
|
||||
}))
|
||||
|
||||
// Insert into Supabase
|
||||
const { error: insertError } = await supabase
|
||||
.from('knowledge_documents')
|
||||
.insert(records)
|
||||
|
||||
if (insertError) {
|
||||
console.error('Error inserting batch:', insertError)
|
||||
throw insertError
|
||||
}
|
||||
|
||||
processed += batch.length
|
||||
console.log(` Inserted ${processed}/${allChunks.length} documents`)
|
||||
}
|
||||
|
||||
console.log('\nIngestion complete!')
|
||||
console.log(`Total documents inserted: ${allChunks.length}`)
|
||||
|
||||
// Verify
|
||||
const { count } = await supabase
|
||||
.from('knowledge_documents')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
|
||||
console.log(`Documents in database: ${count}`)
|
||||
}
|
||||
|
||||
// Run if called directly
|
||||
ingest().catch((error) => {
|
||||
console.error('Ingestion failed:', error)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -1,6 +1,10 @@
|
||||
import { extensionRegistry } from './registry'
|
||||
import { receiptOcrExtension } from '@/extensions/receipt-ocr'
|
||||
import { aiCategorizationExtension } from '@/extensions/ai-categorization'
|
||||
import { pushNotificationsExtension } from '@/extensions/push-notifications'
|
||||
import { sruExportExtension } from '@/extensions/sru-export'
|
||||
import { neBilagaExtension } from '@/extensions/ne-bilaga'
|
||||
import { aiChatExtension } from '@/extensions/ai-chat'
|
||||
import type { Extension } from './types'
|
||||
|
||||
/**
|
||||
@@ -12,6 +16,10 @@ import type { Extension } from './types'
|
||||
const FIRST_PARTY_EXTENSIONS: Extension[] = [
|
||||
receiptOcrExtension,
|
||||
aiCategorizationExtension,
|
||||
pushNotificationsExtension,
|
||||
sruExportExtension,
|
||||
neBilagaExtension,
|
||||
aiChatExtension,
|
||||
]
|
||||
|
||||
let loaded = false
|
||||
|
||||
@@ -1,482 +0,0 @@
|
||||
import {
|
||||
UtensilsCrossed,
|
||||
BedDouble,
|
||||
Monitor,
|
||||
HardHat,
|
||||
HeartPulse,
|
||||
Store,
|
||||
ShoppingCart,
|
||||
Scissors,
|
||||
Truck,
|
||||
Scale,
|
||||
GraduationCap,
|
||||
Wheat,
|
||||
Film,
|
||||
Dumbbell,
|
||||
Wrench,
|
||||
UserPlus,
|
||||
Factory,
|
||||
Briefcase,
|
||||
PartyPopper,
|
||||
Building,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
|
||||
export type ModuleCategory = 'bokforing' | 'rapport' | 'import' | 'operativ'
|
||||
|
||||
export interface ModuleItem {
|
||||
slug: string
|
||||
name: string
|
||||
cat: ModuleCategory
|
||||
desc: string
|
||||
longDesc: string
|
||||
}
|
||||
|
||||
export interface Sector {
|
||||
slug: string
|
||||
name: string
|
||||
icon: LucideIcon
|
||||
description: string
|
||||
modules: ModuleItem[]
|
||||
}
|
||||
|
||||
export const CATEGORY_LABELS: Record<ModuleCategory, string> = {
|
||||
bokforing: 'Bokföring & Skatt',
|
||||
rapport: 'Branschrapporter',
|
||||
import: 'Smart import',
|
||||
operativ: 'Operativa moduler',
|
||||
}
|
||||
|
||||
export const sectors: Sector[] = [
|
||||
{
|
||||
slug: 'restaurang', name: 'Restaurang & Café', icon: UtensilsCrossed,
|
||||
description: 'Menyhantering, bordsbokning & kassa',
|
||||
modules: [
|
||||
{ slug: 'restaurangkontoplan', name: 'Restaurangkontoplan', cat: 'bokforing', desc: 'BAS-kontoplan förberedd för restaurang med uppdelning per intäktstyp', longDesc: 'BAS-kontoplan förberedd för restaurang med uppdelning per intäktstyp. Nyckelfunktioner: Separata konton för mat (3001), dryck (3002), alkohol (3003), take-away; auto-kontosättning från kassaimport' },
|
||||
{ slug: 'momssplit-mat-dryck', name: 'Momssplit mat/dryck', cat: 'bokforing', desc: 'Automatisk uppdelning av moms 12% (mat) vs 25% (alkohol, dryck)', longDesc: 'Automatisk uppdelning av moms 12% (mat) vs 25% (alkohol, dryck). Nyckelfunktioner: Regelmotor för momsfördelning, hantering av blandade kvitton, månadsavstämning per momssats' },
|
||||
{ slug: 'dagskassaavstamning', name: 'Dagskassaavstämning', cat: 'bokforing', desc: 'Stäm av kassans Z-rapport mot faktisk kassa och bank', longDesc: 'Stäm av kassans Z-rapport mot faktisk kassa och bank. Nyckelfunktioner: Z-rapport-import, avvikelseanalys (svinn, differens), verifikation per dag' },
|
||||
{ slug: 'tipsbokforing', name: 'Tipsbokföring', cat: 'bokforing', desc: 'Bokför dricks — arbetsgivaravgift på kartdricks, skattefri kontantdricks', longDesc: 'Bokför dricks korrekt — arbetsgivaravgift på kartdricks, skattefri kontantdricks. Nyckelfunktioner: Fördelning per anställd, arbetsgivaravgiftsberäkning, konto 7699/7010-koppling' },
|
||||
{ slug: 'personalliggare', name: 'Personalliggare', cat: 'bokforing', desc: 'Elektronisk personalliggare enligt krav från Skatteverket', longDesc: 'Elektronisk personalliggare enligt krav från Skatteverket. Nyckelfunktioner: Daglig registrering in/ut, exportformat för kontroll, koppla till lönekostnad' },
|
||||
{ slug: 'alkoholpunktskatt', name: 'Alkohol & Accis', cat: 'bokforing', desc: 'Punktskattehantering för alkoholservering', longDesc: 'Hantera punktskatt vid egenbrygd/import av alkohol. Nyckelfunktioner: Accisberäkning, lagerrapportering, Tullverket-format' },
|
||||
{ slug: 'representationsbokforing', name: 'Representationsbokföring', cat: 'bokforing', desc: 'Bokför representation med rätt avdragsgränser', longDesc: 'Bokför representation med rätt avdragsgränser. Nyckelfunktioner: Automatisk split avdragsgill/ej avdragsgill del, 90 SEK-gräns internt, momsavdrag' },
|
||||
{ slug: 'matkostnad', name: 'Matkostnad (Food Cost %)', cat: 'rapport', desc: 'Livsmedelskostnad som andel av matintäkt', longDesc: 'Livsmedelskostnad som andel av matintäkt. Nyckelfunktioner: Daglig/veckovis/månadsvis %, mål vs utfall, trendkurva, larmgräns' },
|
||||
{ slug: 'personalkostnad-vs-omsattning', name: 'Personalkostnad vs omsättning', cat: 'rapport', desc: 'Personalkostnad som andel av total omsättning', longDesc: 'Spåra personalkostnad som andel av total omsättning. Nyckelfunktioner: Målvärde (typiskt 30-35%), trend, jämförelse budget' },
|
||||
{ slug: 'svinnrapport', name: 'Svinnrapport', cat: 'rapport', desc: 'Analysera matsvinn i kronor och procent', longDesc: 'Analysera matsvinn i kronor och procent. Nyckelfunktioner: Svinn per kategori, kostnad, svinn-till-omsättning' },
|
||||
{ slug: 'revpash', name: 'RevPASH', cat: 'rapport', desc: 'Revenue per Available Seat Hour', longDesc: 'Revenue per available seat hour. Nyckelfunktioner: Intäkt per stolstimme, jämförelse per pass (lunch/middag), kapacitetsoptimering' },
|
||||
{ slug: 'z-rapport-import', name: 'Z-rapport-import', cat: 'import', desc: 'Importera dagskassarapport från kassasystem', longDesc: 'Importera dagskassarapport från kassasystem. Nyckelfunktioner: CSV/Excel från iZettle, Trivec, Square, Orderbird; auto-bokför med momssplit och kontosättning' },
|
||||
{ slug: 'leverantorsfaktura-import', name: 'Leverantörsfaktura-import', cat: 'import', desc: 'Importera leverantörsfakturor för matinköp', longDesc: 'Importera leverantörsfakturor för matinköp. Nyckelfunktioner: Parsning av leverantörsformat (Martin & Servera, Menigo), kostnadsfördelning per kategori' },
|
||||
{ slug: 'menyhantering', name: 'Menyhantering', cat: 'operativ', desc: 'Bygg och publicera menyer digitalt', longDesc: 'Bygg och publicera menyer digitalt. Nyckelfunktioner: Menybyggare, säsongsbyte, allergenmarkering, prissättning per kanal' },
|
||||
{ slug: 'bordsbokning', name: 'Bordsbokning', cat: 'operativ', desc: 'Online och manuell bordsreservation', longDesc: 'Online och manuell bordsreservation. Nyckelfunktioner: Bokningswidget, no-show-hantering, sittningsplanering, kapacitetsvy' },
|
||||
{ slug: 'receptkalkyl', name: 'Receptkalkyl', cat: 'operativ', desc: 'Beräkna kostnad per rätt utifrån inköpspriser', longDesc: 'Beräkna kostnad per rätt utifrån inköpspriser. Nyckelfunktioner: Receptbyggare, portionskostnad, marginalkalkyl, prisändringsanalys' },
|
||||
{ slug: 'personalschema', name: 'Personalschema', cat: 'operativ', desc: 'Schemalägg personal i skift', longDesc: 'Schemalägg personal i skift. Nyckelfunktioner: Skiftplanering, bytesförfrågningar, övertidsvarning, tillgänglighetsvy' },
|
||||
{ slug: 'leverantorsbestallning', name: 'Leverantörsbeställning', cat: 'operativ', desc: 'Beställ varor direkt till leverantörer', longDesc: 'Beställ varor direkt till leverantörer. Nyckelfunktioner: Beställningsmallar, leveransbevakning, prishistorik' },
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'hotell', name: 'Hotell & Boende', icon: BedDouble,
|
||||
description: 'Rumsbokning, gäster & städschema',
|
||||
modules: [
|
||||
{ slug: 'hotellkontoplan', name: 'Hotellkontoplan', cat: 'bokforing', desc: 'BAS-konton uppdelade per intäktstyp: rum, konferens, F&B', longDesc: 'BAS-konton uppdelade per intäktstyp: rum, konferens, F&B, tilläggstjänster. Nyckelfunktioner: Konton 3010 (rumsintäkter), 3020 (konferens), 3030 (F&B); auto-kontosättning' },
|
||||
{ slug: 'momssplit-boende-tjanst', name: 'Momssplit boende/tjänst', cat: 'bokforing', desc: 'Moms 12% på boende, 25% på frukost, minibar, spa', longDesc: 'Moms 12% på boende, 25% på frukost, minibar, spa, parkering. Nyckelfunktioner: Automatisk split vid kombinationspaket (halvpension, konferenspaket), momsavstämning' },
|
||||
{ slug: 'forskottsbetalning', name: 'Förskottsbetalning', cat: 'bokforing', desc: 'Bokför depositioner som förutbetald intäkt', longDesc: 'Bokför förskott/depositioner korrekt som förutbetald intäkt. Nyckelfunktioner: Konto 2420, automatisk intäktsföring vid incheckning, avbokningsbokföring' },
|
||||
{ slug: 'provisionshantering', name: 'Provisionshantering', cat: 'bokforing', desc: 'Bokför provision till Booking.com, Expedia, Airbnb', longDesc: 'Bokför provision till Booking.com, Expedia, Airbnb som kostnad. Nyckelfunktioner: Provisionsberäkning per kanal, nettointäkt per bokning, konto 6090' },
|
||||
{ slug: 'nattrevision', name: 'Nattrevision', cat: 'bokforing', desc: 'Automatiserad nattlig avstämning av dagens intäkter', longDesc: 'Automatiserad nattlig avstämning av dagens intäkter. Nyckelfunktioner: Dagsbokslut, kassaavstämning, no-show-debitering, verifikation per dygn' },
|
||||
{ slug: 'revpar', name: 'RevPAR', cat: 'rapport', desc: 'Revenue Per Available Room', longDesc: 'Revenue per available room — branschens viktigaste KPI. Nyckelfunktioner: Beläggningsgrad × ADR, jämförelse per månad/säsong, budget vs utfall' },
|
||||
{ slug: 'adr', name: 'ADR (Average Daily Rate)', cat: 'rapport', desc: 'Genomsnittlig rumspris', longDesc: 'Genomsnittlig rumspris. Nyckelfunktioner: Pris per rumstyp, kanalfördelning, säsongsanalys' },
|
||||
{ slug: 'belaggningsgrad', name: 'Beläggningsgrad', cat: 'rapport', desc: 'Procent uthyrda rum', longDesc: 'Procent uthyrda rum. Nyckelfunktioner: Daglig/veckovis/månadsvis, prognos baserad på bokningsläge' },
|
||||
{ slug: 'kanallonsamhet', name: 'Kanallönsamhet', cat: 'rapport', desc: 'Jämför nettointäkt per kanal efter provision', longDesc: 'Jämför nettointäkt per kanal efter provision. Nyckelfunktioner: Direktbokning vs OTA, provision %, rekommendation' },
|
||||
{ slug: 'pms-import', name: 'PMS-import', cat: 'import', desc: 'Importera data från PMS (Mews, Protel, Hotellinx)', longDesc: 'Importera boknings- och intäktsdata från PMS (Mews, Protel, Hotellinx). Nyckelfunktioner: CSV/Excel, nattrevisionsrapport, auto-bokföring med momssplit' },
|
||||
{ slug: 'channel-manager-rapport', name: 'Channel Manager-rapport', cat: 'import', desc: 'Importera provisionssammanställning per kanal', longDesc: 'Importera provisionssammanställning per kanal. Nyckelfunktioner: Booking/Expedia/Airbnb-rapporter, provisionsbokföring' },
|
||||
{ slug: 'rumsbokning', name: 'Rumsbokning', cat: 'operativ', desc: 'Central bokningsmotor för rum och stugor', longDesc: 'Central bokningsmotor. Nyckelfunktioner: Kalendervy, tillgänglighet, säsongspriser, min/max-nätter' },
|
||||
{ slug: 'gastregister', name: 'Gästregister', cat: 'operativ', desc: 'Gästprofiler och historik', longDesc: 'Gästprofiler och historik. Nyckelfunktioner: Kontaktuppgifter, besökshistorik, preferenser, GDPR' },
|
||||
{ slug: 'stadschema', name: 'Städschema', cat: 'operativ', desc: 'Planera och följ upp städning per rum', longDesc: 'Planera städning per rum. Nyckelfunktioner: Status per rum, checklista, personalfördelning' },
|
||||
{ slug: 'sasongsplanering', name: 'Säsongsplanering', cat: 'operativ', desc: 'Planera priser och kapacitet per säsong', longDesc: 'Priser och kapacitet per säsong. Nyckelfunktioner: Säsongsdefinition, dynamisk prissättning, beläggningsprognos' },
|
||||
{ slug: 'gastkommunikation', name: 'Gästkommunikation', cat: 'operativ', desc: 'Automatiserad kommunikation med gäster', longDesc: 'Automatiska meddelanden. Nyckelfunktioner: Bekräftelse, incheckning, utcheckning, recensionsförfrågan' },
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'tech', name: 'Tech & IT', icon: Monitor,
|
||||
description: 'Projekthantering, tid & SLA',
|
||||
modules: [
|
||||
{ slug: 'it-kontoplan', name: 'IT-kontoplan', cat: 'bokforing', desc: 'BAS-konton för konsult/SaaS-bolag', longDesc: 'BAS-konton anpassade för konsult/SaaS-bolag. Nyckelfunktioner: Konton för konsultintäkter, licensintäkter, SaaS MRR; auto-kontosättning' },
|
||||
{ slug: 'projektredovisning', name: 'Projektredovisning', cat: 'bokforing', desc: 'Successiv vinstavräkning per kundprojekt', longDesc: 'Intäkter och kostnader per kundprojekt. Nyckelfunktioner: Successiv vinstavräkning, WIP-beräkning (konto 1470), projektbokslut' },
|
||||
{ slug: 'fou-avdrag', name: 'FoU-avdrag', cat: 'bokforing', desc: 'Avdrag för forskning och utveckling', longDesc: 'Beräkna avdrag för forskning och utveckling. Nyckelfunktioner: Nedsättning av arbetsgivaravgifter, underlag per anställd, Skatteverket-krav' },
|
||||
{ slug: 'licensavskrivning', name: 'Licensavskrivning', cat: 'bokforing', desc: 'Avskrivning av mjukvarulicenser och IP', longDesc: 'Hantera avskrivning av mjukvarulicenser och IP. Nyckelfunktioner: Avskrivningsplan, konto 1010/1020, linjär vs degressiv avskrivning' },
|
||||
{ slug: 'eu-tjanstmoms', name: 'EU-tjänstemoms', cat: 'bokforing', desc: 'Omvänd skattskyldighet vid EU-försäljning', longDesc: 'Omvänd skattskyldighet vid försäljning till EU-kunder. Nyckelfunktioner: Automatisk B2B-kontroll (VAT-nummer), konto 2614, EU-försäljningsrapport' },
|
||||
{ slug: 'debiteringsgrad', name: 'Debiteringsgrad', cat: 'rapport', desc: 'Andel debiterbara timmar av total arbetstid', longDesc: 'Andel debiterbara timmar av total arbetstid. Nyckelfunktioner: Per konsult/team, mål vs utfall, trend' },
|
||||
{ slug: 'projektlonsamhet', name: 'Projektlönsamhet', cat: 'rapport', desc: 'Intäkt minus kostnad per projekt', longDesc: 'Intäkt minus kostnad per projekt. Nyckelfunktioner: Timkostnad vs debiterat, överdrag-varning, jämförelse budget' },
|
||||
{ slug: 'mrr-arr', name: 'MRR/ARR', cat: 'rapport', desc: 'Monthly/Annual Recurring Revenue', longDesc: 'Monthly/Annual Recurring Revenue för SaaS. Nyckelfunktioner: Tillväxttakt, churn, expansion revenue' },
|
||||
{ slug: 'tidrapport-import', name: 'Tidrapport-import', cat: 'import', desc: 'Importera från Harvest, Toggl, Clockify', longDesc: 'Importera tidrapporter från Harvest, Toggl, Clockify. Nyckelfunktioner: CSV/Excel, auto-koppling till projekt, fakturaunderlag' },
|
||||
{ slug: 'projekthantering', name: 'Projekthantering', cat: 'operativ', desc: 'Överblick och styrning av kundprojekt', longDesc: 'Överblick och styrning av kundprojekt. Nyckelfunktioner: Projekttavla, milstolpar, tidslinje, budgetuppföljning' },
|
||||
{ slug: 'tidrapportering', name: 'Tidrapportering', cat: 'operativ', desc: 'Logga tid per projekt och kund', longDesc: 'Logga tid per projekt och kund. Nyckelfunktioner: Timer, manuell inmatning, godkännandeflöde, debiterbara timmar' },
|
||||
{ slug: 'arendehantering', name: 'Ärendehantering', cat: 'operativ', desc: 'Supportärenden och buggspårning', longDesc: 'Supportärenden och buggspårning. Nyckelfunktioner: Ärendekö, SLA-prioritet, statusflöde, kundportal' },
|
||||
{ slug: 'resursplanering', name: 'Resursplanering', cat: 'operativ', desc: 'Allokera personal till projekt', longDesc: 'Allokera personal till projekt. Nyckelfunktioner: Beläggningsvy, kompetensfilter, överlappningsvarning' },
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'bygg', name: 'Bygg & Entreprenad', icon: HardHat,
|
||||
description: 'Projektkalkyler, underentreprenörer',
|
||||
modules: [
|
||||
{ slug: 'byggkontoplan', name: 'Byggkontoplan', cat: 'bokforing', desc: 'BAS-konton med projektkontosättning och UE-konton', longDesc: 'BAS-konton med projektkontosättning och UE-konton. Nyckelfunktioner: Separata konton per projekt, UE-kostnader (konto 4400), materialkostnader' },
|
||||
{ slug: 'omvand-skattskyldighet-bygg', name: 'Omvänd skattskyldighet bygg', cat: 'bokforing', desc: 'Omvänd moms vid UE-tjänster (6 kap. ML)', longDesc: 'Automatisk hantering av omvänd moms (6 kap. ML) vid UE-tjänster. Nyckelfunktioner: Automatisk kontroll köpare/säljare, fakturamärkning, momsdeklaration ruta 24' },
|
||||
{ slug: 'rot-avdrag', name: 'ROT-avdrag', cat: 'bokforing', desc: 'ROT-avdrag per kund — 30% max 50 000 SEK', longDesc: 'Beräkna och rapportera ROT-avdrag per kund. Nyckelfunktioner: Automatisk 30%-beräkning, maxbelopp 50 000 SEK, personnummervalidering, Skatteverket-format' },
|
||||
{ slug: 'successiv-vinstavrakning', name: 'Successiv vinstavräkning', cat: 'bokforing', desc: 'Periodisera intäkter efter färdigställandegrad', longDesc: 'Periodisera projektintäkter efter färdigställandegrad. Nyckelfunktioner: Beräkning av färdigställandegrad, intäktsperiodisering, konto 1470 WIP' },
|
||||
{ slug: 'ue-attestering', name: 'UE-attestering', cat: 'bokforing', desc: 'Attestflöde för UE-fakturor med F-skatt-kontroll', longDesc: 'Attestflöde för underentreprenörsfakturor. Nyckelfunktioner: Godkännandeflöde, delavstämning, F-skatt-kontroll, försäkringsverifiering' },
|
||||
{ slug: 'personalliggare-bygg', name: 'Personalliggare bygg', cat: 'bokforing', desc: 'Elektronisk personalliggare inkl. UE-personal', longDesc: 'Elektronisk personalliggare enligt Skatteverkets krav. Nyckelfunktioner: In/ut-registrering, export vid kontroll, UE-personal inkluderad' },
|
||||
{ slug: 'ata-bokforing', name: 'ÄTA-bokföring', cat: 'bokforing', desc: 'Koppla ÄTA-arbeten till projekt och faktura', longDesc: 'Koppla ÄTA-arbeten till rätt projekt och faktura. Nyckelfunktioner: ÄTA-registrering, kostnadseffekt på projektkalkyl, intäktsökning vid godkänd ÄTA' },
|
||||
{ slug: 'projektmarginal', name: 'Projektmarginal', cat: 'rapport', desc: 'Kalkylerad vs verklig marginal per projekt', longDesc: 'Kalkylerad vs verklig marginal per projekt. Nyckelfunktioner: Budget vs utfall, ÄTA-påverkan, UE-kostnad vs plan' },
|
||||
{ slug: 'ata-analys', name: 'ÄTA-analys', cat: 'rapport', desc: 'ÄTA som andel av ursprunglig kalkyl', longDesc: 'ÄTA som andel av ursprunglig kalkyl. Nyckelfunktioner: ÄTA-andel per projekt, typ av ÄTA, kundgodkännandestatus' },
|
||||
{ slug: 'likviditet-per-projekt', name: 'Likviditet per projekt', cat: 'rapport', desc: 'Kassaflöde per projekt', longDesc: 'Kassaflöde per projekt — fakturerat vs betalt vs kostnad. Nyckelfunktioner: Faktureringsrytm, kundfordringsålder, UE-skulder' },
|
||||
{ slug: 'ue-fakturaimport', name: 'UE-fakturaimport', cat: 'import', desc: 'Importera underentreprenörsfakturor', longDesc: 'Importera underentreprenörsfakturor. Nyckelfunktioner: Parsning, projektkoppling, F-skatt-kontroll, auto-kontosättning' },
|
||||
{ slug: 'materialkostnadsimport', name: 'Materialkostnadsimport', cat: 'import', desc: 'Importera materialinköp per projekt', longDesc: 'Importera materialinköp per projekt. Nyckelfunktioner: Leverantörsformat (Beijer, Ahlsell), projektfördelning' },
|
||||
{ slug: 'projektkalkyl', name: 'Projektkalkyl', cat: 'operativ', desc: 'Detaljerade kalkyler inför anbud', longDesc: 'Detaljerade kalkyler inför anbud. Nyckelfunktioner: Arbete, material, maskiner, UE-kostnader, marginalpåslag' },
|
||||
{ slug: 'ata-hantering', name: 'ÄTA-hantering', cat: 'operativ', desc: 'Spåra ändringar, tillägg och avgående', longDesc: 'Spåra ändringar, tillägg och avgående. Nyckelfunktioner: Registrering, kundgodkännande, koppling till faktura' },
|
||||
{ slug: 'byggdagbok', name: 'Byggdagbok', cat: 'operativ', desc: 'Digital byggdagbok per projekt', longDesc: 'Digital dagbok per projekt. Nyckelfunktioner: Dagliga noteringar, foto, väderpåverkan, personallog' },
|
||||
{ slug: 'ritningshantering', name: 'Ritningshantering', cat: 'operativ', desc: 'Versionshantering av projektritningar', longDesc: 'Versionshantering av projektritningar. Nyckelfunktioner: Uppladdning, versionsjämförelse, distribution till UE' },
|
||||
{ slug: 'materialbestallning', name: 'Materialbeställning', cat: 'operativ', desc: 'Beställ och spåra material till projekt', longDesc: 'Beställ och spåra material. Nyckelfunktioner: Materiallistor, leveransbevakning, kostnadsuppföljning' },
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'halsa', name: 'Hälsa & Sjukvård', icon: HeartPulse,
|
||||
description: 'Patientbokning, journal & recept',
|
||||
modules: [
|
||||
{ slug: 'vardkontoplan', name: 'Vårdkontoplan', cat: 'bokforing', desc: 'BAS-konton: region, privat, försäkring', longDesc: 'BAS-konton uppdelade per intäktstyp: regionavtal, privatpatienter, försäkring. Nyckelfunktioner: Konton 3010 (regionersättning), 3020 (patientavgift), 3030 (försäkring)' },
|
||||
{ slug: 'momsfrihet-sjukvard', name: 'Momsfrihet sjukvård', cat: 'bokforing', desc: 'Momsfria vårdtjänster vs momspliktiga sidotjänster', longDesc: 'Hantera momsfria vårdtjänster vs momspliktiga sidotjänster. Nyckelfunktioner: Automatisk momsbedömning per tjänsttyp, korrekt moms vid blandad verksamhet' },
|
||||
{ slug: 'forsakringsersattning', name: 'Försäkringsersättning', cat: 'bokforing', desc: 'Bokför försäkringsersättningar vid godkännande', longDesc: 'Bokför ersättningar från försäkringsbolag korrekt. Nyckelfunktioner: Intäktsföring vid godkännande, kundfordran per försäkringsbolag, avskrivning av nekade ärenden' },
|
||||
{ slug: 'frikort-hogkostnadsskydd', name: 'Frikort & Högkostnadsskydd', cat: 'bokforing', desc: 'Patienters frikort vid avgiftsberäkning', longDesc: 'Hantera patienters frikort vid avgiftsberäkning. Nyckelfunktioner: Automatisk kontroll mot högkostnadsgräns, rapportering till region' },
|
||||
{ slug: 'intakt-per-behandlare', name: 'Intäkt per behandlare', cat: 'rapport', desc: 'Omsättning fördelat per vårdgivare', longDesc: 'Omsättning fördelat per vårdgivare. Nyckelfunktioner: Per dag/vecka/månad, privatpatienter vs region, besökssnitt' },
|
||||
{ slug: 'patientmix', name: 'Patientmix', cat: 'rapport', desc: 'Fördelning region/privat/försäkring', longDesc: 'Fördelning region/privat/försäkring. Nyckelfunktioner: Intäktsfördelning per betalarkategori, trend, lönsamhet per typ' },
|
||||
{ slug: 'regionersattningsimport', name: 'Regionersättningsimport', cat: 'import', desc: 'Importera utbetalningsbesked från region', longDesc: 'Importera utbetalningsbesked från region. Nyckelfunktioner: Periodisering, kontosättning, avstämning mot avtal' },
|
||||
{ slug: 'forsakringsrapport-import', name: 'Försäkringsrapport-import', cat: 'import', desc: 'Importera beslut från försäkringsbolag', longDesc: 'Importera beslut och utbetalningar från försäkringsbolag. Nyckelfunktioner: Auto-bokföring godkända/nekade ärenden' },
|
||||
{ slug: 'patientbokning', name: 'Patientbokning', cat: 'operativ', desc: 'Bokning av besök och behandlingar', longDesc: 'Bokning av besök och behandlingar. Nyckelfunktioner: Onlinebokning, kalendervy, SMS-påminnelse, väntelista' },
|
||||
{ slug: 'journalhantering', name: 'Journalhantering', cat: 'operativ', desc: 'Strukturerade patientjournaler', longDesc: 'Strukturerade patientjournaler. Nyckelfunktioner: Anteckningar, diagnoser, vårdplan, GDPR-efterlevnad' },
|
||||
{ slug: 'remisshantering', name: 'Remisshantering', cat: 'operativ', desc: 'Skicka och ta emot remisser', longDesc: 'Skicka och ta emot remisser. Nyckelfunktioner: Mallar, statusspårning, svarsbevakning' },
|
||||
{ slug: 'kassasystem-patientavgifter', name: 'Kassasystem & Patientavgifter', cat: 'operativ', desc: 'Debitera patientbetalningar', longDesc: 'Debitera patientbetalningar. Nyckelfunktioner: Avgiftskategorier, frikort-koll, kortbetalning' },
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'detaljhandel', name: 'Detaljhandel', icon: Store,
|
||||
description: 'Lager, kassa & leverantörer',
|
||||
modules: [
|
||||
{ slug: 'detaljhandelskontoplan', name: 'Detaljhandelskontoplan', cat: 'bokforing', desc: 'BAS-konton per produktkategori och momssats', longDesc: 'BAS-konton per produktkategori och momssats. Nyckelfunktioner: Intäktskonton per varugrupp, separata konton 25%/12%/6% moms' },
|
||||
{ slug: 'lagervardering', name: 'Lagervärdering', cat: 'bokforing', desc: 'FIFO och vägt genomsnitt för bokslut', longDesc: 'Löpande beräkning av lagervärde för bokslut. Nyckelfunktioner: FIFO och vägt genomsnitt, månadsbokslut, inkuransbedömning, konto 1400' },
|
||||
{ slug: 'kassaavstamning-butik', name: 'Kassaavstämning butik', cat: 'bokforing', desc: 'Daglig avstämning kassa vs kassarapport', longDesc: 'Daglig avstämning kassa vs kassarapport. Nyckelfunktioner: Z-rapport-import, avvikelse, kontant/kort-split, verifikation per dag' },
|
||||
{ slug: 'svinnbokforing', name: 'Svinnbokföring', cat: 'bokforing', desc: 'Bokför varusvinn som kostnad (konto 4730)', longDesc: 'Bokför varusvinn korrekt som kostnad. Nyckelfunktioner: Svinnregistrering, konto 4730, period-uppföljning' },
|
||||
{ slug: 'personalliggare-butik', name: 'Personalliggare butik', cat: 'bokforing', desc: 'Personalliggare för livsmedelsbutik', longDesc: 'Elektronisk personalliggare (obligatorisk livsmedelsbutik). Nyckelfunktioner: In/ut-registrering, Skatteverket-export' },
|
||||
{ slug: 'bruttomarginal-per-varugrupp', name: 'Bruttomarginal per varugrupp', cat: 'rapport', desc: 'Inpris vs utpris per kategori', longDesc: 'Inpris vs utpris per kategori. Nyckelfunktioner: Marginal %, jämförelse kampanjperiod vs normal, trendvy' },
|
||||
{ slug: 'lageromsattningshastighet', name: 'Lageromsättningshastighet', cat: 'rapport', desc: 'Hur snabbt lagret omsätts', longDesc: 'Hur snabbt lagret omsätts. Nyckelfunktioner: Per varugrupp, kapitalbindning, beställningspunkt-förslag' },
|
||||
{ slug: 'svinnprocent', name: 'Svinnprocent', cat: 'rapport', desc: 'Svinn i kronor och procent', longDesc: 'Svinn i kronor och procent av omsättning. Nyckelfunktioner: Per kategori, trend, benchmark mot branschsnitt' },
|
||||
{ slug: 'forsaljning-per-m2', name: 'Försäljning per m²', cat: 'rapport', desc: 'Butikseffektivitet', longDesc: 'Butikseffektivitet. Nyckelfunktioner: Intäkt per kvadratmeter, jämförelse butiker' },
|
||||
{ slug: 'pos-z-rapport-import', name: 'POS Z-rapport-import', cat: 'import', desc: 'Dagskassarapport från kassasystem', longDesc: 'Dagskassarapport från kassasystem. Nyckelfunktioner: CSV/Excel från iZettle, Sitoo, Caspeco; auto-kontosättning med momssplit' },
|
||||
{ slug: 'inventeringsimport', name: 'Inventeringsimport', cat: 'import', desc: 'Importera inventeringsresultat', longDesc: 'Importera inventeringsresultat. Nyckelfunktioner: Avvikelseberäkning, lageruppdatering, svinnbokföring' },
|
||||
{ slug: 'leverantorsfaktura-import-butik', name: 'Leverantörsfaktura-import', cat: 'import', desc: 'Importera leverantörsfakturor', longDesc: 'Importera leverantörsfakturor. Nyckelfunktioner: Parsning, inprisuppdatering, auto-kontosättning' },
|
||||
{ slug: 'lagerhantering', name: 'Lagerhantering', cat: 'operativ', desc: 'Lagernivåer och rörelser', longDesc: 'Lagernivåer och rörelser. Nyckelfunktioner: Inleverans, bristlista, beställningspunkt, lagerplatser' },
|
||||
{ slug: 'kampanjer-rabatter', name: 'Kampanjer & Rabatter', cat: 'operativ', desc: 'Priskampanjer och erbjudanden', longDesc: 'Priskampanjer och erbjudanden. Nyckelfunktioner: Rabattregler, tidsbegränsade kampanjer, marginalbevakning' },
|
||||
{ slug: 'kundklubb', name: 'Kundklubb', cat: 'operativ', desc: 'Lojalitetsprogram med poäng', longDesc: 'Lojalitetsprogram. Nyckelfunktioner: Poängintjäning, belöningar, riktade kampanjer' },
|
||||
{ slug: 'prishantering', name: 'Prishantering', cat: 'operativ', desc: 'Prissättning med marginalbevakning', longDesc: 'Centraliserad prissättning. Nyckelfunktioner: Inpris vs utpris, marginalkalkyl, prisändringslogg' },
|
||||
{ slug: 'butiksdrift-schema', name: 'Butiksdrift & Schema', cat: 'operativ', desc: 'Bemanning och öppettider', longDesc: 'Bemanning och öppettider. Nyckelfunktioner: Schemaläggning, timrapport, budgetjämförelse' },
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'ehandel', name: 'E-handel', icon: ShoppingCart,
|
||||
description: 'Ordrar, frakt & returer',
|
||||
modules: [
|
||||
{ slug: 'ehandelskontoplan', name: 'E-handelskontoplan', cat: 'bokforing', desc: 'BAS-konton för order, frakt, returer, avgifter', longDesc: 'BAS-konton för orderintäkter, fraktintäkt, returer, plattformsavgifter. Nyckelfunktioner: Konton 3001 (varuförsäljning), 3540 (fraktintäkt), 3740 (returkostnad), 6590 (plattformsavgift)' },
|
||||
{ slug: 'lagervardering-ehandel', name: 'Lagervärdering', cat: 'bokforing', desc: 'FIFO/vägt genomsnitt för e-handel', longDesc: 'Löpande lagervärde för bokslut. Nyckelfunktioner: FIFO, vägt genomsnitt, månadsbokslut, inkuransbedömning' },
|
||||
{ slug: 'returbokforing', name: 'Returbokföring', cat: 'bokforing', desc: 'Automatisk kreditering vid returer', longDesc: 'Automatisk kreditering vid returer. Nyckelfunktioner: Lageråterföring, konto 3740, momskorrigering, periodsummering' },
|
||||
{ slug: 'multi-currency', name: 'Multi-currency', cat: 'bokforing', desc: 'Bokför utländsk valuta i SEK', longDesc: 'Bokför försäljning i utländsk valuta i SEK. Nyckelfunktioner: Valutaomvandling via Riksbanken-kurs, kursdifferens konto 3960/7960' },
|
||||
{ slug: 'eu-moms-oss', name: 'EU-moms (OSS)', cat: 'bokforing', desc: 'One Stop Shop för EU-distansförsäljning', longDesc: 'One Stop Shop för moms vid EU-distansförsäljning. Nyckelfunktioner: Moms per destinationsland, OSS-deklarationsunderlag, tröskelvärdebevakning' },
|
||||
{ slug: 'plattformsavgifter', name: 'Plattformsavgifter', cat: 'bokforing', desc: 'Bokför Shopify/Stripe/Klarna-avgifter', longDesc: 'Bokför avgifter från Shopify, Stripe, Klarna korrekt. Nyckelfunktioner: Bruttoförsäljning → avgift → nettoutbetalning, periodisering' },
|
||||
{ slug: 'returprocent', name: 'Returprocent', cat: 'rapport', desc: 'Returer som andel av försäljning', longDesc: 'Returer som andel av försäljning. Nyckelfunktioner: Per produktkategori, kostnad per retur, trend' },
|
||||
{ slug: 'genomsnittligt-ordervarde', name: 'Genomsnittligt ordervärde', cat: 'rapport', desc: 'Snittorder i kronor', longDesc: 'Snittorder i kronor. Nyckelfunktioner: Trend, kanalfördelning, kampanjeffekt' },
|
||||
{ slug: 'kanalfordelning', name: 'Kanalfördelning', cat: 'rapport', desc: 'Intäkt per försäljningskanal', longDesc: 'Intäkt per försäljningskanal. Nyckelfunktioner: Egen webb vs marknadsplats, marginal per kanal' },
|
||||
{ slug: 'fraktkostnad-vs-intakt', name: 'Fraktkostnad vs intäkt', cat: 'rapport', desc: 'Fraktnetto per order', longDesc: 'Fraktnetto per order. Nyckelfunktioner: Fraktsubvention, break-even-analys' },
|
||||
{ slug: 'shopify-woo-import', name: 'Shopify/Woo-import', cat: 'import', desc: 'Importera ordrar från Shopify/WooCommerce', longDesc: 'Importera ordrar och utbetalningsrapporter. Nyckelfunktioner: CSV-export, auto-bokföring per order inkl. moms, frakt, rabatter' },
|
||||
{ slug: 'klarna-rapport-import', name: 'Klarna-rapport-import', cat: 'import', desc: 'Importera Klarna-utbetalningar', longDesc: 'Importera Klarna-utbetalningar och avgifter. Nyckelfunktioner: Bruttobelopp, avgiftsavdrag, nettoutbetalning, periodmatchning' },
|
||||
{ slug: 'fraktrapport-import', name: 'Fraktrapport-import', cat: 'import', desc: 'Importera fraktkostnader per order', longDesc: 'Importera fraktkostnader per order. Nyckelfunktioner: Postnord/DHL/Budbee-rapporter, kostnadsfördelning' },
|
||||
{ slug: 'orderhantering', name: 'Orderhantering', cat: 'operativ', desc: 'Central ordervy från alla kanaler', longDesc: 'Central ordervy. Nyckelfunktioner: Orderstatus, plocklista, batch-hantering' },
|
||||
{ slug: 'frakthantering', name: 'Frakthantering', cat: 'operativ', desc: 'Hantera frakt med transportörer', longDesc: 'Hantera frakt. Nyckelfunktioner: Fraktsedlar, spårning, returlogistik' },
|
||||
{ slug: 'returhantering-operativ', name: 'Returhantering', cat: 'operativ', desc: 'Hantera returer och byten', longDesc: 'Hantera returer och byten. Nyckelfunktioner: Returgodkännande, lageråterföring, återbetalning' },
|
||||
{ slug: 'produktdatahantering', name: 'Produktdatahantering', cat: 'operativ', desc: 'Central produktinformation', longDesc: 'Central produktinfo. Nyckelfunktioner: Beskrivningar, bilder, attribut' },
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'frisor', name: 'Frisör & Skönhet', icon: Scissors,
|
||||
description: 'Tidsbokning, kunder & produkter',
|
||||
modules: [
|
||||
{ slug: 'salongkontoplan', name: 'Salongkontoplan', cat: 'bokforing', desc: 'BAS-konton: behandling, produktförsäljning, presentkort', longDesc: 'BAS-konton uppdelade: tjänsteintäkter, produktförsäljning, presentkort. Nyckelfunktioner: Konton 3010 (behandling), 3020 (produktförsäljning), 2420 (presentkortsskuld)' },
|
||||
{ slug: 'provisionsberakning', name: 'Provisionsberäkning', cat: 'bokforing', desc: 'Provision per frisör/terapeut som lönekostnad', longDesc: 'Räkna provision per frisör/terapeut och bokför som lönekostnad. Nyckelfunktioner: Procent på tjänst/produkt, trappsteg, konto 7010/7210, månadssammanställning' },
|
||||
{ slug: 'presentkort-som-skuld', name: 'Presentkort som skuld', cat: 'bokforing', desc: 'Presentkort som förutbetald intäkt', longDesc: 'Bokför sålda presentkort som förutbetald intäkt tills inlösen. Nyckelfunktioner: Konto 2420 (skuld), intäktsföring vid inlösen, förfallohantering' },
|
||||
{ slug: 'kassaavstamning-salong', name: 'Kassaavstämning salong', cat: 'bokforing', desc: 'Daglig avstämning kort/Swish/kontant', longDesc: 'Daglig avstämning kort/Swish/kontant. Nyckelfunktioner: Z-rapport, avvikelse, verifikation per dag' },
|
||||
{ slug: 'personalliggare-salong', name: 'Personalliggare salong', cat: 'bokforing', desc: 'Personalliggare enligt Skatteverket', longDesc: 'Elektronisk personalliggare enligt Skatteverket. Nyckelfunktioner: In/ut-registrering, export vid kontroll' },
|
||||
{ slug: 'intakt-per-stol', name: 'Intäkt per stol', cat: 'rapport', desc: 'Omsättning per arbetsstation', longDesc: 'Omsättning per arbetsstation. Nyckelfunktioner: Per dag/vecka, beläggningsgrad, optimeringsförslag' },
|
||||
{ slug: 'provisionsandel', name: 'Provisionsandel', cat: 'rapport', desc: 'Provisionskostnad som andel av intäkt', longDesc: 'Provisionskostnad som andel av tjänsteintäkt. Nyckelfunktioner: Per frisör, trend, lönsamhet per anställd' },
|
||||
{ slug: 'produktforsaljning-per-besok', name: 'Produktförsäljning per besök', cat: 'rapport', desc: 'Merförsäljning av produkter', longDesc: 'Merförsäljning av produkter. Nyckelfunktioner: Snitt per kund, andel av omsättning' },
|
||||
{ slug: 'kassarapport-import-salong', name: 'Kassarapport-import', cat: 'import', desc: 'Importera dagskassa från iZettle', longDesc: 'Importera dagskassa från kassasystem. Nyckelfunktioner: CSV från iZettle/Zettle, auto-kontosättning' },
|
||||
{ slug: 'bokningssystem-import', name: 'Bokningssystem-import', cat: 'import', desc: 'Importera från Timma, Fresha, Planway', longDesc: 'Importera bokningsdata och intäkter. Nyckelfunktioner: Export från Timma, Fresha, Planway; intäktsfördelning per behandlare' },
|
||||
{ slug: 'tidsbokning', name: 'Tidsbokning', cat: 'operativ', desc: 'Online och receptionsbokning', longDesc: 'Online och receptionsbokning. Nyckelfunktioner: Bokningswidget, SMS-påminnelse, väntelista' },
|
||||
{ slug: 'kundkort', name: 'Kundkort', cat: 'operativ', desc: 'Profil med behandlingshistorik', longDesc: 'Profil med behandlingshistorik. Nyckelfunktioner: Favoritbehandlare, allergier, färgrecept' },
|
||||
{ slug: 'sms-paminnelser', name: 'SMS-påminnelser', cat: 'operativ', desc: 'Automatiska bokningspåminnelser', longDesc: 'Automatiska påminnelser. Nyckelfunktioner: Bokningspåminnelse, återbesökspåminnelse' },
|
||||
{ slug: 'skiftschema-salong', name: 'Skiftschema', cat: 'operativ', desc: 'Schemalägg personal med stolsfördelning', longDesc: 'Schemalägg personal. Nyckelfunktioner: Stolsfördelning, ledighetsönskemål' },
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'transport', name: 'Transport & Logistik', icon: Truck,
|
||||
description: 'Flotta, rutter & leveranser',
|
||||
modules: [
|
||||
{ slug: 'transportkontoplan', name: 'Transportkontoplan', cat: 'bokforing', desc: 'BAS-konton: bränsle, leasing, underhåll, försäkring', longDesc: 'BAS-konton per kostnadskategori: bränsle, leasing, underhåll, försäkring. Nyckelfunktioner: Konton per fordon möjligt, kostnadskonton 5610/5620/5611/5612' },
|
||||
{ slug: 'fordonsavskrivning', name: 'Fordonsavskrivning', cat: 'bokforing', desc: 'Avskrivning per fordon (räkenskapsenlig)', longDesc: 'Hantera avskrivning per fordon. Nyckelfunktioner: Linjär/degressiv, räkenskapsenlig avskrivning, konto 1240/7832' },
|
||||
{ slug: 'leasinghantering', name: 'Leasinghantering', cat: 'bokforing', desc: 'Operationell vs finansiell leasing', longDesc: 'Bokför leasing korrekt (operationell vs finansiell). Nyckelfunktioner: Leasingavgift som driftskostnad, restvärde, avtalsperiod' },
|
||||
{ slug: 'trangselskatt', name: 'Trängselskatt', cat: 'bokforing', desc: 'Avdragsgill kostnad (konto 5615)', longDesc: 'Bokför trängselskatt som avdragsgill kostnad. Nyckelfunktioner: Auto-kontosättning konto 5615, per fordon, periodsammanställning' },
|
||||
{ slug: 'milersattning-vs-faktisk-kostnad', name: 'Milersättning vs faktisk kostnad', cat: 'bokforing', desc: 'Jämför schablon (25 kr/mil) mot verklig kostnad', longDesc: 'Jämför schablon (25 kr/mil) mot verklig fordonskostnad. Nyckelfunktioner: Per fordon, rekommendation avdragsmetod, Skatteverket-schabloner' },
|
||||
{ slug: 'kostnad-per-mil', name: 'Kostnad per mil', cat: 'rapport', desc: 'Total driftskostnad per mil och fordon', longDesc: 'Total driftskostnad per mil och per fordon. Nyckelfunktioner: Bränsle + underhåll + avskrivning + försäkring, trend' },
|
||||
{ slug: 'intakt-per-fordon', name: 'Intäkt per fordon', cat: 'rapport', desc: 'Omsättning fördelad per fordon', longDesc: 'Omsättning fördelad per fordon. Nyckelfunktioner: Lönsamhet per fordon, underutnyttjade resurser' },
|
||||
{ slug: 'bransleeffektivitet', name: 'Bränsleeffektivitet', cat: 'rapport', desc: 'Bränsleförbrukning per fordon', longDesc: 'Bränsleförbrukning per fordon och period. Nyckelfunktioner: Liter/mil, trend, avvikelse mot förväntat' },
|
||||
{ slug: 'branslekort-import', name: 'Bränslekort-import', cat: 'import', desc: 'Importera tankningar (Circle K, OKQ8, Preem)', longDesc: 'Importera tankningar från bränslekort (Circle K, OKQ8, Preem). Nyckelfunktioner: CSV, auto-kontosättning per fordon' },
|
||||
{ slug: 'vagtulls-import', name: 'Vägtulls-import', cat: 'import', desc: 'Importera trängselskatt (Transportstyrelsen)', longDesc: 'Importera trängselskatt och broavgifter. Nyckelfunktioner: Transportstyrelsen-format, auto-kontosättning' },
|
||||
{ slug: 'flottahantering', name: 'Flottahantering', cat: 'operativ', desc: 'Fordonsregister och översikt', longDesc: 'Fordonsregister och översikt. Nyckelfunktioner: Besiktning, försäkring, leasingperioder' },
|
||||
{ slug: 'ruttplanering', name: 'Ruttplanering', cat: 'operativ', desc: 'Optimera körrutter', longDesc: 'Optimera körrutter. Nyckelfunktioner: Adressklustring, tidsestimering, kapacitet' },
|
||||
{ slug: 'leveranssparning', name: 'Leveransspårning', cat: 'operativ', desc: 'Realtidsspårning av leveranser', longDesc: 'Realtidsspårning av leveranser. Nyckelfunktioner: GPS, ETA, leveransbevis, kundnotifiering' },
|
||||
{ slug: 'fordonsunderhall', name: 'Fordonsunderhåll', cat: 'operativ', desc: 'Planera service och reparationer', longDesc: 'Planera service och reparationer. Nyckelfunktioner: Serviceintervall, reservdelshistorik' },
|
||||
{ slug: 'chauforshantering', name: 'Chaufförshantering', cat: 'operativ', desc: 'Personal- och behörighetsregister', longDesc: 'Personal och behörigheter. Nyckelfunktioner: Körkortsklass, YKB, timrapport' },
|
||||
{ slug: 'fraktsedlar-dokument', name: 'Fraktsedlar & Dokument', cat: 'operativ', desc: 'Generera fraktdokument', longDesc: 'Generera fraktdokument. Nyckelfunktioner: CMR, följesedel, digital signatur' },
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'juridik', name: 'Juridik & Redovisning', icon: Scale,
|
||||
description: 'Ärenden, tidrapport & klienter',
|
||||
modules: [
|
||||
{ slug: 'juristkontoplan', name: 'Juristkontoplan', cat: 'bokforing', desc: 'BAS-konton: arvoden, klientmedel, utlägg', longDesc: 'BAS-konton för arvodesintäkter, klientmedel, utlägg. Nyckelfunktioner: Konton 3010 (arvoden), 1690 (klientmedel i förvaltning), 6990 (utlägg för klients räkning)' },
|
||||
{ slug: 'klientmedelskonto', name: 'Klientmedelskonto', cat: 'bokforing', desc: 'Separat hantering av klientmedel', longDesc: 'Separat hantering av klientmedel (disciplinnämndsregler). Nyckelfunktioner: Konto 1690/2890, klientmedelslista, avstämning mot bankkonto, rapportering' },
|
||||
{ slug: 'wip-vardering', name: 'WIP-värdering', cat: 'bokforing', desc: 'Pågående arbete ej fakturerat', longDesc: 'Värdera pågående arbete (nedlagd tid ej fakturerad). Nyckelfunktioner: Beräkning av WIP, konto 1470, månatlig omvärdering' },
|
||||
{ slug: 'a-conto-bokforing', name: 'A conto-bokföring', cat: 'bokforing', desc: 'A conto-fakturor och slutavräkning', longDesc: 'Bokför a conto-fakturor och slutavräkning. Nyckelfunktioner: Förskottsbokföring konto 2420, avräkning vid slutfaktura' },
|
||||
{ slug: 'debiteringsgrad-juridik', name: 'Debiteringsgrad', cat: 'rapport', desc: 'Debiterbara timmar av total arbetstid', longDesc: 'Andel debiterbara timmar av total arbetstid. Nyckelfunktioner: Per jurist/konsult, mål vs utfall, trend' },
|
||||
{ slug: 'realisationsgrad', name: 'Realisationsgrad', cat: 'rapport', desc: 'Fakturerat vs tidvärde', longDesc: 'Fakturerat belopp vs tidvärde (nedskrivning av tid). Nyckelfunktioner: Per ärende, mönsteranalys, lönsamhet per ärendetyp' },
|
||||
{ slug: 'genomsnittlig-timintakt', name: 'Genomsnittlig timintäkt', cat: 'rapport', desc: 'Faktiskt fakturerat per timme', longDesc: 'Faktiskt fakturerat per timme. Nyckelfunktioner: Per jurist, klient, ärendetyp; jämförelse mot prislista' },
|
||||
{ slug: 'wip-rapport', name: 'WIP-rapport', cat: 'rapport', desc: 'Pågående arbete med åldringsanalys', longDesc: 'Totalt pågående arbete ej fakturerat. Nyckelfunktioner: Per klient/ärende, åldringsanalys, risk för nedskrivning' },
|
||||
{ slug: 'tidrapport-import-juridik', name: 'Tidrapport-import', cat: 'import', desc: 'Importera från Clio, Maconomy', longDesc: 'Importera tid från ärendehanteringssystem. Nyckelfunktioner: CSV från Time, Clio, Maconomy; koppling till ärende och klient' },
|
||||
{ slug: 'arendehantering-juridik', name: 'Ärendehantering', cat: 'operativ', desc: 'Administrera klientärenden', longDesc: 'Administrera klientärenden. Nyckelfunktioner: Ärendetyper, statusflöde, deadline-bevakning' },
|
||||
{ slug: 'tidrapportering-juridik', name: 'Tidrapportering', cat: 'operativ', desc: 'Registrera tid per ärende', longDesc: 'Registrera tid per ärende. Nyckelfunktioner: Debiterbara vs interna timmar, godkännande' },
|
||||
{ slug: 'dokumenthantering', name: 'Dokumenthantering', cat: 'operativ', desc: 'Versionshanterade dokument', longDesc: 'Versionshanterade dokument per ärende. Nyckelfunktioner: Mallbibliotek, e-signering, klientdelning' },
|
||||
{ slug: 'deadlinebevakning', name: 'Deadlinebevakning', cat: 'operativ', desc: 'Påminnelser för tidsfrister', longDesc: 'Påminnelser för tidsfrister. Nyckelfunktioner: Preskriptionstider, eskaleringsregler' },
|
||||
{ slug: 'intressekonfliktskontroll', name: 'Intressekonfliktskontroll', cat: 'operativ', desc: 'Kontrollera jäv vid nya ärenden', longDesc: 'Kontrollera jäv vid nya ärenden. Nyckelfunktioner: Partsregister, automatisk sökning, logg' },
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'utbildning', name: 'Utbildning & Förskola', icon: GraduationCap,
|
||||
description: 'Schema, elever & närvaro',
|
||||
modules: [
|
||||
{ slug: 'utbildningskontoplan', name: 'Utbildningskontoplan', cat: 'bokforing', desc: 'BAS-konton: kommunal peng, avgift, statsbidrag', longDesc: 'BAS-konton för kommunal peng, statsbidrag, föräldraavgift. Nyckelfunktioner: Konton 3010 (kommunal ersättning), 3020 (föräldraavgift), 3910 (statsbidrag)' },
|
||||
{ slug: 'maxtaxa-fakturering', name: 'Maxtaxa-fakturering', cat: 'bokforing', desc: 'Avgift enligt maxtaxa-reglerna', longDesc: 'Automatisk beräkning av avgift enligt maxtaxa-reglerna. Nyckelfunktioner: Inkomstkontroll, syskonrabatt, schema-avdrag, frånvaroavdrag' },
|
||||
{ slug: 'statsbidragsperiodisering', name: 'Statsbidragsperiodisering', cat: 'bokforing', desc: 'Periodisera statsbidrag korrekt', longDesc: 'Periodisera statsbidrag korrekt över den period de avser. Nyckelfunktioner: Konto 2970 (förutbetald intäkt), villkorsuppfyllnad, återbetalningsrisk' },
|
||||
{ slug: 'momsfrihet-utbildning', name: 'Momsfrihet utbildning', cat: 'bokforing', desc: 'Momsfria utbildningstjänster', longDesc: 'Utbildningstjänster är momsfria. Nyckelfunktioner: Korrekt momsbedömning, sidoverksamhet (cafeteria, uthyrning) kan vara momspliktig' },
|
||||
{ slug: 'kostnad-per-barn-elev', name: 'Kostnad per barn/elev', cat: 'rapport', desc: 'Driftskostnad per inskrivet barn', longDesc: 'Total driftskostnad per inskrivet barn. Nyckelfunktioner: Personal, lokal, mat, material; jämförelse med kommunal peng' },
|
||||
{ slug: 'personaltathet', name: 'Personaltäthet', cat: 'rapport', desc: 'Antal barn per heltidsanställd', longDesc: 'Antal barn per heltidsanställd. Nyckelfunktioner: Trend, lagkrav (förskolans riktlinje), schemaanalys' },
|
||||
{ slug: 'kommunal-peng-import', name: 'Kommunal peng-import', cat: 'import', desc: 'Importera utbetalningsbesked', longDesc: 'Importera utbetalningsbesked från kommun. Nyckelfunktioner: Periodisering, kontosättning, avstämning' },
|
||||
{ slug: 'schemalaeggning', name: 'Schemaläggning', cat: 'operativ', desc: 'Bygg undervisningsschema', longDesc: 'Undervisningsschema. Nyckelfunktioner: Per klass/lärare/sal, konfliktkontroll' },
|
||||
{ slug: 'elevregister', name: 'Elevregister', cat: 'operativ', desc: 'Elev- och barnuppgifter', longDesc: 'Elev- och barnuppgifter. Nyckelfunktioner: Kontakt, vårdnadshavare, allergi, specialbehov' },
|
||||
{ slug: 'narvarohantering', name: 'Närvarohantering', cat: 'operativ', desc: 'Registrera frånvaro', longDesc: 'Registrera frånvaro. Nyckelfunktioner: Daglig närvaro, mönsteranalys, föräldraavisering' },
|
||||
{ slug: 'foraldrakommunikation', name: 'Föräldrakommunikation', cat: 'operativ', desc: 'Meddelanden till vårdnadshavare', longDesc: 'Meddelanden till vårdnadshavare. Nyckelfunktioner: Push/e-post/SMS, nyhetsflöde' },
|
||||
{ slug: 'matsedel-allergikost', name: 'Matsedel & Allergikost', cat: 'operativ', desc: 'Publicera matsedel', longDesc: 'Publicera matsedel. Nyckelfunktioner: Veckomatsdel, allergiflaggor' },
|
||||
{ slug: 'vikariebokning', name: 'Vikariebokning', cat: 'operativ', desc: 'Hantera vikarier', longDesc: 'Hantera vikarier. Nyckelfunktioner: Vikariepool, snabbbokning, kostnadsspårning' },
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'jordbruk', name: 'Jordbruk & Livsmedel', icon: Wheat,
|
||||
description: 'Skörd, djurhållning & spårbarhet',
|
||||
modules: [
|
||||
{ slug: 'jordbrukskontoplan', name: 'Jordbrukskontoplan', cat: 'bokforing', desc: 'BAS-konton: gröda, djur, skog, EU-stöd', longDesc: 'BAS-konton anpassade för gröda, djur, skogsbruk, EU-stöd. Nyckelfunktioner: Konton 3010 (växtodling), 3020 (djurförsäljning), 3910 (EU-stöd), 1280 (biologiska tillgångar)' },
|
||||
{ slug: 'skogskonto', name: 'Skogskonto', cat: 'bokforing', desc: 'Insättning/uttag skogskonto (max 60%)', longDesc: 'Hantera insättning och uttag från skogskonto. Nyckelfunktioner: Insättning (max 60% av skogsintäkt), beskattning vid uttag, konto 1760' },
|
||||
{ slug: 'expansionsfond', name: 'Expansionsfond', cat: 'bokforing', desc: 'Avsättning för enskild firma (20.6% skatt)', longDesc: 'Beräkna avsättning till expansionsfond för enskild firma. Nyckelfunktioner: Beräkning av underlag, expansionsfondsskatt 20,6%, NE-bilaga' },
|
||||
{ slug: 'rantefordelning', name: 'Räntefördelning', cat: 'bokforing', desc: 'Positiv/negativ räntefördelning', longDesc: 'Positiv/negativ räntefördelning på kapital. Nyckelfunktioner: Kapitalunderlag, statslåneränta, inkomstfördelning tjänst/kapital' },
|
||||
{ slug: 'eu-stod-som-intakt', name: 'EU-stöd som intäkt', cat: 'bokforing', desc: 'Periodisera EU-jordbruksstöd', longDesc: 'Periodisera EU-jordbruksstöd korrekt. Nyckelfunktioner: Intäktsföring per period, villkorsskuld, jämförelse ansökt vs utbetalt' },
|
||||
{ slug: 'biologiska-tillgangar', name: 'Biologiska tillgångar', cat: 'bokforing', desc: 'Värdera djur och grödor', longDesc: 'Värdera djur och grödor som tillgång. Nyckelfunktioner: Anskaffningsvärde vs verkligt värde, konto 1280, årlig omvärdering' },
|
||||
{ slug: 'momssplit-livsmedel', name: 'Momssplit livsmedel', cat: 'bokforing', desc: 'Moms 12% livsmedel, 25% övrigt', longDesc: 'Moms 12% vid livsmedelsförsäljning, 25% övriga varor. Nyckelfunktioner: Automatisk momskod per produkttyp' },
|
||||
{ slug: 'avkastning-per-hektar', name: 'Avkastning per hektar', cat: 'rapport', desc: 'Intäkt minus kostnad per yta', longDesc: 'Intäkt minus kostnad per odlad yta. Nyckelfunktioner: Per gröda, säsongsjämförelse, vädereffekt' },
|
||||
{ slug: 'bidragsberoende', name: 'Bidragsberoende', cat: 'rapport', desc: 'EU-stöd som andel av intäkt', longDesc: 'EU-stöd som andel av total intäkt. Nyckelfunktioner: Trend, risk vid policyändring' },
|
||||
{ slug: 'djurkostnad-per-enhet', name: 'Djurkostnad per enhet', cat: 'rapport', desc: 'Total kostnad per djur', longDesc: 'Total kostnad per djur (foder, veterinär, stall). Nyckelfunktioner: Per djurtyp, jämförelse med intäkt vid slakt/försäljning' },
|
||||
{ slug: 'sam-utbetalningsimport', name: 'SAM-utbetalningsimport', cat: 'import', desc: 'Importera EU-stödutbetalningar', longDesc: 'Importera utbetalningsbesked för EU-jordbruksstöd. Nyckelfunktioner: Jordbruksverket-format, periodisering, auto-kontosättning' },
|
||||
{ slug: 'skordeplanering', name: 'Skördeplanering', cat: 'operativ', desc: 'Planera odlingscykler', longDesc: 'Planera odlingscykler. Nyckelfunktioner: Fältregister, grödor, sådd/skörd-datum' },
|
||||
{ slug: 'djurhallning', name: 'Djurhållning', cat: 'operativ', desc: 'Register och uppföljning', longDesc: 'Register och uppföljning av besättning. Nyckelfunktioner: Individregister, hälsologg, Jordbruksverket-rapportering' },
|
||||
{ slug: 'sparbarhet', name: 'Spårbarhet', cat: 'operativ', desc: 'Spåra livsmedel från jord till bord', longDesc: 'Spåra livsmedel från jord till bord. Nyckelfunktioner: Batchnummer, leveranskedja, återkallning' },
|
||||
{ slug: 'maskinlogg', name: 'Maskinlogg', cat: 'operativ', desc: 'Drifttid och underhåll', longDesc: 'Drifttid och underhåll. Nyckelfunktioner: Maskinregister, timlogg, servicepåminnelse' },
|
||||
{ slug: 'certifieringar', name: 'Certifieringar', cat: 'operativ', desc: 'KRAV, ekologisk certifiering', longDesc: 'KRAV, ekologisk, EU-ekologisk. Nyckelfunktioner: Certifikatregister, giltighetstid, revisionsförberedelse' },
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'media', name: 'Media & Kommunikation', icon: Film,
|
||||
description: 'Projekt, innehåll & publicering',
|
||||
modules: [
|
||||
{ slug: 'mediakontoplan', name: 'Mediakontoplan', cat: 'bokforing', desc: 'BAS-konton: projekt, licens, royalty', longDesc: 'BAS-konton per intäktstyp: projektarvoden, licensintäkt, royalties. Nyckelfunktioner: Konton 3010 (projektintäkt), 3020 (licensintäkt), 3910 (royalty)' },
|
||||
{ slug: 'projektredovisning-media', name: 'Projektredovisning', cat: 'bokforing', desc: 'Intäkter och kostnader per kundprojekt', longDesc: 'Intäkter och kostnader per kundprojekt. Nyckelfunktioner: Budget vs utfall, WIP vid pågående projekt, projektbokslut' },
|
||||
{ slug: 'freelancer-bokforing', name: 'Freelancer-bokföring', cat: 'bokforing', desc: 'Frilansarfakturor med F-skatt-kontroll', longDesc: 'Hantera fakturor från frilansare korrekt. Nyckelfunktioner: F-skatt-kontroll, auto-kontosättning konto 4010, kostnad per projekt' },
|
||||
{ slug: 'kulturmoms', name: 'Kulturmoms', cat: 'bokforing', desc: 'Moms 6% på publicering/film/kultur', longDesc: 'Hantera moms 6% vid publicering, film, kultur. Nyckelfunktioner: Korrekt momssats per tjänsttyp, blandad verksamhet' },
|
||||
{ slug: 'ip-tillgangar', name: 'IP-tillgångar', cat: 'bokforing', desc: 'Immateriella rättigheter', longDesc: 'Aktivera och avskriva immateriella rättigheter. Nyckelfunktioner: Aktivering, avskrivningsplan, konto 1010/1020' },
|
||||
{ slug: 'projektlonsamhet-media', name: 'Projektlönsamhet', cat: 'rapport', desc: 'Intäkt minus kostnad per projekt', longDesc: 'Intäkt minus kostnad per projekt. Nyckelfunktioner: Budget vs utfall, timkostnad vs debiterat' },
|
||||
{ slug: 'freelancerandel', name: 'Freelancerandel', cat: 'rapport', desc: 'Freelancerkostnad av produktion', longDesc: 'Andel av produktionskostnad som går till freelancers. Nyckelfunktioner: Trend, lönsamhetspåverkan, per projekttyp' },
|
||||
{ slug: 'kundlonsamhet', name: 'Kundlönsamhet', cat: 'rapport', desc: 'Intäkt och marginal per kund', longDesc: 'Intäkt och marginal per kund. Nyckelfunktioner: Tidsförbrukning vs fakturerat, återkommande kunder' },
|
||||
{ slug: 'freelancer-fakturaimport', name: 'Freelancer-fakturaimport', cat: 'import', desc: 'Importera frilansarfakturor', longDesc: 'Importera fakturor från frilansare. Nyckelfunktioner: Parsning, F-skatt-verifiering, projektkoppling' },
|
||||
{ slug: 'kampanjrapport-import', name: 'Kampanjrapport-import', cat: 'import', desc: 'Importera kampanjresultat', longDesc: 'Importera kampanjresultat. Nyckelfunktioner: Intäkter per kampanj, provisionskostnader' },
|
||||
{ slug: 'projekthantering-media', name: 'Projekthantering', cat: 'operativ', desc: 'Styra kundprojekt', longDesc: 'Styra kundprojekt. Nyckelfunktioner: Projekttavla, tidslinje, kundgodkännande' },
|
||||
{ slug: 'innehallsplanering', name: 'Innehållsplanering', cat: 'operativ', desc: 'Redaktionskalender', longDesc: 'Redaktionskalender. Nyckelfunktioner: Kanalvy, statusflöde, deadlines' },
|
||||
{ slug: 'mediebank', name: 'Mediebank', cat: 'operativ', desc: 'Lagring av bilder, video, grafik', longDesc: 'Central lagring av media. Nyckelfunktioner: Taggning, sök, rättighetsspårning' },
|
||||
{ slug: 'tidrapport-debitering-media', name: 'Tidrapport & Debitering', cat: 'operativ', desc: 'Logga tid per kund', longDesc: 'Logga tid per kund. Nyckelfunktioner: Timer, budget vs utfall, fakturaunderlag' },
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'fitness', name: 'Fitness & Sport', icon: Dumbbell,
|
||||
description: 'Medlemskap, klasser & pass',
|
||||
modules: [
|
||||
{ slug: 'fitnesskontoplan', name: 'Fitnesskontoplan', cat: 'bokforing', desc: 'BAS-konton: medlemskap, PT, klippkort, shop', longDesc: 'BAS-konton per intäktstyp: medlemskap, PT, klippkort, shop. Nyckelfunktioner: Konton 3010 (medlemsavgift), 3020 (PT), 2420 (klippkortsskuld), 3030 (shop)' },
|
||||
{ slug: 'momssplit-idrott-pt', name: 'Momssplit idrott/PT', cat: 'bokforing', desc: 'Moms 6% idrott, 25% PT/spa/shop', longDesc: 'Moms 6% på idrottslig verksamhet, 25% på PT, spa, shop. Nyckelfunktioner: Automatisk momssats per tjänsttyp, blandad verksamhet' },
|
||||
{ slug: 'autogiro-periodisering', name: 'Autogiro-periodisering', cat: 'bokforing', desc: 'Autogiro som intäkt rätt period', longDesc: 'Bokför autogiro-dragningar som intäkt rätt period. Nyckelfunktioner: Förskottsbetalning, periodisering konto 2970, misslyckade dragningar' },
|
||||
{ slug: 'klippkort-som-skuld', name: 'Klippkort som skuld', cat: 'bokforing', desc: 'Klippkort som förutbetald intäkt', longDesc: 'Sålda klippkort/startpaket bokförs som förutbetald intäkt. Nyckelfunktioner: Konto 2420, intäktsföring per nyttjat tillfälle, förfallohantering' },
|
||||
{ slug: 'friskvardsbidrag', name: 'Friskvårdsbidrag', cat: 'bokforing', desc: 'Friskvårdsbidrag (max 5 000 SEK)', longDesc: 'Hantera friskvårdsbidrag från arbetsgivare. Nyckelfunktioner: Skatteverkets gräns (5 000 SEK), kvittogenerering, dokumentation' },
|
||||
{ slug: 'churn-rate', name: 'Churn rate', cat: 'rapport', desc: 'Medlemsavgång per månad', longDesc: 'Medlemsavgång per månad. Nyckelfunktioner: Trend, orsaksanalys, retention per avtalstyp' },
|
||||
{ slug: 'intakt-per-medlem', name: 'Intäkt per medlem', cat: 'rapport', desc: 'Genomsnittlig intäkt per medlem', longDesc: 'Genomsnittlig intäkt per aktiv medlem. Nyckelfunktioner: Inklusive PT, shop, extra tjänster' },
|
||||
{ slug: 'belaggningsgrad-klasser', name: 'Beläggningsgrad klasser', cat: 'rapport', desc: 'Fyllnadsgrad per träningspass', longDesc: 'Fyllnadsgrad per gruppträningspass. Nyckelfunktioner: Per klass/tid, optimering av schema' },
|
||||
{ slug: 'autogiro-rapport-import', name: 'Autogiro-rapport-import', cat: 'import', desc: 'Importera autogiro (Bankgirot)', longDesc: 'Importera autogiro-dragningsrapport (Bankgirot). Nyckelfunktioner: Lyckade/misslyckade, auto-kontosättning, period-matchning' },
|
||||
{ slug: 'kassarapport-import-fitness', name: 'Kassarapport-import', cat: 'import', desc: 'Importera dagskassa', longDesc: 'Importera dagskassa. Nyckelfunktioner: Drop-in, PT-paket, shop; auto-kontosättning' },
|
||||
{ slug: 'medlemshantering', name: 'Medlemshantering', cat: 'operativ', desc: 'Registrera och hantera medlemskap', longDesc: 'Registrera och hantera medlemskap. Nyckelfunktioner: Avtalstyper, förnyelse, uppsägning' },
|
||||
{ slug: 'klassbokning', name: 'Klassbokning', cat: 'operativ', desc: 'Boka platser i träningsklasser', longDesc: 'Boka platser i träningspass. Nyckelfunktioner: Schema, platsbegränsning, väntelista' },
|
||||
{ slug: 'pt-bokning', name: 'PT-bokning', cat: 'operativ', desc: 'Boka personlig tränare', longDesc: 'Boka personlig tränare. Nyckelfunktioner: Paket, kundhistorik, kalender' },
|
||||
{ slug: 'tilltradeskontroll', name: 'Tillträdeskontroll', cat: 'operativ', desc: 'Inpassering till anläggning', longDesc: 'Inpassering till anläggning. Nyckelfunktioner: Nyckelbricka/QR, besökslogg' },
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'fordon', name: 'Fordon & Verkstad', icon: Wrench,
|
||||
description: 'Arbetsorder, delar & fordon',
|
||||
modules: [
|
||||
{ slug: 'verkstadskontoplan', name: 'Verkstadskontoplan', cat: 'bokforing', desc: 'BAS-konton: arbete, reservdelar, besiktning', longDesc: 'BAS-konton per intäktstyp: arbete, reservdelar, besiktning. Nyckelfunktioner: Konton 3010 (arbetsintäkt), 3020 (reservdelar), 4010 (inköp reservdelar)' },
|
||||
{ slug: 'arbetsorder-till-faktura', name: 'Arbetsorder → Faktura', cat: 'bokforing', desc: 'Faktura direkt från arbetsorder', longDesc: 'Generera faktura direkt från arbetsorder. Nyckelfunktioner: Timpris + delar, automatisk summering, konto-koppling' },
|
||||
{ slug: 'reservdelslager', name: 'Reservdelslager', cat: 'bokforing', desc: 'Lagervärdering reservdelar (FIFO)', longDesc: 'Lagervärdering av reservdelar. Nyckelfunktioner: FIFO, konto 1460, månadsbokslut, inkuransavdrag' },
|
||||
{ slug: 'vmb-begagnade-delar', name: 'VMB (begagnade delar)', cat: 'bokforing', desc: 'Vinstmarginalbeskattning', longDesc: 'Vinstmarginalbeskattning vid försäljning av begagnade reservdelar. Nyckelfunktioner: Beräkning av vinstmarginal, moms enbart på marginalen, konto 2640' },
|
||||
{ slug: 'garantiavsattning', name: 'Garantiavsättning', cat: 'bokforing', desc: 'Avsätt för garantikostnader', longDesc: 'Avsätt för framtida garantikostnader. Nyckelfunktioner: Avsättning konto 2290, upplösning vid faktisk kostnad' },
|
||||
{ slug: 'genomsnittligt-ordervarde', name: 'Genomsnittligt ordervärde', cat: 'rapport', desc: 'Snittfaktura per arbetsorder', longDesc: 'Snittfaktura per arbetsorder. Nyckelfunktioner: Arbete vs delar, trend, kundtypsanalys' },
|
||||
{ slug: 'reservdelsmarginal', name: 'Reservdelsmarginal', cat: 'rapport', desc: 'Inpris vs utpris på delar', longDesc: 'Inpris vs utpris på delar. Nyckelfunktioner: Per kategori, trend' },
|
||||
{ slug: 'verkstadsbelaggning', name: 'Verkstadsbeläggning', cat: 'rapport', desc: 'Utnyttjandegrad lyftar/mekaniker', longDesc: 'Utnyttjandegrad av lyftar och mekaniker. Nyckelfunktioner: Per resurs, optimering' },
|
||||
{ slug: 'reservdelsleverantor-import', name: 'Reservdelsleverantör-import', cat: 'import', desc: 'Importera från Mekonomen, Autoexperten', longDesc: 'Importera priser och leveranser. Nyckelfunktioner: Mekonomen, Autoexperten-format, auto-kontosättning' },
|
||||
{ slug: 'arbetsorder', name: 'Arbetsorder', cat: 'operativ', desc: 'Skapa och hantera verkstadsarbeten', longDesc: 'Skapa och hantera arbeten. Nyckelfunktioner: Registrering, mekaniker-tilldelning, statusflöde' },
|
||||
{ slug: 'fordonsregister', name: 'Fordonsregister', cat: 'operativ', desc: 'Register med fordonshistorik', longDesc: 'Register med fordonshistorik. Nyckelfunktioner: Regnummer, servicehistorik, besiktningsdatum' },
|
||||
{ slug: 'verkstadsplanering', name: 'Verkstadsplanering', cat: 'operativ', desc: 'Beläggning av lyftar och personal', longDesc: 'Beläggning av lyftar och personal. Nyckelfunktioner: Kalendervy, bokningstider, akut-slot' },
|
||||
{ slug: 'besiktningspaminnelse', name: 'Besiktningspåminnelse', cat: 'operativ', desc: 'Påminnelser inför besiktning', longDesc: 'Automatiska påminnelser. Nyckelfunktioner: SMS/e-post, historik' },
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'bemanning', name: 'Bemanning & HR', icon: UserPlus,
|
||||
description: 'Kandidater, uppdrag & timmar',
|
||||
modules: [
|
||||
{ slug: 'bemanningskontoplan', name: 'Bemanningskontoplan', cat: 'bokforing', desc: 'BAS-konton: uthyrning, rekrytering, UE, löner', longDesc: 'BAS-konton per intäkts- och kostnadstyp: uthyrning, rekrytering, underkonsulter. Nyckelfunktioner: Konton 3010 (uthyrningsintäkt), 3020 (rekryteringsarvode), 4010 (UE-kostnad), 7010 (löner)' },
|
||||
{ slug: 'tidrapport-dubbelbokforing', name: 'Tidrapport → Dubbelbokföring', cat: 'bokforing', desc: 'Genererar kundfaktura och löneunderlag', longDesc: 'Tidrapport genererar både kundfaktura och löneunderlag. Nyckelfunktioner: Timrapport, kunddebitering, lönekostnad, marginalberäkning per uppdrag' },
|
||||
{ slug: 'arbetsgivaravgifter-periodisering', name: 'Arbetsgivaravgifter-periodisering', cat: 'bokforing', desc: 'Periodisera arbetsgivaravgifter (31.42%)', longDesc: 'Periodisera arbetsgivaravgifter korrekt per månad. Nyckelfunktioner: Beräkning per anställd, konto 7510, semesterlöneskuld, period-vy' },
|
||||
{ slug: 'ue-verifiering-bemanning', name: 'UE-verifiering', cat: 'bokforing', desc: 'F-skatt och försäkring för underkonsulter', longDesc: 'Kontrollera F-skatt och försäkring för underkonsulter. Nyckelfunktioner: Skatteverkets API-kontroll, dokumentation, fakturamottagning' },
|
||||
{ slug: 'traktamente-vid-uthyrning', name: 'Traktamente vid uthyrning', cat: 'bokforing', desc: 'Traktamente per Skatteverkets schabloner', longDesc: 'Beräkna traktamente för uthyrd personal. Nyckelfunktioner: Skatteverkets schabloner, skattefri del, förmånsbeskattning vid övernattning' },
|
||||
{ slug: 'marginal-per-konsult', name: 'Marginal per konsult', cat: 'rapport', desc: 'Debiterat minus lönekostnad', longDesc: 'Debiterat minus lönekostnad per uthyrd person. Nyckelfunktioner: Per uppdrag, trend, jämförelse' },
|
||||
{ slug: 'belaggningsgrad-bemanning', name: 'Beläggningsgrad', cat: 'rapport', desc: 'Andel uthyrd tid', longDesc: 'Andel uthyrd tid av total tid. Nyckelfunktioner: Per konsult, team, totalt' },
|
||||
{ slug: 'fakturerat-per-konsult', name: 'Fakturerat per konsult', cat: 'rapport', desc: 'Total fakturering per person', longDesc: 'Total fakturering per person och period. Nyckelfunktioner: Ranking, budget vs utfall' },
|
||||
{ slug: 'tidrapport-import-bemanning', name: 'Tidrapport-import', cat: 'import', desc: 'Importera timmar från kundsystem', longDesc: 'Importera timmar från kundsystem. Nyckelfunktioner: CSV, kundgodkännande-status, auto-koppling till uppdrag' },
|
||||
{ slug: 'lonesystem-import', name: 'Lönesystem-import', cat: 'import', desc: 'Importera från Hogia/Visma Lön', longDesc: 'Importera löneunderlag. Nyckelfunktioner: Export från Hogia, Visma Lön; auto-bokföring av lön + sociala avgifter' },
|
||||
{ slug: 'kandidatregister', name: 'Kandidatregister', cat: 'operativ', desc: 'Rekryteringsdatabas', longDesc: 'Rekryteringsdatabas. Nyckelfunktioner: CV, kompetens, status, GDPR-rensning' },
|
||||
{ slug: 'uppdragshantering-bemanning', name: 'Uppdragshantering', cat: 'operativ', desc: 'Matcha kandidater med uppdrag', longDesc: 'Matcha kandidater med uppdrag. Nyckelfunktioner: Kravprofil, matchning, förlängning' },
|
||||
{ slug: 'avtalshantering-bemanning', name: 'Avtalshantering', cat: 'operativ', desc: 'Kundavtal och konsultavtal', longDesc: 'Kundavtal och konsultavtal. Nyckelfunktioner: Templates, förnyelsepåminnelser' },
|
||||
{ slug: 'kompetensregister', name: 'Kompetensregister', cat: 'operativ', desc: 'Certifieringar och utbildningar', longDesc: 'Certifieringar och utbildningar. Nyckelfunktioner: Per person, giltighetstid' },
|
||||
{ slug: 'compliance-bemanning', name: 'Compliance', cat: 'operativ', desc: 'ID06, arbetstillstånd, försäkringar', longDesc: 'Lagar och branschkrav. Nyckelfunktioner: ID06-kontroll, arbetstillstånd' },
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'tillverkning', name: 'Tillverkning & Industri', icon: Factory,
|
||||
description: 'Produktion, lager & kvalitet',
|
||||
modules: [
|
||||
{ slug: 'tillverkningskontoplan', name: 'Tillverkningskontoplan', cat: 'bokforing', desc: 'BAS-konton per lagertyp och kostnadsställe', longDesc: 'BAS-konton per lagertyp och kostnadsställe. Nyckelfunktioner: Konton 1410 (råvarulager), 1420 (PIA), 1450 (färdigvarulager), 4010 (materialinköp)' },
|
||||
{ slug: 'trestegslagervarderin', name: 'Trestegslagervärdering', cat: 'bokforing', desc: 'Värdera råvaror, PIA och färdigvaror', longDesc: 'Värdera råvaror, halvfabrikat (PIA) och färdigvaror separat. Nyckelfunktioner: FIFO per lagernivå, omvärdering vid produktion, konto 1410/1420/1450' },
|
||||
{ slug: 'bom-kalkyl-lagervarde', name: 'BOM-kalkyl → Lagervärde', cat: 'bokforing', desc: 'Strukturlista styr självkostnad', longDesc: 'Strukturlista (BOM) styr självkostnad per tillverkad enhet. Nyckelfunktioner: Materialkomponenter, arbetskostnad, OH-pålägg, lagervärde per BOM' },
|
||||
{ slug: 'produktionsavvikelse', name: 'Produktionsavvikelse', cat: 'bokforing', desc: 'Avvikelse standard vs faktisk kostnad', longDesc: 'Bokför avvikelse mellan kalkylerad och faktisk kostnad. Nyckelfunktioner: Standard- vs verklig kostnad, avvikelsekonto 4900' },
|
||||
{ slug: 'maskinavskrivning-industri', name: 'Maskinavskrivning', cat: 'bokforing', desc: 'Avskrivning produktionsutrustning', longDesc: 'Avskrivningsplan för produktionsutrustning. Nyckelfunktioner: Räkenskapsenlig avskrivning, konto 1210/7831, restvärdeavskrivning' },
|
||||
{ slug: 'energiskatteavdrag', name: 'Energiskatteavdrag', cat: 'bokforing', desc: 'Avdrag för energiskatt vid industri', longDesc: 'Avdrag för energiskatt vid industriell tillverkning. Nyckelfunktioner: Nedsättning av energiskatt, ansökan till Skatteverket, periodisering' },
|
||||
{ slug: 'materialeffektivitet', name: 'Materialeffektivitet', cat: 'rapport', desc: 'Faktisk vs BOM-förbrukning', longDesc: 'Faktisk materialförbrukning vs BOM-kalkyl. Nyckelfunktioner: Spill %, kostnad för avvikelse, trend' },
|
||||
{ slug: 'kostnad-per-producerad-enhet', name: 'Kostnad per producerad enhet', cat: 'rapport', desc: 'Total enhetskostnad', longDesc: 'Total kostnad (material + arbete + OH) per enhet. Nyckelfunktioner: Per produkt, period-jämförelse' },
|
||||
{ slug: 'oee', name: 'OEE', cat: 'rapport', desc: 'Overall Equipment Effectiveness', longDesc: 'Maskinutnyttjandegrad. Nyckelfunktioner: Tillgänglighet × prestanda × kvalitet' },
|
||||
{ slug: 'produktionsrapport-import', name: 'Produktionsrapport-import', cat: 'import', desc: 'Importera utfallsdata per order', longDesc: 'Importera utfallsdata per tillverkningsorder. Nyckelfunktioner: Godkänt/kasserat, materialförbrukning, auto-kontosättning' },
|
||||
{ slug: 'lagerexport-import', name: 'Lagerexport-import', cat: 'import', desc: 'Importera lagerförändringar', longDesc: 'Importera lagerförändringar. Nyckelfunktioner: Per lagernivå, inventeringsresultat' },
|
||||
{ slug: 'produktionsplanering', name: 'Produktionsplanering', cat: 'operativ', desc: 'Schemalägga tillverkningsorder', longDesc: 'Schemalägga tillverkningsorder. Nyckelfunktioner: Ordervy, maskinbeläggning, kapacitet' },
|
||||
{ slug: 'strukturlista-bom', name: 'Strukturlista (BOM)', cat: 'operativ', desc: 'Materialstruktur per produkt', longDesc: 'Definiera materialstruktur. Nyckelfunktioner: Komponenter, nivåer, alternativa delar' },
|
||||
{ slug: 'kvalitetskontroll', name: 'Kvalitetskontroll', cat: 'operativ', desc: 'Säkerställ produktkvalitet', longDesc: 'Säkerställ produktkvalitet. Nyckelfunktioner: Kontrollplaner, mätprotokoll, avvikelser' },
|
||||
{ slug: 'maskinunderhall', name: 'Maskinunderhåll', cat: 'operativ', desc: 'Förebyggande underhåll', longDesc: 'Förebyggande och avhjälpande underhåll. Nyckelfunktioner: Schema, felanmälan, reservdelsförbrukning' },
|
||||
{ slug: 'sparbarhet-batch', name: 'Spårbarhet & Batch', cat: 'operativ', desc: 'Spåra råvaror till färdig produkt', longDesc: 'Spåra råvaror till färdig produkt. Nyckelfunktioner: Batchnummer, komponentspårning, återkallning' },
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'konsult', name: 'Konsult & Rådgivning', icon: Briefcase,
|
||||
description: 'Uppdrag, tid & debitering',
|
||||
modules: [
|
||||
{ slug: 'konsultkontoplan', name: 'Konsultkontoplan', cat: 'bokforing', desc: 'BAS-konton: arvode, resor, utlägg', longDesc: 'BAS-konton för konsultintäkt, utlägg, resor. Nyckelfunktioner: Konton 3010 (konsultarvode), 5800 (resekostnad), 6990 (vidarefakturerade utlägg)' },
|
||||
{ slug: 'traktamente', name: 'Traktamente', cat: 'bokforing', desc: 'Traktamente per Skatteverkets schabloner', longDesc: 'Beräkna traktamente vid tjänsteresor enligt Skatteverkets schabloner. Nyckelfunktioner: Hel/halvdag, reducering vid fri måltid, skattefri del, förmånsvärde' },
|
||||
{ slug: 'hemmakontor-avdrag', name: 'Hemmakontor-avdrag', cat: 'bokforing', desc: 'Schablonavdrag (2000/4000 SEK)', longDesc: 'Schablonavdrag för hemmakontor. Nyckelfunktioner: Fast belopp (2 000/4 000 SEK), alternativt verklig kostnad med yta-beräkning' },
|
||||
{ slug: 'wip-bevakning-konsult', name: 'WIP-bevakning', cat: 'bokforing', desc: 'Pågående ej fakturerat arbete', longDesc: 'Pågående ej fakturerat arbete. Nyckelfunktioner: Konto 1470, per uppdrag, åldringsanalys' },
|
||||
{ slug: 'debiteringsgrad-konsult', name: 'Debiteringsgrad', cat: 'rapport', desc: 'Debiterbara timmar av total', longDesc: 'Debiterbara timmar / total arbetstid. Nyckelfunktioner: Mål vs utfall, trend' },
|
||||
{ slug: 'intakt-per-konsult', name: 'Intäkt per konsult', cat: 'rapport', desc: 'Total fakturering per person', longDesc: 'Total fakturering per person. Nyckelfunktioner: Ranking, budget vs utfall' },
|
||||
{ slug: 'tidrapport-import-konsult', name: 'Tidrapport-import', cat: 'import', desc: 'Importera från Harvest, Toggl', longDesc: 'Importera tid från Harvest, Toggl, Clockify. Nyckelfunktioner: CSV, koppling till uppdrag, fakturaunderlag' },
|
||||
{ slug: 'uppdragshantering-konsult', name: 'Uppdragshantering', cat: 'operativ', desc: 'Administrera kunduppdrag', longDesc: 'Administrera kunduppdrag. Nyckelfunktioner: Milstolpar, statusvy, lönsamhetsanalys' },
|
||||
{ slug: 'tidrapportering-konsult', name: 'Tidrapportering', cat: 'operativ', desc: 'Tidregistrering per uppdrag', longDesc: 'Detaljerad tidregistrering. Nyckelfunktioner: Timer, kategorier, veckoöversikt' },
|
||||
{ slug: 'offert-avtal', name: 'Offert & Avtal', cat: 'operativ', desc: 'Skapa offerter och avtal', longDesc: 'Skapa offerter. Nyckelfunktioner: Mallar, godkännande, avtalsstatus' },
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'event', name: 'Event & Underhållning', icon: PartyPopper,
|
||||
description: 'Evenemang, biljetter & artister',
|
||||
modules: [
|
||||
{ slug: 'eventkontoplan', name: 'Eventkontoplan', cat: 'bokforing', desc: 'BAS-konton: biljetter, sponsring, F&B, merch', longDesc: 'BAS-konton per intäktstyp: biljetter, sponsring, F&B, merch. Nyckelfunktioner: Konton 3010 (biljettintäkt), 3020 (sponsorintäkt), 3030 (F&B), 3040 (merch)' },
|
||||
{ slug: 'biljettintakt-som-forskott', name: 'Biljettintäkt som förskott', cat: 'bokforing', desc: 'Förskottssålda biljetter som skuld', longDesc: 'Bokför förskottssålda biljetter som skuld tills eventet äger rum. Nyckelfunktioner: Konto 2420, intäktsföring vid genomfört event, avbokningshantering' },
|
||||
{ slug: 'kulturmoms-event', name: 'Kulturmoms event', cat: 'bokforing', desc: 'Moms 6% kultur, 25% F&B/merch', longDesc: 'Hantera reducerad moms på kulturella föreställningar. Nyckelfunktioner: Moms 6% på biljetter, 25% på mat/dryck/merch, blandad verksamhet' },
|
||||
{ slug: 'artistskatt-sink', name: 'Artistskatt (SINK)', cat: 'bokforing', desc: 'SINK-skatt (15%) utländska artister', longDesc: 'Hantera källskatt för utländska artister. Nyckelfunktioner: Beräkning av SINK (15%), inbetalning till Skatteverket, underlag per artist' },
|
||||
{ slug: 'sponsorintaktsbokforing', name: 'Sponsorintäktsbokföring', cat: 'bokforing', desc: 'Sponsoravtal med periodisering', longDesc: 'Bokför sponsoravtal med motprestationsperiodisering. Nyckelfunktioner: Periodisering per event, konto 3910, avtalsvärde vs motprestation' },
|
||||
{ slug: 'intakt-per-besokare', name: 'Intäkt per besökare', cat: 'rapport', desc: 'Total intäkt per besökare', longDesc: 'Total intäkt delat på antal besökare. Nyckelfunktioner: Biljett + F&B + merch, jämförelse mellan event' },
|
||||
{ slug: 'budget-vs-utfall', name: 'Budget vs utfall', cat: 'rapport', desc: 'Eventbudget vs verkligt resultat', longDesc: 'Eventbudget jämfört med verkligt resultat. Nyckelfunktioner: Per kostnadspost, totalt, vinstmarginal' },
|
||||
{ slug: 'sponsorandel', name: 'Sponsorandel', cat: 'rapport', desc: 'Sponsorintäkt som andel av total', longDesc: 'Sponsorintäkt som andel av total intäkt. Nyckelfunktioner: Per event, trend, beroendeanalys' },
|
||||
{ slug: 'biljettsystem-import', name: 'Biljettsystem-import', cat: 'import', desc: 'Importera från Ticketmaster, Eventbrite', longDesc: 'Importera försäljningsdata från biljettsystem. Nyckelfunktioner: Ticketmaster, Eventbrite, NORTIC; auto-kontosättning inkl avgifter' },
|
||||
{ slug: 'pos-rapport-import-event', name: 'POS-rapport-import', cat: 'import', desc: 'Importera F&B- och merch-försäljning', longDesc: 'Importera F&B- och merch-försäljning. Nyckelfunktioner: Kassarapport från eventbar/shop, momssplit' },
|
||||
{ slug: 'evenemangsplanering', name: 'Evenemangsplanering', cat: 'operativ', desc: 'Planera och koordinera event', longDesc: 'Planera och koordinera event. Nyckelfunktioner: Checklista, tidslinje, budget' },
|
||||
{ slug: 'biljettforsaljning', name: 'Biljettförsäljning', cat: 'operativ', desc: 'Sälj och hantera biljetter', longDesc: 'Sälj och hantera biljetter. Nyckelfunktioner: Biljettyper, priskategorier, QR-validering' },
|
||||
{ slug: 'artist-talangbokning', name: 'Artist- & Talangbokning', cat: 'operativ', desc: 'Boka artister och föreläsare', longDesc: 'Boka artister och föreläsare. Nyckelfunktioner: Kontrakt, riderhantering, schema' },
|
||||
{ slug: 'sponsorhantering', name: 'Sponsorhantering', cat: 'operativ', desc: 'Hantera sponsoravtal', longDesc: 'Hantera sponsoravtal. Nyckelfunktioner: Avtalsnivåer, motprestationer, uppföljning' },
|
||||
{ slug: 'volontarhantering', name: 'Volontärhantering', cat: 'operativ', desc: 'Koordinera frivilliga', longDesc: 'Koordinera frivilliga. Nyckelfunktioner: Registrering, skifttilldelning, kommunikation' },
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'fastighet', name: 'Fastighetsförvaltning', icon: Building,
|
||||
description: 'Objekt, hyresgäster & underhåll',
|
||||
modules: [
|
||||
{ slug: 'fastighetskontoplan', name: 'Fastighetskontoplan', cat: 'bokforing', desc: 'BAS-konton per fastighet och kostnadstyp', longDesc: 'BAS-konton per fastighet och kostnadstyp. Nyckelfunktioner: Konton 1110 (byggnader), 3010 (hyresintäkt), 5010 (fastighetsskötsel), 7720 (fastighetsskatt)' },
|
||||
{ slug: 'hyresintakt-periodisering', name: 'Hyresintäkt-periodisering', cat: 'bokforing', desc: 'Periodisera hyresintäkter per månad', longDesc: 'Periodisera hyresintäkter korrekt per månad. Nyckelfunktioner: Förskottshyra (konto 2970), hyresrabatt, tomgångskostnad' },
|
||||
{ slug: 'fastighetsskatt', name: 'Fastighetsskatt', cat: 'bokforing', desc: 'Beräkna fastighetsskatt/avgift', longDesc: 'Beräkna och bokföra fastighetsskatt/avgift. Nyckelfunktioner: Taxeringsvärde, skattesats per fastighetstyp, konto 7720, periodisering' },
|
||||
{ slug: 'fastighetsavskrivning', name: 'Fastighetsavskrivning', cat: 'bokforing', desc: 'Avskrivning per byggnad (K2/K3)', longDesc: 'Avskrivningsplan per byggnad (lång löptid). Nyckelfunktioner: Komponentavskrivning (K3) eller linjär (K2), konto 1119/7820' },
|
||||
{ slug: 'underhallsfond', name: 'Underhållsfond', cat: 'bokforing', desc: 'Avsätt medel för planerat underhåll', longDesc: 'Avsätt medel för planerat underhåll. Nyckelfunktioner: Avsättning per år, upplösning vid genomfört underhåll, 10-årsplan kopplad' },
|
||||
{ slug: 'rot-vid-renovering', name: 'ROT vid renovering', cat: 'bokforing', desc: 'ROT-avdrag vid renovering', longDesc: 'Hantera ROT-avdrag för hyresgäster vid renovering. Nyckelfunktioner: Beräkning, personnummer, Skatteverket-rapportering' },
|
||||
{ slug: 'indexupprakning', name: 'Indexuppräkning', cat: 'bokforing', desc: 'Automatisk hyreshöjning vid index', longDesc: 'Automatisk beräkning av hyreshöjning vid index. Nyckelfunktioner: KPI-koppling, beräkning per kontrakt, underlag för avisering' },
|
||||
{ slug: 'driftnetto-per-fastighet', name: 'Driftnetto per fastighet', cat: 'rapport', desc: 'NOI per fastighet', longDesc: 'Hyresintäkt minus driftskostnad. Nyckelfunktioner: Per fastighet/objekt, jämförelse, trend' },
|
||||
{ slug: 'vakansgrad', name: 'Vakansgrad', cat: 'rapport', desc: 'Andel outhyrda ytor', longDesc: 'Andel outhyrda ytor. Nyckelfunktioner: Per fastighet, trendanalys, intäktsbortfall' },
|
||||
{ slug: 'underhallskostnad-per-m2', name: 'Underhållskostnad per m²', cat: 'rapport', desc: 'Underhåll per kvadratmeter', longDesc: 'Total underhållskostnad per kvadratmeter. Nyckelfunktioner: Plan vs utfall, per fastighet, kategori' },
|
||||
{ slug: 'hyresreskontra-import', name: 'Hyresreskontra-import', cat: 'import', desc: 'Importera hyresreskontra', longDesc: 'Importera hyresreskontra. Nyckelfunktioner: Avier, betalningar, restbelopp; auto-kontosättning' },
|
||||
{ slug: 'energirapport-import', name: 'Energirapport-import', cat: 'import', desc: 'Importera energiförbrukning', longDesc: 'Importera energiförbrukning. Nyckelfunktioner: El/vatten/fjärrvärme, per fastighet, kostnadsfördelning' },
|
||||
{ slug: 'objektregister', name: 'Objektregister', cat: 'operativ', desc: 'Fastighets- och lokalöversikt', longDesc: 'Fastighets- och lokalöversikt. Nyckelfunktioner: Adresser, ytor, byggnadsår, dokument' },
|
||||
{ slug: 'hyresgasthantering', name: 'Hyresgästhantering', cat: 'operativ', desc: 'Administrera hyresavtal', longDesc: 'Administrera hyresavtal. Nyckelfunktioner: Avtalstider, kontakt, uppsägningsflöde' },
|
||||
{ slug: 'hyresavier', name: 'Hyresavier', cat: 'operativ', desc: 'Generera och skicka avier', longDesc: 'Generera och skicka avier. Nyckelfunktioner: Automatisk avisering, tillägg, autogiro' },
|
||||
{ slug: 'felanmalan', name: 'Felanmälan', cat: 'operativ', desc: 'Hantera felanmälningar', longDesc: 'Hantera felanmälningar. Nyckelfunktioner: Webformulär, prioritet, hantverkarstyrning' },
|
||||
{ slug: 'underhallsplanering', name: 'Underhållsplanering', cat: 'operativ', desc: 'Långsiktig underhållsplanering', longDesc: 'Långsiktig och akut. Nyckelfunktioner: 10-årsplan, kostnadsprognos, status' },
|
||||
{ slug: 'besiktning-rondering', name: 'Besiktning & Rondering', cat: 'operativ', desc: 'Dokumentera besiktningar', longDesc: 'Dokumentera besiktningar. Nyckelfunktioner: Protokoll, foto, avvikelserapport' },
|
||||
{ slug: 'energiovervakning', name: 'Energiövervakning', cat: 'operativ', desc: 'Följ energiförbrukning', longDesc: 'Följ energiförbrukning. Nyckelfunktioner: El/vatten/värme, jämförelse, energideklaration' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export function getSectorBySlug(slug: string): Sector | undefined {
|
||||
return sectors.find(s => s.slug === slug)
|
||||
}
|
||||
|
||||
export function getModuleBySlug(sectorSlug: string, moduleSlug: string): { sector: Sector; module: ModuleItem } | undefined {
|
||||
const sector = sectors.find(s => s.slug === sectorSlug)
|
||||
if (!sector) return undefined
|
||||
const module = sector.modules.find(m => m.slug === moduleSlug)
|
||||
if (!module) return undefined
|
||||
return { sector, module }
|
||||
}
|
||||
@@ -1,377 +0,0 @@
|
||||
/**
|
||||
* Notification scheduling and quiet hours handling
|
||||
*/
|
||||
|
||||
import { SupabaseClient } from '@supabase/supabase-js'
|
||||
import {
|
||||
sendPushToMultiple,
|
||||
createTaxDeadlinePayload,
|
||||
createInvoicePayload,
|
||||
type PushSubscriptionKeys,
|
||||
} from './web-push'
|
||||
import type { NotificationSettings, NotificationType } from '@/types'
|
||||
|
||||
/**
|
||||
* Check if current time is within quiet hours (Sweden timezone)
|
||||
*/
|
||||
export function isWithinQuietHours(
|
||||
settings: NotificationSettings,
|
||||
now: Date = new Date()
|
||||
): boolean {
|
||||
// Convert to Sweden timezone
|
||||
const swedenTime = new Intl.DateTimeFormat('sv-SE', {
|
||||
timeZone: 'Europe/Stockholm',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
}).format(now)
|
||||
|
||||
const [hours, minutes] = swedenTime.split(':').map(Number)
|
||||
const currentMinutes = hours * 60 + minutes
|
||||
|
||||
// Parse quiet hours
|
||||
const [startHours, startMinutes] = settings.quiet_start.split(':').map(Number)
|
||||
const [endHours, endMinutes] = settings.quiet_end.split(':').map(Number)
|
||||
|
||||
const quietStartMinutes = startHours * 60 + startMinutes
|
||||
const quietEndMinutes = endHours * 60 + endMinutes
|
||||
|
||||
// Handle overnight quiet hours (e.g., 21:00 to 08:00)
|
||||
if (quietStartMinutes > quietEndMinutes) {
|
||||
// Quiet if after start OR before end
|
||||
return currentMinutes >= quietStartMinutes || currentMinutes < quietEndMinutes
|
||||
}
|
||||
|
||||
// Normal range (e.g., 01:00 to 06:00)
|
||||
return currentMinutes >= quietStartMinutes && currentMinutes < quietEndMinutes
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a notification has already been sent (prevent duplicates)
|
||||
*/
|
||||
async function wasNotificationSent(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
notificationType: NotificationType,
|
||||
referenceId: string,
|
||||
daysBefore: number
|
||||
): Promise<boolean> {
|
||||
const { data } = await supabase
|
||||
.from('notification_log')
|
||||
.select('id')
|
||||
.eq('user_id', userId)
|
||||
.eq('notification_type', notificationType)
|
||||
.eq('reference_id', referenceId)
|
||||
.eq('days_before', daysBefore)
|
||||
.single()
|
||||
|
||||
return !!data
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a sent notification
|
||||
*/
|
||||
async function logNotification(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
notificationType: NotificationType,
|
||||
referenceId: string,
|
||||
daysBefore: number
|
||||
): Promise<void> {
|
||||
await supabase.from('notification_log').insert({
|
||||
user_id: userId,
|
||||
notification_type: notificationType,
|
||||
reference_id: referenceId,
|
||||
days_before: daysBefore,
|
||||
delivery_status: 'sent',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's push subscriptions
|
||||
*/
|
||||
async function getUserSubscriptions(
|
||||
supabase: SupabaseClient,
|
||||
userId: string
|
||||
): Promise<PushSubscriptionKeys[]> {
|
||||
const { data } = await supabase
|
||||
.from('push_subscriptions')
|
||||
.select('endpoint, p256dh, auth')
|
||||
.eq('user_id', userId)
|
||||
.eq('is_active', true)
|
||||
|
||||
return data || []
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's notification settings
|
||||
*/
|
||||
async function getUserNotificationSettings(
|
||||
supabase: SupabaseClient,
|
||||
userId: string
|
||||
): Promise<NotificationSettings | null> {
|
||||
const { data } = await supabase
|
||||
.from('notification_settings')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.single()
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable gone (unsubscribed) push subscriptions
|
||||
*/
|
||||
async function disableGoneSubscriptions(
|
||||
supabase: SupabaseClient,
|
||||
endpoints: string[]
|
||||
): Promise<void> {
|
||||
if (endpoints.length === 0) return
|
||||
|
||||
await supabase
|
||||
.from('push_subscriptions')
|
||||
.update({ is_active: false })
|
||||
.in('endpoint', endpoints)
|
||||
}
|
||||
|
||||
/**
|
||||
* Send tax deadline notifications
|
||||
*/
|
||||
export async function sendTaxDeadlineNotifications(
|
||||
supabase: SupabaseClient
|
||||
): Promise<{ sent: number; skipped: number }> {
|
||||
let sent = 0
|
||||
let skipped = 0
|
||||
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
const todayStr = today.toISOString().split('T')[0]
|
||||
|
||||
// Calculate dates for 7 days and 1 day before
|
||||
const in7Days = new Date(today)
|
||||
in7Days.setDate(in7Days.getDate() + 7)
|
||||
const in7DaysStr = in7Days.toISOString().split('T')[0]
|
||||
|
||||
const tomorrow = new Date(today)
|
||||
tomorrow.setDate(tomorrow.getDate() + 1)
|
||||
const tomorrowStr = tomorrow.toISOString().split('T')[0]
|
||||
|
||||
// Fetch tax deadlines due in 7 days or 1 day
|
||||
const { data: deadlines } = await supabase
|
||||
.from('deadlines')
|
||||
.select('id, user_id, title, due_date')
|
||||
.eq('deadline_type', 'tax')
|
||||
.eq('is_completed', false)
|
||||
.in('status', ['upcoming', 'action_needed'])
|
||||
.in('due_date', [in7DaysStr, tomorrowStr, todayStr])
|
||||
|
||||
if (!deadlines || deadlines.length === 0) {
|
||||
return { sent: 0, skipped: 0 }
|
||||
}
|
||||
|
||||
// Group by user
|
||||
const userDeadlines = new Map<string, typeof deadlines>()
|
||||
for (const deadline of deadlines) {
|
||||
const list = userDeadlines.get(deadline.user_id) || []
|
||||
list.push(deadline)
|
||||
userDeadlines.set(deadline.user_id, list)
|
||||
}
|
||||
|
||||
for (const [userId, userDls] of userDeadlines) {
|
||||
// Get user settings
|
||||
const settings = await getUserNotificationSettings(supabase, userId)
|
||||
|
||||
// Skip if tax deadlines disabled
|
||||
if (settings && !settings.tax_deadlines_enabled) {
|
||||
skipped += userDls.length
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip if push disabled
|
||||
if (settings && !settings.push_enabled) {
|
||||
skipped += userDls.length
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip if within quiet hours
|
||||
if (settings && isWithinQuietHours(settings)) {
|
||||
skipped += userDls.length
|
||||
continue
|
||||
}
|
||||
|
||||
// Get subscriptions
|
||||
const subscriptions = await getUserSubscriptions(supabase, userId)
|
||||
if (subscriptions.length === 0) {
|
||||
skipped += userDls.length
|
||||
continue
|
||||
}
|
||||
|
||||
for (const deadline of userDls) {
|
||||
const daysUntil = Math.ceil(
|
||||
(new Date(deadline.due_date).getTime() - today.getTime()) / (1000 * 60 * 60 * 24)
|
||||
)
|
||||
|
||||
// Check if already sent for this day count
|
||||
const alreadySent = await wasNotificationSent(
|
||||
supabase,
|
||||
userId,
|
||||
'tax_deadline',
|
||||
deadline.id,
|
||||
daysUntil
|
||||
)
|
||||
|
||||
if (alreadySent) {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
// Create and send notification
|
||||
const payload = createTaxDeadlinePayload(
|
||||
deadline.title,
|
||||
deadline.due_date,
|
||||
daysUntil,
|
||||
deadline.id
|
||||
)
|
||||
|
||||
const result = await sendPushToMultiple(subscriptions, payload)
|
||||
|
||||
// Log the notification
|
||||
await logNotification(supabase, userId, 'tax_deadline', deadline.id, daysUntil)
|
||||
|
||||
// Disable gone subscriptions
|
||||
if (result.goneSubscriptions.length > 0) {
|
||||
await disableGoneSubscriptions(supabase, result.goneSubscriptions)
|
||||
}
|
||||
|
||||
if (result.successful > 0) {
|
||||
sent++
|
||||
} else {
|
||||
skipped++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { sent, skipped }
|
||||
}
|
||||
|
||||
/**
|
||||
* Send invoice reminder notifications
|
||||
*/
|
||||
export async function sendInvoiceNotifications(
|
||||
supabase: SupabaseClient
|
||||
): Promise<{ sent: number; skipped: number }> {
|
||||
let sent = 0
|
||||
let skipped = 0
|
||||
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
const todayStr = today.toISOString().split('T')[0]
|
||||
|
||||
// Calculate dates
|
||||
const in3Days = new Date(today)
|
||||
in3Days.setDate(in3Days.getDate() + 3)
|
||||
const in3DaysStr = in3Days.toISOString().split('T')[0]
|
||||
|
||||
const daysAgo3 = new Date(today)
|
||||
daysAgo3.setDate(daysAgo3.getDate() - 3)
|
||||
const daysAgo3Str = daysAgo3.toISOString().split('T')[0]
|
||||
|
||||
const daysAgo7 = new Date(today)
|
||||
daysAgo7.setDate(daysAgo7.getDate() - 7)
|
||||
const daysAgo7Str = daysAgo7.toISOString().split('T')[0]
|
||||
|
||||
// Fetch invoices: due soon (3d, 0d) or overdue (+3d, +7d)
|
||||
const { data: invoices } = await supabase
|
||||
.from('invoices')
|
||||
.select('id, user_id, invoice_number, total, due_date, customer:customers(name)')
|
||||
.in('status', ['sent', 'overdue'])
|
||||
.in('due_date', [in3DaysStr, todayStr, daysAgo3Str, daysAgo7Str])
|
||||
|
||||
if (!invoices || invoices.length === 0) {
|
||||
return { sent: 0, skipped: 0 }
|
||||
}
|
||||
|
||||
// Group by user
|
||||
const userInvoices = new Map<string, typeof invoices>()
|
||||
for (const invoice of invoices) {
|
||||
const list = userInvoices.get(invoice.user_id) || []
|
||||
list.push(invoice)
|
||||
userInvoices.set(invoice.user_id, list)
|
||||
}
|
||||
|
||||
for (const [userId, userInvs] of userInvoices) {
|
||||
const settings = await getUserNotificationSettings(supabase, userId)
|
||||
|
||||
if (settings && !settings.invoice_reminders_enabled) {
|
||||
skipped += userInvs.length
|
||||
continue
|
||||
}
|
||||
|
||||
if (settings && !settings.push_enabled) {
|
||||
skipped += userInvs.length
|
||||
continue
|
||||
}
|
||||
|
||||
if (settings && isWithinQuietHours(settings)) {
|
||||
skipped += userInvs.length
|
||||
continue
|
||||
}
|
||||
|
||||
const subscriptions = await getUserSubscriptions(supabase, userId)
|
||||
if (subscriptions.length === 0) {
|
||||
skipped += userInvs.length
|
||||
continue
|
||||
}
|
||||
|
||||
for (const invoice of userInvs) {
|
||||
const dueDate = new Date(invoice.due_date)
|
||||
const daysUntil = Math.ceil((dueDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24))
|
||||
const isOverdue = daysUntil < 0
|
||||
|
||||
const notificationType: NotificationType = isOverdue ? 'invoice_overdue' : 'invoice_due'
|
||||
|
||||
const alreadySent = await wasNotificationSent(
|
||||
supabase,
|
||||
userId,
|
||||
notificationType,
|
||||
invoice.id,
|
||||
Math.abs(daysUntil)
|
||||
)
|
||||
|
||||
if (alreadySent) {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
// customer is a single object from the join, but TypeScript sees it as an array from select()
|
||||
const customer = invoice.customer as unknown as { name: string } | null
|
||||
const customerName = customer?.name || 'Okänd kund'
|
||||
|
||||
const payload = createInvoicePayload(
|
||||
invoice.invoice_number,
|
||||
customerName,
|
||||
invoice.total,
|
||||
invoice.due_date,
|
||||
isOverdue,
|
||||
invoice.id
|
||||
)
|
||||
|
||||
const result = await sendPushToMultiple(subscriptions, payload)
|
||||
|
||||
await logNotification(supabase, userId, notificationType, invoice.id, Math.abs(daysUntil))
|
||||
|
||||
if (result.goneSubscriptions.length > 0) {
|
||||
await disableGoneSubscriptions(supabase, result.goneSubscriptions)
|
||||
}
|
||||
|
||||
if (result.successful > 0) {
|
||||
sent++
|
||||
} else {
|
||||
skipped++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { sent, skipped }
|
||||
}
|
||||
|
||||
@@ -1,203 +0,0 @@
|
||||
/**
|
||||
* Web Push configuration and sending utilities
|
||||
*/
|
||||
|
||||
import webpush from 'web-push'
|
||||
|
||||
// Configure web-push with VAPID keys
|
||||
const vapidPublicKey = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY
|
||||
const vapidPrivateKey = process.env.VAPID_PRIVATE_KEY
|
||||
const vapidSubject = process.env.VAPID_SUBJECT || 'mailto:support@erp-base.se'
|
||||
|
||||
if (vapidPublicKey && vapidPrivateKey) {
|
||||
webpush.setVapidDetails(vapidSubject, vapidPublicKey, vapidPrivateKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* Push subscription keys from the browser
|
||||
*/
|
||||
export interface PushSubscriptionKeys {
|
||||
endpoint: string
|
||||
p256dh: string
|
||||
auth: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Notification payload
|
||||
*/
|
||||
export interface NotificationPayload {
|
||||
title: string
|
||||
body: string
|
||||
icon?: string
|
||||
badge?: string
|
||||
tag?: string
|
||||
data?: {
|
||||
url?: string
|
||||
type?: string
|
||||
id?: string
|
||||
}
|
||||
actions?: Array<{
|
||||
action: string
|
||||
title: string
|
||||
icon?: string
|
||||
}>
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a push notification to a subscription
|
||||
*/
|
||||
export async function sendPushNotification(
|
||||
subscription: PushSubscriptionKeys,
|
||||
payload: NotificationPayload
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
if (!vapidPublicKey || !vapidPrivateKey) {
|
||||
console.error('VAPID keys not configured')
|
||||
return { success: false, error: 'VAPID keys not configured' }
|
||||
}
|
||||
|
||||
const pushSubscription = {
|
||||
endpoint: subscription.endpoint,
|
||||
keys: {
|
||||
p256dh: subscription.p256dh,
|
||||
auth: subscription.auth,
|
||||
},
|
||||
}
|
||||
|
||||
try {
|
||||
await webpush.sendNotification(
|
||||
pushSubscription,
|
||||
JSON.stringify(payload),
|
||||
{
|
||||
TTL: 86400, // 24 hours
|
||||
urgency: 'normal',
|
||||
}
|
||||
)
|
||||
return { success: true }
|
||||
} catch (error: unknown) {
|
||||
const err = error as { statusCode?: number; message?: string }
|
||||
|
||||
// Handle subscription gone (user unsubscribed)
|
||||
if (err.statusCode === 410) {
|
||||
return { success: false, error: 'subscription_gone' }
|
||||
}
|
||||
|
||||
console.error('Push notification error:', err)
|
||||
return { success: false, error: err.message || 'Unknown error' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send push notifications to multiple subscriptions
|
||||
*/
|
||||
export async function sendPushToMultiple(
|
||||
subscriptions: PushSubscriptionKeys[],
|
||||
payload: NotificationPayload
|
||||
): Promise<{
|
||||
successful: number
|
||||
failed: number
|
||||
goneSubscriptions: string[]
|
||||
}> {
|
||||
let successful = 0
|
||||
let failed = 0
|
||||
const goneSubscriptions: string[] = []
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
subscriptions.map((sub) => sendPushNotification(sub, payload))
|
||||
)
|
||||
|
||||
results.forEach((result, index) => {
|
||||
if (result.status === 'fulfilled' && result.value.success) {
|
||||
successful++
|
||||
} else {
|
||||
failed++
|
||||
if (result.status === 'fulfilled' && result.value.error === 'subscription_gone') {
|
||||
goneSubscriptions.push(subscriptions[index].endpoint)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return { successful, failed, goneSubscriptions }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the public VAPID key for client-side subscription
|
||||
*/
|
||||
export function getVapidPublicKey(): string | null {
|
||||
return vapidPublicKey || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Create notification payload for tax deadline
|
||||
*/
|
||||
export function createTaxDeadlinePayload(
|
||||
title: string,
|
||||
dueDate: string,
|
||||
daysUntil: number,
|
||||
deadlineId: string
|
||||
): NotificationPayload {
|
||||
const urgencyText = daysUntil === 0 ? 'IDAG!' : daysUntil === 1 ? 'imorgon' : `om ${daysUntil} dagar`
|
||||
|
||||
return {
|
||||
title: `📋 ${title}`,
|
||||
body: `Deadline ${urgencyText} (${formatDate(dueDate)})`,
|
||||
icon: '/icons/icon-192.png',
|
||||
badge: '/icons/badge-72.png',
|
||||
tag: `tax-deadline-${deadlineId}`,
|
||||
data: {
|
||||
url: '/calendar',
|
||||
type: 'tax_deadline',
|
||||
id: deadlineId,
|
||||
},
|
||||
actions: [
|
||||
{ action: 'view', title: 'Visa' },
|
||||
{ action: 'dismiss', title: 'Avfärda' },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create notification payload for invoice reminder
|
||||
*/
|
||||
export function createInvoicePayload(
|
||||
invoiceNumber: string,
|
||||
customerName: string,
|
||||
amount: number,
|
||||
dueDate: string,
|
||||
isOverdue: boolean,
|
||||
invoiceId: string
|
||||
): NotificationPayload {
|
||||
const title = isOverdue
|
||||
? `⚠️ Obetald faktura #${invoiceNumber}`
|
||||
: `💰 Faktura #${invoiceNumber} förfaller`
|
||||
|
||||
const body = isOverdue
|
||||
? `${customerName} - ${amount.toLocaleString('sv-SE')} kr (förföll ${formatDate(dueDate)})`
|
||||
: `${customerName} - ${amount.toLocaleString('sv-SE')} kr (${formatDate(dueDate)})`
|
||||
|
||||
return {
|
||||
title,
|
||||
body,
|
||||
icon: '/icons/icon-192.png',
|
||||
badge: '/icons/badge-72.png',
|
||||
tag: `invoice-${invoiceId}`,
|
||||
data: {
|
||||
url: `/invoices/${invoiceId}`,
|
||||
type: isOverdue ? 'invoice_overdue' : 'invoice_due',
|
||||
id: invoiceId,
|
||||
},
|
||||
actions: [
|
||||
{ action: 'view', title: 'Visa faktura' },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date for notifications
|
||||
*/
|
||||
function formatDate(dateStr: string): string {
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleDateString('sv-SE', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
})
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
mapSuggestedCategory,
|
||||
getBASAccount,
|
||||
categorizeLineItem,
|
||||
processLineItems,
|
||||
calculateReceiptSplit,
|
||||
getDefaultClassification,
|
||||
} from '../receipt-categorizer'
|
||||
import type { ExtractedLineItem } from '@/types'
|
||||
|
||||
describe('mapSuggestedCategory', () => {
|
||||
it('maps AI categories to TransactionCategory', () => {
|
||||
expect(mapSuggestedCategory('equipment')).toBe('expense_equipment')
|
||||
expect(mapSuggestedCategory('software')).toBe('expense_software')
|
||||
expect(mapSuggestedCategory('travel')).toBe('expense_travel')
|
||||
expect(mapSuggestedCategory('office')).toBe('expense_office')
|
||||
expect(mapSuggestedCategory('marketing')).toBe('expense_marketing')
|
||||
expect(mapSuggestedCategory('professional_services')).toBe('expense_professional_services')
|
||||
expect(mapSuggestedCategory('education')).toBe('expense_education')
|
||||
expect(mapSuggestedCategory('other')).toBe('expense_other')
|
||||
})
|
||||
|
||||
it('returns null for unknown categories', () => {
|
||||
expect(mapSuggestedCategory('nonexistent')).toBeNull()
|
||||
expect(mapSuggestedCategory(null)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getBASAccount', () => {
|
||||
it('returns correct BAS account per category', () => {
|
||||
expect(getBASAccount('expense_equipment')).toBe('5410')
|
||||
expect(getBASAccount('expense_software')).toBe('5420')
|
||||
expect(getBASAccount('expense_travel')).toBe('5800')
|
||||
expect(getBASAccount('expense_office')).toBe('5010')
|
||||
expect(getBASAccount('expense_marketing')).toBe('5910')
|
||||
expect(getBASAccount('expense_professional_services')).toBe('6530')
|
||||
expect(getBASAccount('expense_education')).toBe('6991')
|
||||
expect(getBASAccount('expense_bank_fees')).toBe('6570')
|
||||
expect(getBASAccount('income_services')).toBe('3001')
|
||||
})
|
||||
})
|
||||
|
||||
describe('categorizeLineItem', () => {
|
||||
it('keyword patterns match Swedish terms — dator → expense_equipment', () => {
|
||||
const result = categorizeLineItem('MacBook Pro dator')
|
||||
expect(result.category).toBe('expense_equipment')
|
||||
expect(result.confidence).toBe(0.7)
|
||||
})
|
||||
|
||||
it('matches software patterns', () => {
|
||||
const result = categorizeLineItem('Adobe Creative Cloud prenumeration')
|
||||
expect(result.category).toBe('expense_software')
|
||||
})
|
||||
|
||||
it('matches travel patterns', () => {
|
||||
const result = categorizeLineItem('SJ tåg Stockholm-Malmö')
|
||||
expect(result.category).toBe('expense_travel')
|
||||
})
|
||||
|
||||
it('returns null category for unrecognized descriptions', () => {
|
||||
const result = categorizeLineItem('xyzzy foobarbaz')
|
||||
expect(result.category).toBeNull()
|
||||
expect(result.confidence).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('processLineItems', () => {
|
||||
it('prefers AI suggestion over pattern match', () => {
|
||||
const items: ExtractedLineItem[] = [
|
||||
{
|
||||
description: 'MacBook Pro dator', // pattern → equipment
|
||||
quantity: 1,
|
||||
unitPrice: 15000,
|
||||
lineTotal: 15000,
|
||||
vatRate: 25,
|
||||
suggestedCategory: 'software', // AI says software
|
||||
confidence: 0.9,
|
||||
},
|
||||
]
|
||||
|
||||
const result = processLineItems(items)
|
||||
expect(result[0].category).toBe('expense_software') // AI wins
|
||||
expect(result[0].basAccount).toBe('5420')
|
||||
})
|
||||
|
||||
it('falls back to pattern match when no AI suggestion', () => {
|
||||
const items: ExtractedLineItem[] = [
|
||||
{
|
||||
description: 'MacBook Pro dator',
|
||||
quantity: 1,
|
||||
unitPrice: 15000,
|
||||
lineTotal: 15000,
|
||||
vatRate: 25,
|
||||
suggestedCategory: null,
|
||||
},
|
||||
]
|
||||
|
||||
const result = processLineItems(items)
|
||||
expect(result[0].category).toBe('expense_equipment') // pattern match
|
||||
expect(result[0].basAccount).toBe('5410')
|
||||
})
|
||||
})
|
||||
|
||||
describe('calculateReceiptSplit', () => {
|
||||
it('correct business/private/unclassified totals', () => {
|
||||
const items = [
|
||||
{ lineTotal: 100, is_business: true as boolean | null },
|
||||
{ lineTotal: 50, is_business: false as boolean | null },
|
||||
{ lineTotal: 25, is_business: null as boolean | null },
|
||||
]
|
||||
|
||||
const result = calculateReceiptSplit(items)
|
||||
expect(result.businessTotal).toBe(100)
|
||||
expect(result.privateTotal).toBe(50)
|
||||
expect(result.unclassifiedTotal).toBe(25)
|
||||
// 100 / 175 * 100 = 57.142... → 57.1
|
||||
expect(result.businessPercentage).toBeCloseTo(57.1, 1)
|
||||
})
|
||||
|
||||
it('handles rounding correctly', () => {
|
||||
const items = [
|
||||
{ lineTotal: 33.333, is_business: true as boolean | null },
|
||||
{ lineTotal: 66.667, is_business: false as boolean | null },
|
||||
]
|
||||
|
||||
const result = calculateReceiptSplit(items)
|
||||
expect(result.businessTotal).toBe(33.33)
|
||||
expect(result.privateTotal).toBe(66.67)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getDefaultClassification', () => {
|
||||
it('Systembolaget defaults to private', () => {
|
||||
const result = getDefaultClassification(false, true)
|
||||
expect(result.defaultIsBusiness).toBe(false)
|
||||
expect(result.requiresReview).toBe(true)
|
||||
expect(result.warningMessage).toContain('Alkohol')
|
||||
})
|
||||
|
||||
it('restaurant requires review', () => {
|
||||
const result = getDefaultClassification(true, false)
|
||||
expect(result.defaultIsBusiness).toBeNull()
|
||||
expect(result.requiresReview).toBe(true)
|
||||
expect(result.warningMessage).toContain('Restaurangbesök')
|
||||
})
|
||||
|
||||
it('non-restaurant, non-systembolaget has no warning', () => {
|
||||
const result = getDefaultClassification(false, false)
|
||||
expect(result.defaultIsBusiness).toBeNull()
|
||||
expect(result.requiresReview).toBe(false)
|
||||
expect(result.warningMessage).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,286 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
findTransactionMatches,
|
||||
autoMatchReceipts,
|
||||
filterUnmatchedTransactions,
|
||||
filterUnmatchedReceipts,
|
||||
} from '../receipt-matcher'
|
||||
import { makeReceipt, makeTransaction } from '@/tests/helpers'
|
||||
|
||||
describe('findTransactionMatches', () => {
|
||||
it('exact date + exact amount → high confidence', () => {
|
||||
const receipt = makeReceipt({
|
||||
receipt_date: '2024-06-15',
|
||||
total_amount: 299,
|
||||
merchant_name: 'ICA Maxi',
|
||||
})
|
||||
const transactions = [
|
||||
makeTransaction({
|
||||
date: '2024-06-15',
|
||||
amount: -299,
|
||||
merchant_name: 'ICA Maxi',
|
||||
receipt_id: null,
|
||||
}),
|
||||
]
|
||||
|
||||
const matches = findTransactionMatches(receipt, transactions)
|
||||
expect(matches).toHaveLength(1)
|
||||
expect(matches[0].confidence).toBeGreaterThanOrEqual(0.8)
|
||||
expect(matches[0].dateVariance).toBe(0)
|
||||
})
|
||||
|
||||
it('date within ±3 days → matches with lower confidence', () => {
|
||||
const receipt = makeReceipt({
|
||||
receipt_date: '2024-06-15',
|
||||
total_amount: 500,
|
||||
merchant_name: '',
|
||||
})
|
||||
const transactions = [
|
||||
makeTransaction({
|
||||
date: '2024-06-17',
|
||||
amount: -500,
|
||||
merchant_name: '',
|
||||
receipt_id: null,
|
||||
}),
|
||||
]
|
||||
|
||||
const matches = findTransactionMatches(receipt, transactions)
|
||||
expect(matches.length).toBeGreaterThanOrEqual(1)
|
||||
expect(matches[0].dateVariance).toBeCloseTo(2, 0)
|
||||
})
|
||||
|
||||
it('date outside ±3 days → no match', () => {
|
||||
const receipt = makeReceipt({
|
||||
receipt_date: '2024-06-15',
|
||||
total_amount: 500,
|
||||
merchant_name: '',
|
||||
})
|
||||
const transactions = [
|
||||
makeTransaction({
|
||||
date: '2024-06-25',
|
||||
amount: -500,
|
||||
merchant_name: '',
|
||||
receipt_id: null,
|
||||
}),
|
||||
]
|
||||
|
||||
const matches = findTransactionMatches(receipt, transactions)
|
||||
expect(matches).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('amount within 5% tolerance → matches', () => {
|
||||
const receipt = makeReceipt({
|
||||
receipt_date: '2024-06-15',
|
||||
total_amount: 1000,
|
||||
merchant_name: '',
|
||||
})
|
||||
// 4% off = 960
|
||||
const transactions = [
|
||||
makeTransaction({
|
||||
date: '2024-06-15',
|
||||
amount: -960,
|
||||
merchant_name: '',
|
||||
receipt_id: null,
|
||||
}),
|
||||
]
|
||||
|
||||
const matches = findTransactionMatches(receipt, transactions)
|
||||
expect(matches.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('amount outside tolerance → no match', () => {
|
||||
const receipt = makeReceipt({
|
||||
receipt_date: '2024-06-15',
|
||||
total_amount: 1000,
|
||||
merchant_name: '',
|
||||
is_foreign_merchant: false,
|
||||
})
|
||||
// 10% off = 900 (way above 5%)
|
||||
const transactions = [
|
||||
makeTransaction({
|
||||
date: '2024-06-15',
|
||||
amount: -900,
|
||||
merchant_name: '',
|
||||
receipt_id: null,
|
||||
}),
|
||||
]
|
||||
|
||||
const matches = findTransactionMatches(receipt, transactions)
|
||||
expect(matches).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('merchant name similarity boosts confidence', () => {
|
||||
const receipt = makeReceipt({
|
||||
receipt_date: '2024-06-15',
|
||||
total_amount: 500,
|
||||
merchant_name: 'Coop Konsum',
|
||||
})
|
||||
|
||||
const txWithMerchant = makeTransaction({
|
||||
id: 'tx-with',
|
||||
date: '2024-06-15',
|
||||
amount: -500,
|
||||
merchant_name: 'Coop Konsum Stockholm',
|
||||
receipt_id: null,
|
||||
})
|
||||
const txWithout = makeTransaction({
|
||||
id: 'tx-without',
|
||||
date: '2024-06-15',
|
||||
amount: -500,
|
||||
merchant_name: '',
|
||||
receipt_id: null,
|
||||
})
|
||||
|
||||
const matchesWithMerchant = findTransactionMatches(receipt, [txWithMerchant])
|
||||
const matchesWithout = findTransactionMatches(receipt, [txWithout])
|
||||
|
||||
// Both should match since date+amount are exact
|
||||
expect(matchesWithMerchant.length).toBeGreaterThanOrEqual(1)
|
||||
expect(matchesWithout.length).toBeGreaterThanOrEqual(1)
|
||||
|
||||
// Merchant match should have higher confidence
|
||||
expect(matchesWithMerchant[0].confidence).toBeGreaterThan(
|
||||
matchesWithout[0].confidence
|
||||
)
|
||||
})
|
||||
|
||||
it('skips already-matched transactions (receipt_id set)', () => {
|
||||
const receipt = makeReceipt({
|
||||
receipt_date: '2024-06-15',
|
||||
total_amount: 299,
|
||||
})
|
||||
const transactions = [
|
||||
makeTransaction({
|
||||
date: '2024-06-15',
|
||||
amount: -299,
|
||||
receipt_id: 'already-matched',
|
||||
}),
|
||||
]
|
||||
|
||||
const matches = findTransactionMatches(receipt, transactions)
|
||||
expect(matches).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('skips income transactions (amount >= 0)', () => {
|
||||
const receipt = makeReceipt({
|
||||
receipt_date: '2024-06-15',
|
||||
total_amount: 299,
|
||||
})
|
||||
const transactions = [
|
||||
makeTransaction({
|
||||
date: '2024-06-15',
|
||||
amount: 299, // income, positive
|
||||
receipt_id: null,
|
||||
}),
|
||||
]
|
||||
|
||||
const matches = findTransactionMatches(receipt, transactions)
|
||||
expect(matches).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('autoMatchReceipts', () => {
|
||||
it('returns matches above threshold', () => {
|
||||
const receipts = [
|
||||
makeReceipt({
|
||||
id: 'r1',
|
||||
receipt_date: '2024-06-15',
|
||||
total_amount: 500,
|
||||
merchant_name: 'Coop',
|
||||
matched_transaction_id: null,
|
||||
}),
|
||||
]
|
||||
const transactions = [
|
||||
makeTransaction({
|
||||
id: 'tx1',
|
||||
date: '2024-06-15',
|
||||
amount: -500,
|
||||
merchant_name: 'Coop',
|
||||
receipt_id: null,
|
||||
}),
|
||||
]
|
||||
|
||||
const results = autoMatchReceipts(receipts, transactions, 0.5)
|
||||
expect(results).toHaveLength(1)
|
||||
expect(results[0].receipt.id).toBe('r1')
|
||||
expect(results[0].match.confidence).toBeGreaterThanOrEqual(0.5)
|
||||
})
|
||||
|
||||
it('respects custom threshold', () => {
|
||||
const receipts = [
|
||||
makeReceipt({
|
||||
id: 'r1',
|
||||
receipt_date: '2024-06-15',
|
||||
total_amount: 500,
|
||||
merchant_name: '',
|
||||
matched_transaction_id: null,
|
||||
}),
|
||||
]
|
||||
const transactions = [
|
||||
makeTransaction({
|
||||
id: 'tx1',
|
||||
date: '2024-06-17', // 2 days off, no merchant → moderate confidence
|
||||
amount: -500,
|
||||
merchant_name: '',
|
||||
receipt_id: null,
|
||||
}),
|
||||
]
|
||||
|
||||
// With a very high threshold, it should not match
|
||||
const highThreshold = autoMatchReceipts(receipts, transactions, 0.99)
|
||||
expect(highThreshold).toHaveLength(0)
|
||||
|
||||
// With a lower threshold, it should match
|
||||
const lowThreshold = autoMatchReceipts(receipts, transactions, 0.4)
|
||||
expect(lowThreshold).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('skips already-matched receipts', () => {
|
||||
const receipts = [
|
||||
makeReceipt({
|
||||
id: 'r1',
|
||||
receipt_date: '2024-06-15',
|
||||
total_amount: 500,
|
||||
matched_transaction_id: 'existing-tx',
|
||||
}),
|
||||
]
|
||||
const transactions = [
|
||||
makeTransaction({
|
||||
date: '2024-06-15',
|
||||
amount: -500,
|
||||
receipt_id: null,
|
||||
}),
|
||||
]
|
||||
|
||||
const results = autoMatchReceipts(receipts, transactions)
|
||||
expect(results).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('filterUnmatchedTransactions', () => {
|
||||
it('returns only unmatched expenses', () => {
|
||||
const transactions = [
|
||||
makeTransaction({ id: 't1', receipt_id: null, amount: -100 }),
|
||||
makeTransaction({ id: 't2', receipt_id: 'r1', amount: -200 }), // matched
|
||||
makeTransaction({ id: 't3', receipt_id: null, amount: 300 }), // income
|
||||
]
|
||||
|
||||
const result = filterUnmatchedTransactions(transactions)
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].id).toBe('t1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('filterUnmatchedReceipts', () => {
|
||||
it('returns only confirmed unmatched receipts', () => {
|
||||
const receipts = [
|
||||
makeReceipt({ id: 'r1', status: 'confirmed', matched_transaction_id: null }),
|
||||
makeReceipt({ id: 'r2', status: 'confirmed', matched_transaction_id: 'tx1' }),
|
||||
makeReceipt({ id: 'r3', status: 'extracted', matched_transaction_id: null }),
|
||||
]
|
||||
|
||||
const result = filterUnmatchedReceipts(receipts)
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].id).toBe('r1')
|
||||
})
|
||||
})
|
||||
@@ -1,460 +0,0 @@
|
||||
/**
|
||||
* Receipt Analyzer using Claude Haiku Vision API
|
||||
*
|
||||
* SERVER-ONLY: This module uses the Anthropic SDK and must only be imported
|
||||
* in server components or API routes.
|
||||
*
|
||||
* Analyzes receipt images and extracts line items, merchant info,
|
||||
* and special flags (restaurant, Systembolaget, foreign merchant).
|
||||
*/
|
||||
|
||||
import 'server-only'
|
||||
import Anthropic from '@anthropic-ai/sdk'
|
||||
import { ChatAnthropic } from '@langchain/anthropic'
|
||||
import { HumanMessage, SystemMessage } from '@langchain/core/messages'
|
||||
import { z } from 'zod'
|
||||
import type { ReceiptExtractionResult, ExtractedLineItem } from '@/types'
|
||||
import {
|
||||
SYSTEMBOLAGET_PATTERNS,
|
||||
RESTAURANT_PATTERNS,
|
||||
RESTAURANT_MCC_CODES,
|
||||
} from './receipt-utils'
|
||||
|
||||
// Re-export client-safe functions for convenience
|
||||
export {
|
||||
calculateRepresentationLimits,
|
||||
isRestaurantMCC,
|
||||
detectSystembolaget,
|
||||
detectRestaurant,
|
||||
} from './receipt-utils'
|
||||
|
||||
const anthropic = new Anthropic()
|
||||
|
||||
// Maximum retries for API calls
|
||||
const MAX_RETRIES = 3
|
||||
const RETRY_DELAY_MS = 1000
|
||||
|
||||
/**
|
||||
* Analyze a receipt image using Claude Haiku Vision
|
||||
*/
|
||||
export async function analyzeReceipt(
|
||||
imageBase64: string,
|
||||
mimeType: 'image/jpeg' | 'image/png' | 'image/webp' | 'image/gif' = 'image/jpeg'
|
||||
): Promise<ReceiptExtractionResult> {
|
||||
const systemPrompt = `Du är expert på att extrahera data från svenska kvitton och fakturor.
|
||||
Din uppgift är att noggrant analysera kvittobilden och extrahera all relevant information.
|
||||
|
||||
VIKTIGT:
|
||||
- Extrahera VARJE artikelrad, inte bara summan
|
||||
- Identifiera momssats per rad om möjligt (25%, 12%, 6%)
|
||||
- Flagga om detta är: restaurang, Systembolaget, eller utländsk handlare
|
||||
- Svenska organisationsnummer är i format XXXXXX-XXXX
|
||||
- Momsregistreringsnummer börjar med SE
|
||||
- Datum ska vara i ISO-format (YYYY-MM-DD)
|
||||
- Belopp ska vara numeriska värden utan valutasymboler
|
||||
- Ange konfidenstal (0.0-1.0) för hela extraheringen baserat på bildkvalitet`
|
||||
|
||||
const userPrompt = `Analysera detta kvitto och extrahera strukturerad data.
|
||||
|
||||
Returnera ett JSON-objekt med följande struktur:
|
||||
|
||||
{
|
||||
"merchant": {
|
||||
"name": "Handlarens namn",
|
||||
"orgNumber": "XXXXXX-XXXX eller null",
|
||||
"vatNumber": "SE... eller null",
|
||||
"isForeign": false
|
||||
},
|
||||
"receipt": {
|
||||
"date": "YYYY-MM-DD",
|
||||
"time": "HH:MM eller null",
|
||||
"currency": "SEK"
|
||||
},
|
||||
"lineItems": [
|
||||
{
|
||||
"description": "Artikelbeskrivning",
|
||||
"quantity": 1,
|
||||
"unitPrice": 100.00,
|
||||
"lineTotal": 100.00,
|
||||
"vatRate": 25,
|
||||
"suggestedCategory": "equipment|software|travel|office|marketing|professional_services|education|other"
|
||||
}
|
||||
],
|
||||
"totals": {
|
||||
"subtotal": 100.00,
|
||||
"vatAmount": 25.00,
|
||||
"total": 125.00
|
||||
},
|
||||
"flags": {
|
||||
"isRestaurant": false,
|
||||
"isSystembolaget": false,
|
||||
"isForeignMerchant": false
|
||||
},
|
||||
"confidence": 0.95
|
||||
}
|
||||
|
||||
KATEGORIER för suggestedCategory:
|
||||
- equipment: Datorer, telefoner, kameror, teknikprylar
|
||||
- software: Program, appar, molntjänster, prenumerationer
|
||||
- travel: Flyg, tåg, hotell, taxi
|
||||
- office: Kontorsmaterial, möbler, hyra
|
||||
- marketing: Reklam, marknadsföring, PR
|
||||
- professional_services: Konsulter, redovisning, juridik
|
||||
- education: Kurser, böcker, utbildning
|
||||
- other: Övrigt
|
||||
|
||||
Returnera ENDAST JSON-objektet, ingen annan text.`
|
||||
|
||||
let lastError: Error | null = null
|
||||
|
||||
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
const message = await anthropic.messages.create({
|
||||
model: 'claude-haiku-4-5-20251001',
|
||||
max_tokens: 4096,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'image',
|
||||
source: {
|
||||
type: 'base64',
|
||||
media_type: mimeType,
|
||||
data: imageBase64,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: userPrompt,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
system: systemPrompt,
|
||||
})
|
||||
|
||||
// Extract the text content
|
||||
const content = message.content[0]
|
||||
if (content.type !== 'text') {
|
||||
throw new Error('Unexpected response type from AI')
|
||||
}
|
||||
|
||||
// Parse the JSON 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)
|
||||
|
||||
// Validate and enhance the result
|
||||
return validateAndEnhanceResult(parsed)
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error('Unknown error')
|
||||
|
||||
// Don't retry on JSON parse errors
|
||||
if (error instanceof SyntaxError) {
|
||||
throw new Error(`Failed to parse AI response: ${lastError.message}`)
|
||||
}
|
||||
|
||||
// Wait before retrying
|
||||
if (attempt < MAX_RETRIES - 1) {
|
||||
await sleep(RETRY_DELAY_MS * (attempt + 1))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Receipt analysis failed after ${MAX_RETRIES} attempts: ${lastError?.message}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and enhance the extraction result
|
||||
*/
|
||||
function validateAndEnhanceResult(raw: unknown): ReceiptExtractionResult {
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
throw new Error('Invalid extraction result: not an object')
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const data = raw as any
|
||||
|
||||
const merchant = data.merchant || {}
|
||||
const receipt = data.receipt || {}
|
||||
const totals = data.totals || {}
|
||||
const flags = data.flags || {}
|
||||
|
||||
// Validate line items
|
||||
const lineItems = validateLineItems(data.lineItems)
|
||||
|
||||
// Detect special merchants if not already flagged
|
||||
const merchantName = (merchant.name || '').toLowerCase()
|
||||
const isSystembolaget = flags.isSystembolaget || detectSystembolagetLocal(merchantName)
|
||||
const isRestaurant = flags.isRestaurant || detectRestaurantLocal(merchantName)
|
||||
const isForeign = flags.isForeignMerchant || detectForeignMerchant(merchant, receipt.currency)
|
||||
|
||||
const result: ReceiptExtractionResult = {
|
||||
merchant: {
|
||||
name: validateString(merchant.name),
|
||||
orgNumber: validateOrgNumber(merchant.orgNumber),
|
||||
vatNumber: validateVatNumber(merchant.vatNumber),
|
||||
isForeign,
|
||||
},
|
||||
receipt: {
|
||||
date: validateDate(receipt.date),
|
||||
time: validateTime(receipt.time),
|
||||
currency: validateString(receipt.currency) || 'SEK',
|
||||
},
|
||||
lineItems,
|
||||
totals: {
|
||||
subtotal: validateNumber(totals.subtotal),
|
||||
vatAmount: validateNumber(totals.vatAmount),
|
||||
total: validateNumber(totals.total),
|
||||
},
|
||||
flags: {
|
||||
isRestaurant,
|
||||
isSystembolaget,
|
||||
isForeignMerchant: isForeign,
|
||||
},
|
||||
confidence: validateNumber(data.confidence) || 0.5,
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate line items array
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function validateLineItems(data: any): ExtractedLineItem[] {
|
||||
if (!Array.isArray(data)) return []
|
||||
|
||||
return data
|
||||
.filter((item) => item && typeof item === 'object' && item.description)
|
||||
.map((item) => ({
|
||||
description: String(item.description).trim(),
|
||||
quantity: validateNumber(item.quantity) || 1,
|
||||
unitPrice: validateNumber(item.unitPrice),
|
||||
lineTotal: validateNumber(item.lineTotal) || 0,
|
||||
vatRate: validateNumber(item.vatRate),
|
||||
suggestedCategory: validateCategory(item.suggestedCategory),
|
||||
confidence: validateNumber(item.confidence) || undefined,
|
||||
}))
|
||||
.filter((item) => item.lineTotal > 0 || item.description.length > 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate expense category suggestion
|
||||
*/
|
||||
function validateCategory(value: unknown): string | null {
|
||||
const validCategories = [
|
||||
'equipment',
|
||||
'software',
|
||||
'travel',
|
||||
'office',
|
||||
'marketing',
|
||||
'professional_services',
|
||||
'education',
|
||||
'other',
|
||||
]
|
||||
|
||||
if (typeof value === 'string' && validCategories.includes(value)) {
|
||||
return value
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if merchant is Systembolaget (local version using imported patterns)
|
||||
*/
|
||||
function detectSystembolagetLocal(merchantName: string): boolean {
|
||||
return SYSTEMBOLAGET_PATTERNS.some((pattern) => merchantName.includes(pattern))
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if merchant is a restaurant (local version using imported patterns)
|
||||
*/
|
||||
function detectRestaurantLocal(merchantName: string): boolean {
|
||||
return RESTAURANT_PATTERNS.some((pattern) => merchantName.includes(pattern))
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if merchant is foreign (non-Swedish)
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function detectForeignMerchant(merchant: any, currency?: string): boolean {
|
||||
// Check currency (non-SEK suggests foreign)
|
||||
if (currency && currency !== 'SEK') {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check org number format (Swedish org numbers are 10 digits)
|
||||
const orgNumber = merchant.orgNumber || ''
|
||||
if (orgNumber && !isSwedishOrgNumber(orgNumber)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check VAT number (Swedish starts with SE)
|
||||
const vatNumber = merchant.vatNumber || ''
|
||||
if (vatNumber && !vatNumber.toUpperCase().startsWith('SE')) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if org number is Swedish format
|
||||
*/
|
||||
function isSwedishOrgNumber(value: string): boolean {
|
||||
const digits = value.replace(/\D/g, '')
|
||||
return digits.length === 10
|
||||
}
|
||||
|
||||
// Validation helpers
|
||||
function validateString(value: unknown): string | null {
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
return value.trim()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function validateNumber(value: unknown): number | null {
|
||||
if (typeof value === 'number' && !isNaN(value)) {
|
||||
return value
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const parsed = parseFloat(value.replace(/[^\d.-]/g, ''))
|
||||
if (!isNaN(parsed)) {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function validateDate(value: unknown): string | null {
|
||||
if (typeof value !== 'string') return null
|
||||
|
||||
const date = new Date(value)
|
||||
if (isNaN(date.getTime())) return null
|
||||
|
||||
return date.toISOString().split('T')[0]
|
||||
}
|
||||
|
||||
function validateTime(value: unknown): string | null {
|
||||
if (typeof value !== 'string') return null
|
||||
|
||||
// Match HH:MM or HH:MM:SS
|
||||
const match = value.match(/^(\d{2}):(\d{2})(:\d{2})?$/)
|
||||
if (match) {
|
||||
return `${match[1]}:${match[2]}`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function validateOrgNumber(value: unknown): string | null {
|
||||
if (typeof value !== 'string') return null
|
||||
|
||||
const digits = value.replace(/\D/g, '')
|
||||
|
||||
// Swedish org numbers are 10 digits
|
||||
if (digits.length === 10) {
|
||||
return `${digits.slice(0, 6)}-${digits.slice(6)}`
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function validateVatNumber(value: unknown): string | null {
|
||||
if (typeof value !== 'string') return null
|
||||
|
||||
const cleaned = value.trim().toUpperCase()
|
||||
if (cleaned.startsWith('SE') && cleaned.length >= 12) {
|
||||
return cleaned
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
const ProductEstimationSchema = z.object({
|
||||
estimatedValue: z.number().describe('Uppskattat marknadsvärde i SEK'),
|
||||
confidence: z.number().min(0).max(1).describe('Hur säker du är på uppskattningen (0.0-1.0)'),
|
||||
description: z.string().describe('Kort beskrivning av produkten'),
|
||||
brand: z.string().nullable().describe('Varumärke om identifierbart, annars null'),
|
||||
})
|
||||
|
||||
/**
|
||||
* Estimate product value from image using Claude Vision via LangChain
|
||||
* Used for gift/product registration without receipt
|
||||
*/
|
||||
export async function estimateProductValue(
|
||||
imageBase64: string,
|
||||
mimeType: 'image/jpeg' | 'image/png' | 'image/webp' | 'image/gif' = 'image/jpeg'
|
||||
): Promise<{
|
||||
estimatedValue: number
|
||||
confidence: number
|
||||
description: string
|
||||
brand: string | null
|
||||
}> {
|
||||
const model = new ChatAnthropic({
|
||||
modelName: 'claude-haiku-4-5-20251001',
|
||||
maxTokens: 1024,
|
||||
temperature: 0,
|
||||
anthropicApiKey: process.env.ANTHROPIC_API_KEY,
|
||||
})
|
||||
|
||||
const structuredModel = model.withStructuredOutput(ProductEstimationSchema)
|
||||
|
||||
const systemPrompt = `Du är expert på att uppskatta marknadsvärdet på produkter baserat på bilder.
|
||||
Din uppgift är att identifiera produkten och ge en rimlig uppskattning av dess marknadsvärde i svenska kronor.`
|
||||
|
||||
const userPrompt = `Analysera produkten i bilden och uppskatta dess marknadsvärde i SEK. Identifiera varumärke om möjligt.`
|
||||
|
||||
let lastError: Error | null = null
|
||||
|
||||
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
const result = await structuredModel.invoke([
|
||||
new SystemMessage(systemPrompt),
|
||||
new HumanMessage({
|
||||
content: [
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: { url: `data:${mimeType};base64,${imageBase64}` },
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: userPrompt,
|
||||
},
|
||||
],
|
||||
}),
|
||||
])
|
||||
|
||||
return {
|
||||
estimatedValue: result.estimatedValue || 0,
|
||||
confidence: result.confidence || 0.5,
|
||||
description: result.description || 'Okänd produkt',
|
||||
brand: result.brand,
|
||||
}
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error('Unknown error')
|
||||
|
||||
if (attempt < MAX_RETRIES - 1) {
|
||||
await sleep(RETRY_DELAY_MS * (attempt + 1))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Product value estimation failed after ${MAX_RETRIES} attempts: ${lastError?.message}`)
|
||||
}
|
||||
@@ -1,297 +0,0 @@
|
||||
/**
|
||||
* Receipt Categorizer - Auto-categorization of receipt line items
|
||||
*
|
||||
* Maps line item descriptions to BAS accounts and expense categories
|
||||
* using pattern matching and AI-suggested categories.
|
||||
*/
|
||||
|
||||
import type { TransactionCategory, ReceiptLineItem, ExtractedLineItem } from '@/types'
|
||||
|
||||
// Category mappings from suggested category to TransactionCategory
|
||||
const CATEGORY_MAPPING: Record<string, TransactionCategory> = {
|
||||
equipment: 'expense_equipment',
|
||||
software: 'expense_software',
|
||||
travel: 'expense_travel',
|
||||
office: 'expense_office',
|
||||
marketing: 'expense_marketing',
|
||||
professional_services: 'expense_professional_services',
|
||||
education: 'expense_education',
|
||||
other: 'expense_other',
|
||||
}
|
||||
|
||||
// BAS account mappings for expense categories
|
||||
const BAS_ACCOUNT_MAPPING: Record<TransactionCategory, string> = {
|
||||
expense_equipment: '5410', // Förbrukningsinventarier
|
||||
expense_software: '5420', // Programvaror
|
||||
expense_travel: '5800', // Resekostnader
|
||||
expense_office: '5010', // Lokalhyra / Kontorsmaterial
|
||||
expense_marketing: '5910', // Annonsering
|
||||
expense_professional_services: '6530', // Redovisningstjänster
|
||||
expense_education: '6991', // Övriga avdragsgilla kostnader
|
||||
expense_bank_fees: '6570', // Bankavgifter
|
||||
expense_card_fees: '6570', // Kortavgifter
|
||||
expense_currency_exchange: '7960', // Valutakursförluster
|
||||
expense_other: '6991', // Övriga avdragsgilla kostnader
|
||||
// Non-expense categories (for completeness)
|
||||
income_services: '3001',
|
||||
income_products: '3001',
|
||||
income_other: '3900',
|
||||
private: '2013',
|
||||
uncategorized: '6991',
|
||||
}
|
||||
|
||||
// Keyword patterns for auto-categorization
|
||||
const CATEGORY_PATTERNS: Array<{
|
||||
category: TransactionCategory
|
||||
patterns: RegExp[]
|
||||
}> = [
|
||||
{
|
||||
category: 'expense_equipment',
|
||||
patterns: [
|
||||
/\b(dator|laptop|macbook|ipad|tablet|telefon|iphone|samsung|kamera|camera|mikrofon|microphone|belysning|lighting|stativ|tripod|usb|kabel|adapter|hörlurar|headphones|airpods)\b/i,
|
||||
/\b(apple|logitech|sony|canon|nikon|rode|elgato|razer)\b/i,
|
||||
],
|
||||
},
|
||||
{
|
||||
category: 'expense_software',
|
||||
patterns: [
|
||||
/\b(adobe|microsoft|spotify|netflix|youtube premium|dropbox|google|icloud|canva|notion|slack|zoom|teams|subscription|prenumeration|licens|license)\b/i,
|
||||
/\b(app|program|software|saas|cloud)\b/i,
|
||||
],
|
||||
},
|
||||
{
|
||||
category: 'expense_travel',
|
||||
patterns: [
|
||||
/\b(flyg|flight|hotell|hotel|taxi|uber|bolt|tåg|train|sj|arlanda|bromma|landvetter|bensin|fuel|parkering|parking|biljett|ticket)\b/i,
|
||||
/\b(resa|travel|transport|resekostnad)\b/i,
|
||||
],
|
||||
},
|
||||
{
|
||||
category: 'expense_office',
|
||||
patterns: [
|
||||
/\b(hyra|rent|el|electricity|internet|bredband|vatten|försäkring|insurance|städning|cleaning|kontorsmaterial|papper|toner|skrivare|printer)\b/i,
|
||||
/\b(ikea|clas ohlson|biltema|staples|lyreco)\b/i,
|
||||
],
|
||||
},
|
||||
{
|
||||
category: 'expense_marketing',
|
||||
patterns: [
|
||||
/\b(reklam|advertising|annons|ad|meta ads|facebook ads|instagram ads|google ads|tiktok ads|influencer|sponsor|kampanj|campaign|pr|press)\b/i,
|
||||
/\b(marknadsföring|marketing|promotion)\b/i,
|
||||
],
|
||||
},
|
||||
{
|
||||
category: 'expense_professional_services',
|
||||
patterns: [
|
||||
/\b(konsult|consultant|redovisning|accounting|bokföring|bookkeeping|juridik|legal|advokat|lawyer|revisor|auditor)\b/i,
|
||||
/\b(tjänst|service|arvode|fee)\b/i,
|
||||
],
|
||||
},
|
||||
{
|
||||
category: 'expense_education',
|
||||
patterns: [
|
||||
/\b(kurs|course|utbildning|training|bok|book|seminar|konferens|conference|workshop|webinar|certifiering|certification)\b/i,
|
||||
/\b(lärande|learning|studie|study)\b/i,
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* Get the TransactionCategory from AI-suggested category
|
||||
*/
|
||||
export function mapSuggestedCategory(suggestedCategory: string | null): TransactionCategory | null {
|
||||
if (!suggestedCategory) return null
|
||||
return CATEGORY_MAPPING[suggestedCategory] || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get BAS account for a category
|
||||
*/
|
||||
export function getBASAccount(category: TransactionCategory): string {
|
||||
return BAS_ACCOUNT_MAPPING[category] || '6991'
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-categorize a line item based on description
|
||||
*/
|
||||
export function categorizeLineItem(description: string): {
|
||||
category: TransactionCategory | null
|
||||
confidence: number
|
||||
} {
|
||||
const normalizedDescription = description.toLowerCase()
|
||||
|
||||
for (const { category, patterns } of CATEGORY_PATTERNS) {
|
||||
for (const pattern of patterns) {
|
||||
if (pattern.test(normalizedDescription)) {
|
||||
return {
|
||||
category,
|
||||
confidence: 0.7, // Pattern-based confidence
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
category: null,
|
||||
confidence: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process extracted line items and add category suggestions
|
||||
*/
|
||||
export function processLineItems(
|
||||
lineItems: ExtractedLineItem[]
|
||||
): Array<ExtractedLineItem & { category: TransactionCategory | null; basAccount: string | null }> {
|
||||
return lineItems.map((item) => {
|
||||
// First try the AI-suggested category
|
||||
let category = mapSuggestedCategory(item.suggestedCategory)
|
||||
let confidence = item.confidence || 0.8
|
||||
|
||||
// If no AI suggestion, try pattern matching
|
||||
if (!category) {
|
||||
const patternResult = categorizeLineItem(item.description)
|
||||
category = patternResult.category
|
||||
confidence = patternResult.confidence
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
category,
|
||||
basAccount: category ? getBASAccount(category) : null,
|
||||
confidence,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate split amounts for mixed business/private receipt
|
||||
*/
|
||||
export function calculateReceiptSplit(
|
||||
lineItems: Array<{ lineTotal: number; is_business: boolean | null }>
|
||||
): {
|
||||
businessTotal: number
|
||||
privateTotal: number
|
||||
unclassifiedTotal: number
|
||||
businessPercentage: number
|
||||
} {
|
||||
let businessTotal = 0
|
||||
let privateTotal = 0
|
||||
let unclassifiedTotal = 0
|
||||
|
||||
for (const item of lineItems) {
|
||||
if (item.is_business === true) {
|
||||
businessTotal += item.lineTotal
|
||||
} else if (item.is_business === false) {
|
||||
privateTotal += item.lineTotal
|
||||
} else {
|
||||
unclassifiedTotal += item.lineTotal
|
||||
}
|
||||
}
|
||||
|
||||
const total = businessTotal + privateTotal + unclassifiedTotal
|
||||
const businessPercentage = total > 0 ? (businessTotal / total) * 100 : 0
|
||||
|
||||
return {
|
||||
businessTotal: Math.round(businessTotal * 100) / 100,
|
||||
privateTotal: Math.round(privateTotal * 100) / 100,
|
||||
unclassifiedTotal: Math.round(unclassifiedTotal * 100) / 100,
|
||||
businessPercentage: Math.round(businessPercentage * 10) / 10,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate line items by category for summary display
|
||||
*/
|
||||
export function aggregateByCategory(
|
||||
lineItems: Array<{ lineTotal: number; category: TransactionCategory | null; is_business: boolean | null }>
|
||||
): Array<{ category: TransactionCategory | null; total: number; count: number }> {
|
||||
const aggregation = new Map<TransactionCategory | null, { total: number; count: number }>()
|
||||
|
||||
for (const item of lineItems) {
|
||||
// Only aggregate business items
|
||||
if (item.is_business !== true) continue
|
||||
|
||||
const existing = aggregation.get(item.category) || { total: 0, count: 0 }
|
||||
aggregation.set(item.category, {
|
||||
total: existing.total + item.lineTotal,
|
||||
count: existing.count + 1,
|
||||
})
|
||||
}
|
||||
|
||||
return Array.from(aggregation.entries())
|
||||
.map(([category, data]) => ({
|
||||
category,
|
||||
total: Math.round(data.total * 100) / 100,
|
||||
count: data.count,
|
||||
}))
|
||||
.sort((a, b) => b.total - a.total)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate default line item classifications based on receipt flags
|
||||
*/
|
||||
export function getDefaultClassification(
|
||||
isRestaurant: boolean,
|
||||
isSystembolaget: boolean
|
||||
): {
|
||||
defaultIsBusiness: boolean | null
|
||||
requiresReview: boolean
|
||||
warningMessage: string | null
|
||||
} {
|
||||
if (isSystembolaget) {
|
||||
return {
|
||||
defaultIsBusiness: false, // Default to private for Systembolaget
|
||||
requiresReview: true,
|
||||
warningMessage: 'Alkohol från Systembolaget är normalt en privat utgift. Om det är representation, ange syfte och antal personer.',
|
||||
}
|
||||
}
|
||||
|
||||
if (isRestaurant) {
|
||||
return {
|
||||
defaultIsBusiness: null, // Need user input
|
||||
requiresReview: true,
|
||||
warningMessage: 'Restaurangbesök kräver uppgift om antal personer och syfte för att räknas som representation.',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
defaultIsBusiness: null, // User decides per item
|
||||
requiresReview: false,
|
||||
warningMessage: null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Category labels in Swedish
|
||||
*/
|
||||
export const CATEGORY_LABELS: Record<TransactionCategory, string> = {
|
||||
expense_equipment: 'Utrustning',
|
||||
expense_software: 'Programvara',
|
||||
expense_travel: 'Resa',
|
||||
expense_office: 'Kontor',
|
||||
expense_marketing: 'Marknadsföring',
|
||||
expense_professional_services: 'Konsulttjänster',
|
||||
expense_education: 'Utbildning',
|
||||
expense_bank_fees: 'Bankavgift',
|
||||
expense_card_fees: 'Kortavgift',
|
||||
expense_currency_exchange: 'Valutaväxling',
|
||||
expense_other: 'Övrigt',
|
||||
income_services: 'Tjänsteintäkt',
|
||||
income_products: 'Varuintäkt',
|
||||
income_other: 'Övrig intäkt',
|
||||
private: 'Privat',
|
||||
uncategorized: 'Ej kategoriserad',
|
||||
}
|
||||
|
||||
/**
|
||||
* Get expense categories only (for dropdown)
|
||||
*/
|
||||
export function getExpenseCategories(): Array<{ value: TransactionCategory; label: string }> {
|
||||
return Object.entries(CATEGORY_LABELS)
|
||||
.filter(([key]) => key.startsWith('expense_'))
|
||||
.map(([value, label]) => ({
|
||||
value: value as TransactionCategory,
|
||||
label,
|
||||
}))
|
||||
}
|
||||
@@ -1,288 +0,0 @@
|
||||
/**
|
||||
* Receipt Matcher - Fuzzy matching between receipts and transactions
|
||||
*
|
||||
* Uses date variance, amount tolerance, and merchant name similarity
|
||||
* to find potential transaction matches for receipts.
|
||||
*/
|
||||
|
||||
import type { Transaction, Receipt, ReceiptMatchCandidate } from '@/types'
|
||||
|
||||
// Matching configuration
|
||||
const DATE_TOLERANCE_DAYS = 3 // Allow ±3 days variance
|
||||
const AMOUNT_TOLERANCE_PERCENT = 0.05 // Allow ±5% variance (for currency conversion)
|
||||
const MIN_MATCH_CONFIDENCE = 0.4 // Minimum confidence to consider a match
|
||||
|
||||
/**
|
||||
* Find potential transaction matches for a receipt
|
||||
*/
|
||||
export function findTransactionMatches(
|
||||
receipt: Receipt,
|
||||
transactions: Transaction[]
|
||||
): ReceiptMatchCandidate[] {
|
||||
if (!receipt.receipt_date || !receipt.total_amount) {
|
||||
return []
|
||||
}
|
||||
|
||||
const receiptDate = new Date(receipt.receipt_date)
|
||||
const receiptAmount = Math.abs(receipt.total_amount)
|
||||
const merchantName = (receipt.merchant_name || '').toLowerCase()
|
||||
|
||||
const candidates: ReceiptMatchCandidate[] = []
|
||||
|
||||
for (const transaction of transactions) {
|
||||
// Skip already matched transactions
|
||||
if (transaction.receipt_id) continue
|
||||
|
||||
// Only match expense transactions (negative amounts)
|
||||
if (transaction.amount >= 0) continue
|
||||
|
||||
const transactionDate = new Date(transaction.date)
|
||||
const transactionAmount = Math.abs(transaction.amount)
|
||||
|
||||
// Calculate date variance
|
||||
const dateVariance = Math.abs(
|
||||
(receiptDate.getTime() - transactionDate.getTime()) / (1000 * 60 * 60 * 24)
|
||||
)
|
||||
|
||||
// Skip if outside date tolerance
|
||||
if (dateVariance > DATE_TOLERANCE_DAYS) continue
|
||||
|
||||
// Calculate amount variance
|
||||
const amountVariance = Math.abs(receiptAmount - transactionAmount) / receiptAmount
|
||||
|
||||
// Skip if outside amount tolerance (unless it's a foreign receipt with currency conversion)
|
||||
if (amountVariance > AMOUNT_TOLERANCE_PERCENT && !receipt.is_foreign_merchant) continue
|
||||
if (amountVariance > 0.15) continue // Hard limit even for foreign
|
||||
|
||||
// Calculate merchant similarity
|
||||
const transactionMerchant = (transaction.merchant_name || transaction.description || '').toLowerCase()
|
||||
const merchantSimilarity = calculateMerchantSimilarity(merchantName, transactionMerchant)
|
||||
|
||||
// Calculate overall confidence
|
||||
const { confidence, matchReasons } = calculateMatchConfidence(
|
||||
dateVariance,
|
||||
amountVariance,
|
||||
merchantSimilarity,
|
||||
receipt,
|
||||
transaction
|
||||
)
|
||||
|
||||
if (confidence >= MIN_MATCH_CONFIDENCE) {
|
||||
candidates.push({
|
||||
transaction,
|
||||
confidence,
|
||||
matchReasons,
|
||||
dateVariance,
|
||||
amountVariance,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by confidence (highest first)
|
||||
return candidates.sort((a, b) => b.confidence - a.confidence)
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate merchant name similarity using Levenshtein distance
|
||||
*/
|
||||
function calculateMerchantSimilarity(name1: string, name2: string): number {
|
||||
if (!name1 || !name2) return 0
|
||||
|
||||
// Normalize names
|
||||
const n1 = normalizeMerchantName(name1)
|
||||
const n2 = normalizeMerchantName(name2)
|
||||
|
||||
// Check for exact match
|
||||
if (n1 === n2) return 1
|
||||
|
||||
// Check if one contains the other
|
||||
if (n1.includes(n2) || n2.includes(n1)) return 0.9
|
||||
|
||||
// Check for word overlap
|
||||
const words1 = n1.split(/\s+/)
|
||||
const words2 = n2.split(/\s+/)
|
||||
const commonWords = words1.filter((w) => words2.includes(w))
|
||||
|
||||
if (commonWords.length > 0) {
|
||||
const overlapScore = commonWords.length / Math.max(words1.length, words2.length)
|
||||
if (overlapScore >= 0.5) return 0.7 + overlapScore * 0.2
|
||||
}
|
||||
|
||||
// Calculate Levenshtein similarity
|
||||
const distance = levenshteinDistance(n1, n2)
|
||||
const maxLength = Math.max(n1.length, n2.length)
|
||||
const similarity = 1 - distance / maxLength
|
||||
|
||||
return similarity
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize merchant name for comparison
|
||||
*/
|
||||
function normalizeMerchantName(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\såäöé]/g, '') // Remove special chars except Swedish letters
|
||||
.replace(/\b(ab|hb|kb|ek|för|stiftelse)\b/g, '') // Remove company suffixes
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate Levenshtein distance between two strings
|
||||
*/
|
||||
function levenshteinDistance(str1: string, str2: string): number {
|
||||
const m = str1.length
|
||||
const n = str2.length
|
||||
|
||||
// Create matrix
|
||||
const dp: number[][] = Array(m + 1)
|
||||
.fill(null)
|
||||
.map(() => Array(n + 1).fill(0))
|
||||
|
||||
// Initialize first row and column
|
||||
for (let i = 0; i <= m; i++) dp[i][0] = i
|
||||
for (let j = 0; j <= n; j++) dp[0][j] = j
|
||||
|
||||
// Fill the matrix
|
||||
for (let i = 1; i <= m; i++) {
|
||||
for (let j = 1; j <= n; j++) {
|
||||
const cost = str1[i - 1] === str2[j - 1] ? 0 : 1
|
||||
dp[i][j] = Math.min(
|
||||
dp[i - 1][j] + 1, // deletion
|
||||
dp[i][j - 1] + 1, // insertion
|
||||
dp[i - 1][j - 1] + cost // substitution
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return dp[m][n]
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate overall match confidence and reasons
|
||||
*/
|
||||
function calculateMatchConfidence(
|
||||
dateVariance: number,
|
||||
amountVariance: number,
|
||||
merchantSimilarity: number,
|
||||
receipt: Receipt,
|
||||
transaction: Transaction
|
||||
): { confidence: number; matchReasons: string[] } {
|
||||
const matchReasons: string[] = []
|
||||
let totalWeight = 0
|
||||
let weightedScore = 0
|
||||
|
||||
// Date score (weight: 25%)
|
||||
const dateScore = Math.max(0, 1 - dateVariance / DATE_TOLERANCE_DAYS)
|
||||
if (dateScore >= 0.8) {
|
||||
matchReasons.push(dateVariance === 0 ? 'Exakt datum' : `Datum ±${Math.round(dateVariance)} dagar`)
|
||||
}
|
||||
weightedScore += dateScore * 0.25
|
||||
totalWeight += 0.25
|
||||
|
||||
// Amount score (weight: 40%)
|
||||
const amountScore = Math.max(0, 1 - amountVariance / AMOUNT_TOLERANCE_PERCENT)
|
||||
if (amountVariance < 0.01) {
|
||||
matchReasons.push('Exakt belopp')
|
||||
} else if (amountVariance < AMOUNT_TOLERANCE_PERCENT) {
|
||||
matchReasons.push(`Belopp ±${Math.round(amountVariance * 100)}%`)
|
||||
}
|
||||
weightedScore += amountScore * 0.4
|
||||
totalWeight += 0.4
|
||||
|
||||
// Merchant score (weight: 35%)
|
||||
if (merchantSimilarity > 0) {
|
||||
if (merchantSimilarity >= 0.9) {
|
||||
matchReasons.push('Handlare matchar')
|
||||
} else if (merchantSimilarity >= 0.6) {
|
||||
matchReasons.push('Trolig handlarmatch')
|
||||
}
|
||||
weightedScore += merchantSimilarity * 0.35
|
||||
totalWeight += 0.35
|
||||
}
|
||||
|
||||
// Bonus for MCC match (if transaction has MCC and receipt is flagged)
|
||||
if (receipt.is_restaurant && transaction.mcc_code) {
|
||||
const restaurantMCCs = [5812, 5813, 5814]
|
||||
if (restaurantMCCs.includes(transaction.mcc_code)) {
|
||||
matchReasons.push('Restaurang MCC matchar')
|
||||
weightedScore += 0.1
|
||||
}
|
||||
}
|
||||
|
||||
const confidence = totalWeight > 0 ? weightedScore / totalWeight : 0
|
||||
|
||||
return {
|
||||
confidence: Math.round(confidence * 100) / 100,
|
||||
matchReasons,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unmatched transactions for a user
|
||||
* (transactions without linked receipts)
|
||||
*/
|
||||
export function filterUnmatchedTransactions(transactions: Transaction[]): Transaction[] {
|
||||
return transactions.filter((t) => !t.receipt_id && t.amount < 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unmatched receipts for a user
|
||||
* (confirmed receipts without linked transactions)
|
||||
*/
|
||||
export function filterUnmatchedReceipts(receipts: Receipt[]): Receipt[] {
|
||||
return receipts.filter((r) => !r.matched_transaction_id && r.status === 'confirmed')
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-match receipts to transactions
|
||||
* Returns matches with confidence >= threshold
|
||||
*/
|
||||
export function autoMatchReceipts(
|
||||
receipts: Receipt[],
|
||||
transactions: Transaction[],
|
||||
confidenceThreshold: number = 0.8
|
||||
): Array<{ receipt: Receipt; match: ReceiptMatchCandidate }> {
|
||||
const results: Array<{ receipt: Receipt; match: ReceiptMatchCandidate }> = []
|
||||
|
||||
for (const receipt of receipts) {
|
||||
if (receipt.matched_transaction_id) continue
|
||||
|
||||
const matches = findTransactionMatches(receipt, transactions)
|
||||
const bestMatch = matches[0]
|
||||
|
||||
if (bestMatch && bestMatch.confidence >= confidenceThreshold) {
|
||||
results.push({ receipt, match: bestMatch })
|
||||
// Mark the transaction as used so it's not matched again
|
||||
// (Note: this mutates the transaction, caller should be aware)
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate match statistics for queue display
|
||||
*/
|
||||
export function calculateMatchStats(
|
||||
receipts: Receipt[],
|
||||
transactions: Transaction[]
|
||||
): {
|
||||
unmatchedReceiptsCount: number
|
||||
unmatchedTransactionsCount: number
|
||||
pendingReviewCount: number
|
||||
matchedCount: number
|
||||
} {
|
||||
const unmatchedReceipts = filterUnmatchedReceipts(receipts)
|
||||
const unmatchedTransactions = filterUnmatchedTransactions(transactions)
|
||||
const pendingReview = receipts.filter((r) => r.status === 'extracted')
|
||||
const matched = receipts.filter((r) => r.matched_transaction_id)
|
||||
|
||||
return {
|
||||
unmatchedReceiptsCount: unmatchedReceipts.length,
|
||||
unmatchedTransactionsCount: unmatchedTransactions.length,
|
||||
pendingReviewCount: pendingReview.length,
|
||||
matchedCount: matched.length,
|
||||
}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
/**
|
||||
* Receipt utility functions (client-safe)
|
||||
*
|
||||
* These functions can be used in both client and server components.
|
||||
*/
|
||||
|
||||
// Known Swedish merchants for special handling
|
||||
export const SYSTEMBOLAGET_PATTERNS = [
|
||||
'systembolaget',
|
||||
'systemet',
|
||||
'bolaget',
|
||||
]
|
||||
|
||||
export const RESTAURANT_MCC_CODES = [5812, 5813, 5814]
|
||||
|
||||
export const RESTAURANT_PATTERNS = [
|
||||
'restaurang',
|
||||
'restaurant',
|
||||
'café',
|
||||
'cafe',
|
||||
'bistro',
|
||||
'pizzeria',
|
||||
'sushi',
|
||||
'thai',
|
||||
'wok',
|
||||
'grill',
|
||||
'bar',
|
||||
'pub',
|
||||
'krog',
|
||||
'brasserie',
|
||||
'trattoria',
|
||||
'osteria',
|
||||
'matsal',
|
||||
'lunch',
|
||||
'middag',
|
||||
]
|
||||
|
||||
/**
|
||||
* Check if a transaction MCC code indicates restaurant
|
||||
*/
|
||||
export function isRestaurantMCC(mccCode: number | null): boolean {
|
||||
if (!mccCode) return false
|
||||
return RESTAURANT_MCC_CODES.includes(mccCode)
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate restaurant representation limits
|
||||
*
|
||||
* Swedish rules (2024):
|
||||
* - Max 60 kr/person för intern representation (lunch/middag)
|
||||
* - Max 180 kr/person för extern representation
|
||||
* - Max 180 kr/person för representationsgåvor
|
||||
*
|
||||
* For simplicity, we use 60 kr as the safe deductible limit per person
|
||||
*/
|
||||
export function calculateRepresentationLimits(
|
||||
totalAmount: number,
|
||||
persons: number
|
||||
): {
|
||||
deductibleAmount: number
|
||||
nonDeductibleAmount: number
|
||||
perPersonAmount: number
|
||||
maxDeductiblePerPerson: number
|
||||
} {
|
||||
const MAX_DEDUCTIBLE_PER_PERSON = 60
|
||||
|
||||
const perPersonAmount = totalAmount / persons
|
||||
const deductiblePerPerson = Math.min(perPersonAmount, MAX_DEDUCTIBLE_PER_PERSON)
|
||||
const deductibleAmount = deductiblePerPerson * persons
|
||||
const nonDeductibleAmount = totalAmount - deductibleAmount
|
||||
|
||||
return {
|
||||
deductibleAmount: Math.round(deductibleAmount * 100) / 100,
|
||||
nonDeductibleAmount: Math.round(nonDeductibleAmount * 100) / 100,
|
||||
perPersonAmount: Math.round(perPersonAmount * 100) / 100,
|
||||
maxDeductiblePerPerson: MAX_DEDUCTIBLE_PER_PERSON,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if merchant is Systembolaget
|
||||
*/
|
||||
export function detectSystembolaget(merchantName: string): boolean {
|
||||
const name = merchantName.toLowerCase()
|
||||
return SYSTEMBOLAGET_PATTERNS.some((pattern) => name.includes(pattern))
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if merchant is a restaurant
|
||||
*/
|
||||
export function detectRestaurant(merchantName: string): boolean {
|
||||
const name = merchantName.toLowerCase()
|
||||
return RESTAURANT_PATTERNS.some((pattern) => name.includes(pattern))
|
||||
}
|
||||
@@ -1,337 +0,0 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import type {
|
||||
NEDeclaration,
|
||||
NEDeclarationRutor,
|
||||
NEAccountMapping,
|
||||
FiscalPeriod,
|
||||
JournalEntry,
|
||||
JournalEntryLine,
|
||||
} from '@/types'
|
||||
|
||||
/**
|
||||
* NE-bilaga (Enskild Firma / Sole Proprietorship Declaration)
|
||||
*
|
||||
* Maps BAS account balances to NE declaration rutor (R1-R11) for
|
||||
* tax reporting to Skatteverket.
|
||||
*
|
||||
* Account mappings:
|
||||
* R1: Försäljning med moms (3000-3499 excl 3100)
|
||||
* R2: Momsfria intäkter (3100, 3900, 3970, 3980) - inkl gåvor utan motprestation
|
||||
* R3: Bil/bostadsförmån (3200)
|
||||
* R4: Ränteintäkter (8310-8330)
|
||||
* R5: Varuinköp (4000-4990)
|
||||
* R6: Övriga kostnader (5000-6990, 7970) - inkl avdragsgilla gåvor (5460)
|
||||
* R7: Lönekostnader (7000-7699)
|
||||
* R8: Räntekostnader (8400-8499)
|
||||
* R9: Avskrivningar fastighet (7820)
|
||||
* R10: Avskrivningar övrigt (7700-7899 excl 7820)
|
||||
* R11: Årets resultat (calculated)
|
||||
*
|
||||
* Gift handling:
|
||||
* - Gåvor MED motprestation: R1 (momspliktig bytestransaktion)
|
||||
* - Gåvor UTAN motprestation: R2 via konto 3900
|
||||
* - Avdragsgilla gåvor: R6 via konto 5460
|
||||
*/
|
||||
|
||||
/**
|
||||
* Account mapping configuration for NE declaration
|
||||
*/
|
||||
export const NE_ACCOUNT_MAPPINGS: NEAccountMapping[] = [
|
||||
{
|
||||
ruta: 'R1',
|
||||
description: 'Försäljning med moms (25%)',
|
||||
accountRanges: [
|
||||
{ start: '3000', end: '3499', exclude: ['3100'] },
|
||||
],
|
||||
isExpense: false,
|
||||
},
|
||||
{
|
||||
ruta: 'R2',
|
||||
description: 'Momsfria intäkter',
|
||||
accountRanges: [
|
||||
{ start: '3100', end: '3100' },
|
||||
{ start: '3900', end: '3900' }, // Övriga rörelseintäkter (inkl gåvor utan motprestation)
|
||||
{ start: '3970', end: '3980' },
|
||||
],
|
||||
isExpense: false,
|
||||
},
|
||||
{
|
||||
ruta: 'R3',
|
||||
description: 'Bil/bostadsförmån',
|
||||
accountRanges: [
|
||||
{ start: '3200', end: '3299' },
|
||||
],
|
||||
isExpense: false,
|
||||
},
|
||||
{
|
||||
ruta: 'R4',
|
||||
description: 'Ränteintäkter',
|
||||
accountRanges: [
|
||||
{ start: '8310', end: '8330' },
|
||||
],
|
||||
isExpense: false,
|
||||
},
|
||||
{
|
||||
ruta: 'R5',
|
||||
description: 'Varuinköp',
|
||||
accountRanges: [
|
||||
{ start: '4000', end: '4990' },
|
||||
],
|
||||
isExpense: true,
|
||||
},
|
||||
{
|
||||
ruta: 'R6',
|
||||
description: 'Övriga kostnader',
|
||||
accountRanges: [
|
||||
{ start: '5000', end: '6990' },
|
||||
{ start: '7970', end: '7970' },
|
||||
],
|
||||
isExpense: true,
|
||||
},
|
||||
{
|
||||
ruta: 'R7',
|
||||
description: 'Lönekostnader',
|
||||
accountRanges: [
|
||||
{ start: '7000', end: '7699' },
|
||||
],
|
||||
isExpense: true,
|
||||
},
|
||||
{
|
||||
ruta: 'R8',
|
||||
description: 'Räntekostnader',
|
||||
accountRanges: [
|
||||
{ start: '8400', end: '8499' },
|
||||
],
|
||||
isExpense: true,
|
||||
},
|
||||
{
|
||||
ruta: 'R9',
|
||||
description: 'Avskrivningar fastighet',
|
||||
accountRanges: [
|
||||
{ start: '7820', end: '7820' },
|
||||
],
|
||||
isExpense: true,
|
||||
},
|
||||
{
|
||||
ruta: 'R10',
|
||||
description: 'Avskrivningar övrigt',
|
||||
accountRanges: [
|
||||
{ start: '7700', end: '7899', exclude: ['7820'] },
|
||||
],
|
||||
isExpense: true,
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* Check if an account number falls within a mapping's ranges
|
||||
*/
|
||||
function isAccountInMapping(accountNumber: string, mapping: NEAccountMapping): boolean {
|
||||
for (const range of mapping.accountRanges) {
|
||||
const num = accountNumber
|
||||
if (num >= range.start && num <= range.end) {
|
||||
// Check exclusions
|
||||
if (range.exclude && range.exclude.includes(num)) {
|
||||
continue
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Round to nearest krona (whole number) for NE declaration
|
||||
*/
|
||||
function roundToKrona(value: number): number {
|
||||
return Math.round(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate NE declaration for a fiscal period
|
||||
*/
|
||||
export async function generateNEDeclaration(
|
||||
userId: string,
|
||||
fiscalPeriodId: string
|
||||
): Promise<NEDeclaration> {
|
||||
const supabase = await createClient()
|
||||
|
||||
// Fetch fiscal period
|
||||
const { data: period, error: periodError } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('*')
|
||||
.eq('id', fiscalPeriodId)
|
||||
.eq('user_id', userId)
|
||||
.single()
|
||||
|
||||
if (periodError || !period) {
|
||||
throw new Error('Fiscal period not found')
|
||||
}
|
||||
|
||||
// Fetch company settings
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('company_name, org_number, entity_type')
|
||||
.eq('user_id', userId)
|
||||
.single()
|
||||
|
||||
// Check if it's enskild firma
|
||||
if (settings?.entity_type !== 'enskild_firma') {
|
||||
throw new Error('NE declaration is only for enskild firma (sole proprietorship)')
|
||||
}
|
||||
|
||||
// Fetch all posted journal entries with lines for this period
|
||||
const { data: entries, error: entriesError } = await supabase
|
||||
.from('journal_entries')
|
||||
.select('*, lines:journal_entry_lines(*)')
|
||||
.eq('user_id', userId)
|
||||
.eq('fiscal_period_id', fiscalPeriodId)
|
||||
.eq('status', 'posted')
|
||||
|
||||
if (entriesError) {
|
||||
throw new Error(`Failed to fetch journal entries: ${entriesError.message}`)
|
||||
}
|
||||
|
||||
// Fetch chart of accounts for account names
|
||||
const { data: accounts } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
.select('account_number, account_name')
|
||||
.eq('user_id', userId)
|
||||
|
||||
const accountNameMap = new Map<string, string>()
|
||||
for (const acc of accounts || []) {
|
||||
accountNameMap.set(acc.account_number, acc.account_name)
|
||||
}
|
||||
|
||||
// Calculate balances per account
|
||||
const accountBalances = new Map<string, number>()
|
||||
|
||||
for (const entry of (entries as JournalEntry[]) || []) {
|
||||
const lines = (entry.lines as JournalEntryLine[]) || []
|
||||
for (const line of lines) {
|
||||
const current = accountBalances.get(line.account_number) || 0
|
||||
// Net amount: debit - credit
|
||||
const netAmount = (Number(line.debit_amount) || 0) - (Number(line.credit_amount) || 0)
|
||||
accountBalances.set(line.account_number, current + netAmount)
|
||||
}
|
||||
}
|
||||
|
||||
// Map account balances to NE rutor
|
||||
const rutor: NEDeclarationRutor = {
|
||||
R1: 0,
|
||||
R2: 0,
|
||||
R3: 0,
|
||||
R4: 0,
|
||||
R5: 0,
|
||||
R6: 0,
|
||||
R7: 0,
|
||||
R8: 0,
|
||||
R9: 0,
|
||||
R10: 0,
|
||||
R11: 0,
|
||||
}
|
||||
|
||||
const breakdown: Record<keyof NEDeclarationRutor, {
|
||||
accounts: Array<{ accountNumber: string; accountName: string; amount: number }>
|
||||
total: number
|
||||
}> = {
|
||||
R1: { accounts: [], total: 0 },
|
||||
R2: { accounts: [], total: 0 },
|
||||
R3: { accounts: [], total: 0 },
|
||||
R4: { accounts: [], total: 0 },
|
||||
R5: { accounts: [], total: 0 },
|
||||
R6: { accounts: [], total: 0 },
|
||||
R7: { accounts: [], total: 0 },
|
||||
R8: { accounts: [], total: 0 },
|
||||
R9: { accounts: [], total: 0 },
|
||||
R10: { accounts: [], total: 0 },
|
||||
R11: { accounts: [], total: 0 },
|
||||
}
|
||||
|
||||
const warnings: string[] = []
|
||||
|
||||
// Process each account balance
|
||||
for (const [accountNumber, balance] of accountBalances) {
|
||||
// Skip zero balances
|
||||
if (Math.abs(balance) < 0.01) continue
|
||||
|
||||
// Find which ruta this account belongs to
|
||||
for (const mapping of NE_ACCOUNT_MAPPINGS) {
|
||||
if (isAccountInMapping(accountNumber, mapping)) {
|
||||
// For revenue accounts (credit normal), negate the balance
|
||||
// For expense accounts (debit normal), use as-is
|
||||
// Net balance is debit - credit, so:
|
||||
// - Revenue accounts have negative net balance (credit > debit)
|
||||
// - Expense accounts have positive net balance (debit > credit)
|
||||
const amount = mapping.isExpense ? balance : -balance
|
||||
|
||||
rutor[mapping.ruta] += amount
|
||||
|
||||
breakdown[mapping.ruta].accounts.push({
|
||||
accountNumber,
|
||||
accountName: accountNameMap.get(accountNumber) || `Konto ${accountNumber}`,
|
||||
amount: roundToKrona(amount),
|
||||
})
|
||||
|
||||
break // Account matched, no need to check other mappings
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Round all rutor to whole numbers
|
||||
for (const key of Object.keys(rutor) as (keyof NEDeclarationRutor)[]) {
|
||||
if (key !== 'R11') {
|
||||
rutor[key] = roundToKrona(rutor[key])
|
||||
breakdown[key].total = rutor[key]
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate R11 (Årets resultat)
|
||||
// Result = Revenue (R1+R2+R3+R4) - Expenses (R5+R6+R7+R8+R9+R10)
|
||||
const totalRevenue = rutor.R1 + rutor.R2 + rutor.R3 + rutor.R4
|
||||
const totalExpenses = rutor.R5 + rutor.R6 + rutor.R7 + rutor.R8 + rutor.R9 + rutor.R10
|
||||
rutor.R11 = totalRevenue - totalExpenses
|
||||
breakdown.R11.total = rutor.R11
|
||||
|
||||
// Add warnings
|
||||
if (!(period as FiscalPeriod).is_closed) {
|
||||
warnings.push('Räkenskapsåret är inte stängt. Siffrorna kan ändras.')
|
||||
}
|
||||
|
||||
if (rutor.R11 === 0 && totalRevenue === 0) {
|
||||
warnings.push('Inga bokförda intäkter eller kostnader hittades för perioden.')
|
||||
}
|
||||
|
||||
return {
|
||||
fiscalYear: {
|
||||
id: period.id,
|
||||
name: period.name,
|
||||
start: period.period_start,
|
||||
end: period.period_end,
|
||||
isClosed: period.is_closed,
|
||||
},
|
||||
rutor,
|
||||
breakdown,
|
||||
companyInfo: {
|
||||
companyName: settings?.company_name || 'Okänt företag',
|
||||
orgNumber: settings?.org_number || null,
|
||||
},
|
||||
warnings,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get totals for display
|
||||
*/
|
||||
export function getNEDeclarationTotals(declaration: NEDeclaration): {
|
||||
totalRevenue: number
|
||||
totalExpenses: number
|
||||
netResult: number
|
||||
} {
|
||||
const { rutor } = declaration
|
||||
|
||||
return {
|
||||
totalRevenue: rutor.R1 + rutor.R2 + rutor.R3 + rutor.R4,
|
||||
totalExpenses: rutor.R5 + rutor.R6 + rutor.R7 + rutor.R8 + rutor.R9 + rutor.R10,
|
||||
netResult: rutor.R11,
|
||||
}
|
||||
}
|
||||
@@ -1,209 +0,0 @@
|
||||
import type { NEDeclaration, SRUFile, SRURecord } from '@/types'
|
||||
|
||||
/**
|
||||
* SRU File Generator
|
||||
*
|
||||
* Generates SRU (Standardiserat Räkenskapsutdrag) files for electronic
|
||||
* submission to Skatteverket. The SRU format is used for tax declarations.
|
||||
*
|
||||
* Format specification:
|
||||
* - Each line starts with # followed by field code
|
||||
* - Values follow the field code
|
||||
* - File must be plain text (ISO-8859-1 encoding traditionally, but UTF-8 is often accepted)
|
||||
*
|
||||
* NE-bilaga field codes:
|
||||
* - #BLANKETT NE - Declares this is an NE form
|
||||
* - #IDENTITET - Organization number + name
|
||||
* - #UPPGIFT - Individual field values
|
||||
*/
|
||||
|
||||
/**
|
||||
* SRU field codes for NE declaration
|
||||
* These are the official Skatteverket field codes for NE-bilaga
|
||||
*/
|
||||
const NE_SRU_FIELD_CODES: Record<string, string> = {
|
||||
// Company identification
|
||||
ORG_NUMBER: '201',
|
||||
COMPANY_NAME: '202',
|
||||
|
||||
// NE rutor - Income
|
||||
R1: '7310', // Försäljning och andra intäkter med moms
|
||||
R2: '7311', // Momsfria intäkter (ej skattepliktiga)
|
||||
R3: '7312', // Bil- och bostadsförmån m.m.
|
||||
R4: '7313', // Ränteintäkter
|
||||
|
||||
// NE rutor - Expenses
|
||||
R5: '7320', // Varuinköp
|
||||
R6: '7321', // Övriga externa kostnader
|
||||
R7: '7322', // Anställdas löner
|
||||
R8: '7323', // Räntekostnader
|
||||
R9: '7324', // Avskrivningar på byggnader och markanläggningar
|
||||
R10: '7325', // Avskrivningar på maskiner och inventarier
|
||||
|
||||
// Result
|
||||
R11: '7350', // Årets resultat
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate SRU file content from NE declaration
|
||||
*/
|
||||
export function generateSRUFile(declaration: NEDeclaration): SRUFile {
|
||||
const records: SRURecord[] = []
|
||||
const now = new Date()
|
||||
|
||||
// File header
|
||||
records.push({ fieldCode: 'PRODUKT', value: 'KONTROLLUPPGIFTER' })
|
||||
records.push({ fieldCode: 'SESSION', value: '1' })
|
||||
records.push({ fieldCode: 'PROGRAMNAMN', value: 'ERPBase' })
|
||||
records.push({ fieldCode: 'PROGRAMVERSION', value: '1.0' })
|
||||
records.push({
|
||||
fieldCode: 'SKAPAT',
|
||||
value: formatSRUDate(now),
|
||||
})
|
||||
|
||||
// Form declaration
|
||||
records.push({ fieldCode: 'BLANKETT', value: 'NE' })
|
||||
|
||||
// Company identification
|
||||
if (declaration.companyInfo.orgNumber) {
|
||||
// Remove any dashes from org number
|
||||
const cleanOrgNumber = declaration.companyInfo.orgNumber.replace(/-/g, '')
|
||||
records.push({
|
||||
fieldCode: 'IDENTITET',
|
||||
value: cleanOrgNumber,
|
||||
})
|
||||
}
|
||||
|
||||
// Fiscal year
|
||||
records.push({
|
||||
fieldCode: 'UPPGIFT',
|
||||
value: `7000 ${formatSRUDateRange(declaration.fiscalYear.start, declaration.fiscalYear.end)}`,
|
||||
})
|
||||
|
||||
// NE rutor values
|
||||
const rutaEntries: [keyof typeof NE_SRU_FIELD_CODES, number][] = [
|
||||
['R1', declaration.rutor.R1],
|
||||
['R2', declaration.rutor.R2],
|
||||
['R3', declaration.rutor.R3],
|
||||
['R4', declaration.rutor.R4],
|
||||
['R5', declaration.rutor.R5],
|
||||
['R6', declaration.rutor.R6],
|
||||
['R7', declaration.rutor.R7],
|
||||
['R8', declaration.rutor.R8],
|
||||
['R9', declaration.rutor.R9],
|
||||
['R10', declaration.rutor.R10],
|
||||
['R11', declaration.rutor.R11],
|
||||
]
|
||||
|
||||
for (const [ruta, value] of rutaEntries) {
|
||||
// Only include non-zero values
|
||||
if (value !== 0) {
|
||||
const fieldCode = NE_SRU_FIELD_CODES[ruta]
|
||||
records.push({
|
||||
fieldCode: 'UPPGIFT',
|
||||
value: `${fieldCode} ${formatSRUAmount(value)}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// End of form
|
||||
records.push({ fieldCode: 'BLANKETTSLUT', value: '' })
|
||||
|
||||
return {
|
||||
records,
|
||||
generatedAt: now.toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert SRU file to string content
|
||||
*/
|
||||
export function sruFileToString(sruFile: SRUFile): string {
|
||||
const lines: string[] = []
|
||||
|
||||
for (const record of sruFile.records) {
|
||||
if (record.value === '') {
|
||||
lines.push(`#${record.fieldCode}`)
|
||||
} else {
|
||||
lines.push(`#${record.fieldCode} ${record.value}`)
|
||||
}
|
||||
}
|
||||
|
||||
// SRU files should end with a newline
|
||||
return lines.join('\r\n') + '\r\n'
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date for SRU: YYYYMMDD
|
||||
*/
|
||||
function formatSRUDate(date: Date): string {
|
||||
const y = date.getFullYear()
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const d = String(date.getDate()).padStart(2, '0')
|
||||
return `${y}${m}${d}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date string (YYYY-MM-DD) to SRU format (YYYYMMDD)
|
||||
*/
|
||||
function dateStringToSRU(dateStr: string): string {
|
||||
return dateStr.replace(/-/g, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Format fiscal year date range for SRU
|
||||
*/
|
||||
function formatSRUDateRange(startDate: string, endDate: string): string {
|
||||
return `${dateStringToSRU(startDate)}-${dateStringToSRU(endDate)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Format amount for SRU
|
||||
* - Whole numbers (no decimals)
|
||||
* - No thousands separator
|
||||
* - Negative values with minus sign
|
||||
*/
|
||||
function formatSRUAmount(amount: number): string {
|
||||
return Math.round(amount).toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate SRU file content
|
||||
*/
|
||||
export function validateSRUFile(sruFile: SRUFile): {
|
||||
isValid: boolean
|
||||
errors: string[]
|
||||
} {
|
||||
const errors: string[] = []
|
||||
|
||||
// Check for required records
|
||||
const hasHeader = sruFile.records.some(r => r.fieldCode === 'PRODUKT')
|
||||
const hasBlankett = sruFile.records.some(r => r.fieldCode === 'BLANKETT')
|
||||
const hasBlankettslut = sruFile.records.some(r => r.fieldCode === 'BLANKETTSLUT')
|
||||
|
||||
if (!hasHeader) {
|
||||
errors.push('Missing PRODUKT header')
|
||||
}
|
||||
|
||||
if (!hasBlankett) {
|
||||
errors.push('Missing BLANKETT declaration')
|
||||
}
|
||||
|
||||
if (!hasBlankettslut) {
|
||||
errors.push('Missing BLANKETTSLUT')
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get filename for SRU file download
|
||||
*/
|
||||
export function getSRUFilename(declaration: NEDeclaration): string {
|
||||
const year = declaration.fiscalYear.start.substring(0, 4)
|
||||
const orgNumber = declaration.companyInfo.orgNumber?.replace(/-/g, '') || 'unknown'
|
||||
return `NE_${orgNumber}_${year}.sru`
|
||||
}
|
||||
+6
-12
@@ -1,4 +1,4 @@
|
||||
import type { EntityType, TaxEstimate, TaxWarningLevel, TaxWarningStatus, SchablonavdragSummary } from '@/types'
|
||||
import type { EntityType, TaxEstimate, TaxWarningLevel, TaxWarningStatus } from '@/types'
|
||||
|
||||
// Current Swedish tax rates (2026)
|
||||
const TAX_RATES = {
|
||||
@@ -71,20 +71,16 @@ export function calculateGrundavdrag(taxableIncome: number): number {
|
||||
* Calculate tax estimates for Enskild Firma
|
||||
* @param netIncome - Net income after expenses
|
||||
* @param preliminaryTaxPaidYTD - Preliminary tax paid year to date
|
||||
* @param schablonavdrag - Optional schablonavdrag summary for deductions
|
||||
* @param _reserved - Reserved for future use (deductions extension)
|
||||
* @param momsFromUnpaidInvoices - VAT from unpaid invoices that needs to be paid
|
||||
*/
|
||||
export function calculateEFTax(
|
||||
netIncome: number,
|
||||
preliminaryTaxPaidYTD: number = 0,
|
||||
schablonavdrag?: SchablonavdragSummary | null,
|
||||
_reserved?: unknown,
|
||||
momsFromUnpaidInvoices: number = 0
|
||||
): TaxEstimate {
|
||||
// Apply schablonavdrag deductions to net income
|
||||
const schablonavdragDeduction = schablonavdrag?.total_deduction || 0
|
||||
const adjustedNetIncome = netIncome - schablonavdragDeduction
|
||||
|
||||
if (adjustedNetIncome <= 0) {
|
||||
if (netIncome <= 0) {
|
||||
return {
|
||||
egenavgifter: 0,
|
||||
income_tax: 0,
|
||||
@@ -93,17 +89,16 @@ export function calculateEFTax(
|
||||
total_tax_liability: momsFromUnpaidInvoices,
|
||||
preliminary_paid_ytd: preliminaryTaxPaidYTD,
|
||||
difference: momsFromUnpaidInvoices - preliminaryTaxPaidYTD,
|
||||
schablonavdrag_deduction: schablonavdragDeduction,
|
||||
}
|
||||
}
|
||||
|
||||
// Egenavgifter (self-employment contributions) - 28.97%
|
||||
const egenavgifter = adjustedNetIncome * TAX_RATES.egenavgifter
|
||||
const egenavgifter = netIncome * TAX_RATES.egenavgifter
|
||||
|
||||
// Taxable income (after egenavgifter deduction)
|
||||
// 25% of egenavgifter is deductible from taxable income
|
||||
const egenavgifterDeduction = egenavgifter * 0.25
|
||||
const taxableIncome = adjustedNetIncome - egenavgifterDeduction
|
||||
const taxableIncome = netIncome - egenavgifterDeduction
|
||||
|
||||
// Calculate progressive grundavdrag based on income level
|
||||
const grundavdrag = calculateGrundavdrag(taxableIncome)
|
||||
@@ -128,7 +123,6 @@ export function calculateEFTax(
|
||||
total_tax_liability: Math.round(totalTax),
|
||||
preliminary_paid_ytd: preliminaryTaxPaidYTD,
|
||||
difference: Math.round(totalTax - preliminaryTaxPaidYTD),
|
||||
schablonavdrag_deduction: schablonavdragDeduction,
|
||||
grundavdrag: Math.round(grundavdrag),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,199 +0,0 @@
|
||||
/**
|
||||
* Schablonavdrag (Standard Deductions) Calculator
|
||||
* Swedish tax deductions that don't require detailed receipts
|
||||
*/
|
||||
|
||||
import type { SchablonavdragSettings, SchablonavdragSummary, MileageEntry } from '@/types'
|
||||
|
||||
// Housing types for hemmakontor deduction
|
||||
export type HousingType = 'villa' | 'apartment' // villa = privatbostadsfastighet, apartment = hyresrätt/bostadsrätt
|
||||
|
||||
// Current Swedish schablonavdrag rates (2026)
|
||||
export const SCHABLONAVDRAG_RATES = {
|
||||
hemmakontor: {
|
||||
// Different rates based on housing type
|
||||
villa: 2000, // 2,000 kr/year for villa/privatbostadsfastighet
|
||||
apartment: 4000, // 4,000 kr/year for hyresrätt/bostadsrätt
|
||||
min_hours_per_year: 800, // Minimum 800 hours working from home required
|
||||
description: 'Schablonavdrag för hemmakontor',
|
||||
},
|
||||
bil: {
|
||||
rate_per_mil: 25, // 25 kr per Swedish mile (10 km)
|
||||
rate_per_km: 2.5, // 2.50 kr per kilometer
|
||||
description: 'Schablonavdrag för bilkostnader (milersättning)',
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate hemmakontor (home office) deduction
|
||||
* Rate depends on housing type:
|
||||
* - Villa/privatbostadsfastighet: 2,000 kr/year
|
||||
* - Hyresrätt/Bostadsrätt: 4,000 kr/year
|
||||
* Prorated by months active
|
||||
*/
|
||||
export function calculateHemmakontorDeduction(
|
||||
monthsActive: number = 12,
|
||||
housingType: HousingType = 'apartment'
|
||||
): number {
|
||||
const validMonths = Math.min(Math.max(monthsActive, 0), 12)
|
||||
const annualAmount = housingType === 'villa'
|
||||
? SCHABLONAVDRAG_RATES.hemmakontor.villa
|
||||
: SCHABLONAVDRAG_RATES.hemmakontor.apartment
|
||||
return Math.round((annualAmount / 12) * validMonths)
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate mileage deduction from total kilometers
|
||||
* Rate: 25 kr/mil = 2.50 kr/km
|
||||
*/
|
||||
export function calculateMileageDeduction(totalKm: number): number {
|
||||
if (totalKm <= 0) return 0
|
||||
// 2.50 kr per kilometer (25 kr per Swedish mile)
|
||||
return Math.round(totalKm * SCHABLONAVDRAG_RATES.bil.rate_per_km * 100) / 100
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate mileage deduction from entries
|
||||
*/
|
||||
export function calculateMileageDeductionFromEntries(entries: MileageEntry[]): number {
|
||||
const totalKm = entries.reduce((sum, entry) => sum + Number(entry.distance_km), 0)
|
||||
return calculateMileageDeduction(totalKm)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get complete schablonavdrag summary for a user and year
|
||||
*/
|
||||
export function getSchablonavdragSummary(
|
||||
settings: SchablonavdragSettings | null,
|
||||
mileageEntries: MileageEntry[],
|
||||
year: number = new Date().getFullYear(),
|
||||
monthsInBusiness: number = 12
|
||||
): SchablonavdragSummary {
|
||||
// Default settings if none provided
|
||||
const schablonavdragSettings: SchablonavdragSettings = settings || {
|
||||
hemmakontor_enabled: false,
|
||||
hemmakontor_housing_type: 'apartment',
|
||||
bil_enabled: false,
|
||||
}
|
||||
|
||||
// Get housing type (default to apartment for higher deduction)
|
||||
const housingType: HousingType = schablonavdragSettings.hemmakontor_housing_type || 'apartment'
|
||||
|
||||
// Calculate hemmakontor deduction based on housing type
|
||||
const hemmakontorDeduction = schablonavdragSettings.hemmakontor_enabled
|
||||
? calculateHemmakontorDeduction(monthsInBusiness, housingType)
|
||||
: 0
|
||||
|
||||
// Get the annual amount for this housing type
|
||||
const hemmakontorAnnualAmount = housingType === 'villa'
|
||||
? SCHABLONAVDRAG_RATES.hemmakontor.villa
|
||||
: SCHABLONAVDRAG_RATES.hemmakontor.apartment
|
||||
|
||||
// Filter entries for the specified year
|
||||
const yearEntries = mileageEntries.filter((entry) => {
|
||||
const entryYear = new Date(entry.date).getFullYear()
|
||||
return entryYear === year
|
||||
})
|
||||
|
||||
// Calculate mileage totals
|
||||
const totalKm = yearEntries.reduce((sum, entry) => sum + Number(entry.distance_km), 0)
|
||||
const mileageDeduction = schablonavdragSettings.bil_enabled
|
||||
? calculateMileageDeduction(totalKm)
|
||||
: 0
|
||||
|
||||
return {
|
||||
year,
|
||||
hemmakontor: {
|
||||
enabled: schablonavdragSettings.hemmakontor_enabled,
|
||||
housing_type: housingType,
|
||||
months_active: monthsInBusiness,
|
||||
annual_amount: hemmakontorAnnualAmount,
|
||||
deduction: hemmakontorDeduction,
|
||||
},
|
||||
mileage: {
|
||||
enabled: schablonavdragSettings.bil_enabled,
|
||||
total_km: totalKm,
|
||||
rate_per_km: SCHABLONAVDRAG_RATES.bil.rate_per_km,
|
||||
total_deduction: mileageDeduction,
|
||||
entries_count: yearEntries.length,
|
||||
},
|
||||
total_deduction: hemmakontorDeduction + mileageDeduction,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a mileage entry
|
||||
*/
|
||||
export function validateMileageEntry(entry: {
|
||||
date: string
|
||||
distance_km: number
|
||||
purpose: string
|
||||
}): { valid: boolean; errors: string[] } {
|
||||
const errors: string[] = []
|
||||
|
||||
// Check date
|
||||
if (!entry.date) {
|
||||
errors.push('Datum krävs')
|
||||
} else {
|
||||
const entryDate = new Date(entry.date)
|
||||
if (isNaN(entryDate.getTime())) {
|
||||
errors.push('Ogiltigt datum')
|
||||
} else if (entryDate > new Date()) {
|
||||
errors.push('Datum kan inte vara i framtiden')
|
||||
}
|
||||
}
|
||||
|
||||
// Check distance
|
||||
if (!entry.distance_km || entry.distance_km <= 0) {
|
||||
errors.push('Avståndet måste vara större än 0 km')
|
||||
} else if (entry.distance_km > 10000) {
|
||||
errors.push('Orimligt avstånd (max 10 000 km per resa)')
|
||||
}
|
||||
|
||||
// Check purpose
|
||||
if (!entry.purpose || entry.purpose.trim().length < 3) {
|
||||
errors.push('Ändamål krävs (minst 3 tecken)')
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get mileage entries grouped by month
|
||||
*/
|
||||
export function groupMileageEntriesByMonth(
|
||||
entries: MileageEntry[]
|
||||
): Map<string, { entries: MileageEntry[]; totalKm: number; totalDeduction: number }> {
|
||||
const grouped = new Map<string, { entries: MileageEntry[]; totalKm: number; totalDeduction: number }>()
|
||||
|
||||
for (const entry of entries) {
|
||||
const date = new Date(entry.date)
|
||||
const key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`
|
||||
|
||||
if (!grouped.has(key)) {
|
||||
grouped.set(key, { entries: [], totalKm: 0, totalDeduction: 0 })
|
||||
}
|
||||
|
||||
const group = grouped.get(key)!
|
||||
group.entries.push(entry)
|
||||
group.totalKm += Number(entry.distance_km)
|
||||
group.totalDeduction += Number(entry.total_deduction)
|
||||
}
|
||||
|
||||
return grouped
|
||||
}
|
||||
|
||||
/**
|
||||
* Format month key for display (Swedish)
|
||||
*/
|
||||
export function formatMonthKey(key: string): string {
|
||||
const [year, month] = key.split('-')
|
||||
const monthNames = [
|
||||
'Januari', 'Februari', 'Mars', 'April', 'Maj', 'Juni',
|
||||
'Juli', 'Augusti', 'September', 'Oktober', 'November', 'December'
|
||||
]
|
||||
return `${monthNames[parseInt(month) - 1]} ${year}`
|
||||
}
|
||||
Reference in New Issue
Block a user