feat: remove AI extensions, restructure settings, and add atomic voucher commits (#157)
Remove AI-dependent extensions (ai-chat, ai-categorization, receipt-ocr, invoice-inbox) and their infrastructure (lib/ai/*, ai-consent, LangChain/ Anthropic/OpenAI deps) to simplify core and reduce bundle size. Restructure monolithic settings page into dedicated sub-pages (company, bookkeeping, invoicing, tax, banking, api, account, team, templates) with shared layout and sidebar navigation. Add atomic commit_journal_entry RPC so voucher number increment and status update happen in a single transaction — prevents burned numbers on constraint failures. Add continuity check report and voucher gap explanation tracking. Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
e89f2c402d
commit
d0b3f21bde
@@ -1,83 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef, useCallback, KeyboardEvent } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Send, Loader2 } from 'lucide-react'
|
||||
|
||||
interface ChatInputProps {
|
||||
onSend: (message: string) => void
|
||||
disabled?: boolean
|
||||
isLoading?: boolean
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
export function ChatInput({
|
||||
onSend,
|
||||
disabled = false,
|
||||
isLoading = false,
|
||||
placeholder = 'Skriv ett meddelande...',
|
||||
}: ChatInputProps) {
|
||||
const [message, setMessage] = useState('')
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
const handleSend = useCallback(() => {
|
||||
const trimmed = message.trim()
|
||||
if (trimmed && !disabled && !isLoading) {
|
||||
onSend(trimmed)
|
||||
setMessage('')
|
||||
// Reset textarea height
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto'
|
||||
}
|
||||
}
|
||||
}, [message, disabled, isLoading, onSend])
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
// Send on Enter (without Shift)
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSend()
|
||||
}
|
||||
},
|
||||
[handleSend]
|
||||
)
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setMessage(e.target.value)
|
||||
// Auto-resize textarea
|
||||
const textarea = e.target
|
||||
textarea.style.height = 'auto'
|
||||
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`
|
||||
}
|
||||
|
||||
const isDisabled = disabled || isLoading || !message.trim()
|
||||
|
||||
return (
|
||||
<div className="flex gap-2 items-end p-4 border-t bg-background">
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={message}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled || isLoading}
|
||||
className="min-h-[44px] max-h-[200px] resize-none py-3"
|
||||
rows={1}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSend}
|
||||
disabled={isDisabled}
|
||||
size="icon"
|
||||
className="flex-shrink-0 h-[44px] w-[44px]"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ChevronDown, ChevronUp, FileText, User, Bot, Database, Loader2 } from 'lucide-react'
|
||||
import type { ChatMessage as ChatMessageType, SourceReference } from '@/types/chat'
|
||||
import { ArtifactRenderer } from './artifacts/ArtifactRenderer'
|
||||
|
||||
interface ChatMessageProps {
|
||||
message: ChatMessageType
|
||||
isStreaming?: boolean
|
||||
toolsExecuting?: string[]
|
||||
}
|
||||
|
||||
function SourcesList({ sources }: { sources: SourceReference[] }) {
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
|
||||
if (sources.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="mt-3 pt-3 border-t border-border/40">
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<FileText className="h-3 w-3" />
|
||||
<span>{sources.length} källa{sources.length !== 1 ? 'or' : ''}</span>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
) : (
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="mt-2 space-y-1.5">
|
||||
{sources.map((source, index) => (
|
||||
<div
|
||||
key={source.id || index}
|
||||
className="text-xs bg-muted/50 rounded-md px-2.5 py-1.5"
|
||||
>
|
||||
<div className="font-medium text-foreground/80">
|
||||
{source.title}
|
||||
{source.section_title && (
|
||||
<span className="font-normal text-muted-foreground">
|
||||
{' '}
|
||||
› {source.section_title}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-muted-foreground mt-0.5">
|
||||
{source.source_file} ({(source.similarity * 100).toFixed(0)}% relevans)
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const TOOL_LABELS: Record<string, string> = {
|
||||
get_invoices: 'Hämtar fakturor',
|
||||
get_supplier_invoices: 'Hämtar leverantörsfakturor',
|
||||
get_account_balances: 'Hämtar kontosaldon',
|
||||
get_transactions: 'Hämtar transaktioner',
|
||||
get_journal_entries: 'Hämtar verifikationer',
|
||||
get_income_statement: 'Genererar resultaträkning',
|
||||
get_balance_sheet: 'Genererar balansräkning',
|
||||
get_vat_summary: 'Beräknar momssammanställning',
|
||||
get_company_overview: 'Hämtar företagsöversikt',
|
||||
get_aging_report: 'Genererar åldersanalys',
|
||||
}
|
||||
|
||||
function ToolExecutingIndicator({ tools }: { tools: string[] }) {
|
||||
if (tools.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1 mb-2">
|
||||
{tools.map((toolName, i) => (
|
||||
<div key={i} className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
<Database className="h-3 w-3" />
|
||||
<span>{TOOL_LABELS[toolName] || toolName}...</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ChatMessage({ message, isStreaming, toolsExecuting }: ChatMessageProps) {
|
||||
const isUser = message.role === 'user'
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex gap-3 px-4 py-3',
|
||||
isUser ? 'flex-row-reverse' : 'flex-row'
|
||||
)}
|
||||
>
|
||||
{/* Avatar */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center',
|
||||
isUser
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{isUser ? (
|
||||
<User className="h-4 w-4" />
|
||||
) : (
|
||||
<Bot className="h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Message content */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex-1 max-w-[80%] rounded-xl px-4 py-3',
|
||||
isUser
|
||||
? 'bg-primary text-primary-foreground ml-auto'
|
||||
: 'bg-muted/60 text-foreground'
|
||||
)}
|
||||
>
|
||||
{/* Tool execution indicator */}
|
||||
{!isUser && isStreaming && toolsExecuting && toolsExecuting.length > 0 && !message.content && (
|
||||
<ToolExecutingIndicator tools={toolsExecuting} />
|
||||
)}
|
||||
|
||||
<div className={cn(
|
||||
"text-sm break-words",
|
||||
!isUser && "prose prose-sm max-w-none prose-headings:text-foreground prose-headings:font-semibold prose-h1:text-base prose-h2:text-sm prose-h3:text-sm prose-p:text-foreground prose-p:my-1.5 prose-headings:my-2 prose-ul:my-1.5 prose-ol:my-1.5 prose-li:my-0.5 prose-li:text-foreground prose-strong:text-foreground prose-table:my-2 prose-table:text-xs prose-th:px-2 prose-th:py-1 prose-td:px-2 prose-td:py-1 prose-th:bg-muted/50 prose-th:border prose-td:border prose-th:border-border/50 prose-td:border-border/50 prose-th:text-foreground prose-td:text-foreground"
|
||||
)}>
|
||||
{isUser ? (
|
||||
<p className="whitespace-pre-wrap m-0">{message.content}</p>
|
||||
) : (
|
||||
<ReactMarkdown>{message.content}</ReactMarkdown>
|
||||
)}
|
||||
{isStreaming && !isUser && (
|
||||
<span className="inline-block w-1.5 h-4 bg-current animate-pulse ml-0.5" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Artifact visualization */}
|
||||
{!isUser && message.artifact && (
|
||||
<ArtifactRenderer artifact={message.artifact} />
|
||||
)}
|
||||
|
||||
{!isUser && message.sources && message.sources.length > 0 && (
|
||||
<SourcesList sources={message.sources} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { ChatMessage } from './ChatMessage'
|
||||
import { ChatInput } from './ChatInput'
|
||||
import { useChatStream } from './useChatStream'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { Bot, RotateCcw } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
interface ChatPanelProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function ChatPanel({ className }: ChatPanelProps) {
|
||||
const { toast } = useToast()
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const {
|
||||
messages,
|
||||
isLoading,
|
||||
isStreaming,
|
||||
error: _error,
|
||||
toolsExecuting,
|
||||
sendMessage,
|
||||
clearChat,
|
||||
} = useChatStream({
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: error,
|
||||
variant: 'destructive',
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
// Auto-scroll to bottom when new messages arrive
|
||||
useEffect(() => {
|
||||
if (messagesEndRef.current) {
|
||||
messagesEndRef.current.scrollIntoView({ behavior: 'smooth' })
|
||||
}
|
||||
}, [messages])
|
||||
|
||||
const handleClearChat = () => {
|
||||
clearChat()
|
||||
toast({
|
||||
title: 'Chatten rensad',
|
||||
description: 'En ny konversation har startats.',
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col h-full ${className || ''}`}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b bg-muted/30">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<Bot className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-sm">AI-assistent</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Skatt, moms och bokföring
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{messages.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleClearChat}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4 mr-1" />
|
||||
Ny chatt
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{messages.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center px-6 py-8">
|
||||
<div className="w-12 h-12 rounded-full bg-primary/10 flex items-center justify-center mb-4">
|
||||
<Bot className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<h4 className="font-medium mb-2">Hur kan jag hjälpa dig?</h4>
|
||||
<p className="text-sm text-muted-foreground max-w-xs">
|
||||
Jag kan svara på frågor om skatt, moms, bokföring och andra
|
||||
ekonomiska frågor för företagare.
|
||||
</p>
|
||||
<div className="mt-6 space-y-2 w-full max-w-xs">
|
||||
<SuggestionButton
|
||||
onClick={() => sendMessage('Hur går det för mitt företag?')}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Hur går det för mitt företag?
|
||||
</SuggestionButton>
|
||||
<SuggestionButton
|
||||
onClick={() => sendMessage('Visa mina senaste fakturor')}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Visa mina senaste fakturor
|
||||
</SuggestionButton>
|
||||
<SuggestionButton
|
||||
onClick={() => sendMessage('Hur ser min resultaträkning ut?')}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Hur ser min resultaträkning ut?
|
||||
</SuggestionButton>
|
||||
<SuggestionButton
|
||||
onClick={() => sendMessage('Vad kan jag dra av som företagare?')}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Vad kan jag dra av som företagare?
|
||||
</SuggestionButton>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-4">
|
||||
{messages.map((message, index) => (
|
||||
<ChatMessage
|
||||
key={message.id}
|
||||
message={message}
|
||||
isStreaming={
|
||||
isStreaming &&
|
||||
index === messages.length - 1 &&
|
||||
message.role === 'assistant'
|
||||
}
|
||||
toolsExecuting={
|
||||
isStreaming &&
|
||||
index === messages.length - 1 &&
|
||||
message.role === 'assistant'
|
||||
? toolsExecuting
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<ChatInput
|
||||
onSend={sendMessage}
|
||||
disabled={isStreaming}
|
||||
isLoading={isLoading}
|
||||
placeholder="Ställ en fråga om skatt, moms eller bokföring..."
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SuggestionButton({
|
||||
children,
|
||||
onClick,
|
||||
disabled,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
onClick: () => void
|
||||
disabled?: boolean
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className="w-full text-left text-sm px-3 py-2 rounded-lg border border-border/60 hover:bg-muted/50 hover:border-border transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ChatPanel } from './ChatPanel'
|
||||
import { MessageCircle, X } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
|
||||
|
||||
const chatEnabled = ENABLED_EXTENSION_IDS.has('ai-chat')
|
||||
|
||||
export function ChatWidget() {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
// Allow other components to open the chat via custom event
|
||||
useEffect(() => {
|
||||
const handler = () => setIsOpen(true)
|
||||
window.addEventListener('open-ai-chat', handler)
|
||||
return () => window.removeEventListener('open-ai-chat', handler)
|
||||
}, [])
|
||||
|
||||
if (!chatEnabled) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Floating chat panel */}
|
||||
<div
|
||||
className={cn(
|
||||
'fixed bottom-36 right-4 md:bottom-20 z-[60] transition-all duration-300 ease-in-out',
|
||||
isOpen
|
||||
? 'opacity-100 translate-y-0 pointer-events-auto'
|
||||
: 'opacity-0 translate-y-4 pointer-events-none'
|
||||
)}
|
||||
>
|
||||
<div className="w-[380px] h-[550px] max-h-[calc(100vh-120px)] bg-background border rounded-xl shadow-xl overflow-hidden flex flex-col">
|
||||
{/* Close button in corner */}
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="absolute top-2 right-2 z-10 p-1.5 rounded-full bg-muted/80 hover:bg-muted transition-colors"
|
||||
aria-label="Stäng chatt"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<ChatPanel />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Floating action button */}
|
||||
<Button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
size="icon"
|
||||
className={cn(
|
||||
'fixed bottom-20 right-4 md:bottom-4 z-[60] h-14 w-14 rounded-full shadow-lg transition-all duration-300',
|
||||
isOpen && 'rotate-90'
|
||||
)}
|
||||
aria-label={isOpen ? 'Stäng chatt' : 'Öppna chatt'}
|
||||
>
|
||||
{isOpen ? (
|
||||
<X className="h-6 w-6" />
|
||||
) : (
|
||||
<MessageCircle className="h-6 w-6" />
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { ArtifactSpec } from '@/types/chat'
|
||||
import { ChatChart } from './ChatChart'
|
||||
import { ChatDataTable } from './ChatDataTable'
|
||||
import { ChatKpiCards } from './ChatKpiCards'
|
||||
import { ChatAgingBuckets } from './ChatAgingBuckets'
|
||||
|
||||
interface ArtifactRendererProps {
|
||||
artifact: ArtifactSpec
|
||||
}
|
||||
|
||||
export function ArtifactRenderer({ artifact }: ArtifactRendererProps) {
|
||||
switch (artifact.type) {
|
||||
case 'bar_chart':
|
||||
case 'line_chart':
|
||||
case 'pie_chart':
|
||||
case 'stacked_bar':
|
||||
return (
|
||||
<div className="mt-3 p-3 rounded-lg border border-border bg-card">
|
||||
<ChatChart artifact={artifact} />
|
||||
</div>
|
||||
)
|
||||
case 'table':
|
||||
return (
|
||||
<div className="mt-3">
|
||||
<ChatDataTable artifact={artifact} />
|
||||
</div>
|
||||
)
|
||||
case 'kpi_cards':
|
||||
return (
|
||||
<div className="mt-3">
|
||||
<ChatKpiCards artifact={artifact} />
|
||||
</div>
|
||||
)
|
||||
case 'aging_buckets':
|
||||
return (
|
||||
<div className="mt-3 p-3 rounded-lg border border-border bg-card">
|
||||
<ChatAgingBuckets artifact={artifact} />
|
||||
</div>
|
||||
)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { AgingBucketsArtifact } from '@/types/chat'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
|
||||
const BUCKET_COLORS = [
|
||||
'bg-success',
|
||||
'bg-warning',
|
||||
'bg-warning/70',
|
||||
'bg-destructive/70',
|
||||
'bg-destructive',
|
||||
]
|
||||
|
||||
interface ChatAgingBucketsProps {
|
||||
artifact: AgingBucketsArtifact
|
||||
}
|
||||
|
||||
export function ChatAgingBuckets({ artifact }: ChatAgingBucketsProps) {
|
||||
const { title, buckets, total } = artifact
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="flex items-baseline justify-between mb-3">
|
||||
<h4 className="text-sm font-semibold">{title}</h4>
|
||||
<span className="text-sm font-bold tabular-nums">
|
||||
{formatCurrency(total)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Stacked bar */}
|
||||
{total > 0 && (
|
||||
<div className="flex h-6 rounded-md overflow-hidden mb-3">
|
||||
{buckets.map((bucket, i) => {
|
||||
const widthPercent = (bucket.amount / total) * 100
|
||||
if (widthPercent < 0.5) return null
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`${BUCKET_COLORS[i % BUCKET_COLORS.length]} transition-all`}
|
||||
style={{ width: `${widthPercent}%` }}
|
||||
title={`${bucket.label}: ${formatCurrency(bucket.amount)}`}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Legend */}
|
||||
<div className="space-y-1.5">
|
||||
{buckets.map((bucket, i) => (
|
||||
<div key={i} className="flex items-center justify-between text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`w-3 h-3 rounded-sm ${BUCKET_COLORS[i % BUCKET_COLORS.length]}`}
|
||||
/>
|
||||
<span className="text-muted-foreground">{bucket.label}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-muted-foreground">
|
||||
{bucket.count} st
|
||||
</span>
|
||||
<span className="font-medium tabular-nums">
|
||||
{formatCurrency(bucket.amount)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
LineChart,
|
||||
Line,
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts'
|
||||
import type { ChartArtifact } from '@/types/chat'
|
||||
|
||||
const DEFAULT_COLORS = [
|
||||
'var(--color-chart-1, #3b82f6)',
|
||||
'var(--color-chart-2, #10b981)',
|
||||
'var(--color-chart-3, #f59e0b)',
|
||||
'var(--color-chart-4, #ef4444)',
|
||||
'var(--color-chart-5, #8b5cf6)',
|
||||
'var(--color-chart-6, #ec4899)',
|
||||
'var(--color-chart-7, #06b6d4)',
|
||||
'var(--color-chart-8, #84cc16)',
|
||||
]
|
||||
|
||||
function formatValue(value: number, unit?: string): string {
|
||||
const formatted = new Intl.NumberFormat('sv-SE').format(Math.round(value))
|
||||
return unit ? `${formatted} ${unit}` : formatted
|
||||
}
|
||||
|
||||
interface ChatChartProps {
|
||||
artifact: ChartArtifact
|
||||
}
|
||||
|
||||
export function ChatChart({ artifact }: ChatChartProps) {
|
||||
const { type, title, data, unit, subtitle } = artifact
|
||||
|
||||
const chartData = data.map((d, i) => ({
|
||||
...d,
|
||||
fill: d.color || DEFAULT_COLORS[i % DEFAULT_COLORS.length],
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="mb-3">
|
||||
<h4 className="text-sm font-semibold">{title}</h4>
|
||||
{subtitle && <p className="text-xs text-muted-foreground">{subtitle}</p>}
|
||||
</div>
|
||||
<div className="h-64 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
{type === 'pie_chart' ? (
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={chartData}
|
||||
dataKey="value"
|
||||
nameKey="label"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
outerRadius="80%"
|
||||
label={(props) => {
|
||||
const name = props.name ?? ''
|
||||
const percent = typeof props.percent === 'number' ? props.percent : 0
|
||||
return `${name} (${(percent * 100).toFixed(0)}%)`
|
||||
}}
|
||||
labelLine={false}
|
||||
>
|
||||
{chartData.map((entry, i) => (
|
||||
<Cell key={i} fill={entry.fill} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
formatter={(value) => formatValue(Number(value ?? 0), unit)}
|
||||
/>
|
||||
</PieChart>
|
||||
) : type === 'line_chart' ? (
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="opacity-30" />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fontSize: 11 }}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 11 }}
|
||||
tickFormatter={(v) => formatValue(v, unit)}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value) => formatValue(Number(value ?? 0), unit)}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke={DEFAULT_COLORS[0]}
|
||||
strokeWidth={2}
|
||||
dot={{ r: 3 }}
|
||||
/>
|
||||
</LineChart>
|
||||
) : (
|
||||
<BarChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="opacity-30" />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fontSize: 11 }}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 11 }}
|
||||
tickFormatter={(v) => formatValue(v, unit)}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value) => formatValue(Number(value ?? 0), unit)}
|
||||
/>
|
||||
<Bar dataKey="value" radius={[4, 4, 0, 0]}>
|
||||
{chartData.map((entry, i) => (
|
||||
<Cell key={i} fill={entry.fill} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
)}
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { TableArtifact } from '@/types/chat'
|
||||
|
||||
interface ChatDataTableProps {
|
||||
artifact: TableArtifact
|
||||
}
|
||||
|
||||
function formatCell(value: string | number, _align?: 'left' | 'right'): string {
|
||||
if (typeof value === 'number') {
|
||||
return new Intl.NumberFormat('sv-SE', {
|
||||
minimumFractionDigits: value % 1 !== 0 ? 2 : 0,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(value)
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
|
||||
export function ChatDataTable({ artifact }: ChatDataTableProps) {
|
||||
const { title, columns, rows, summary_row } = artifact
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<h4 className="text-sm font-semibold mb-2">{title}</h4>
|
||||
<div className="overflow-x-auto rounded-lg border border-border">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="bg-muted/50">
|
||||
{columns.map((col) => (
|
||||
<th
|
||||
key={col.key}
|
||||
className={`px-3 py-2 font-medium text-muted-foreground border-b border-border ${
|
||||
col.align === 'right' ? 'text-right' : 'text-left'
|
||||
}`}
|
||||
>
|
||||
{col.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, i) => (
|
||||
<tr
|
||||
key={i}
|
||||
className="border-b border-border/50 hover:bg-muted/20 transition-colors"
|
||||
>
|
||||
{columns.map((col) => (
|
||||
<td
|
||||
key={col.key}
|
||||
className={`px-3 py-1.5 ${
|
||||
col.align === 'right' ? 'text-right tabular-nums' : 'text-left'
|
||||
}`}
|
||||
>
|
||||
{formatCell(row[col.key], col.align)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
{summary_row && (
|
||||
<tr className="bg-muted/30 font-semibold">
|
||||
{columns.map((col) => (
|
||||
<td
|
||||
key={col.key}
|
||||
className={`px-3 py-2 border-t border-border ${
|
||||
col.align === 'right' ? 'text-right tabular-nums' : 'text-left'
|
||||
}`}
|
||||
>
|
||||
{summary_row[col.key] !== undefined
|
||||
? formatCell(summary_row[col.key], col.align)
|
||||
: ''}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { TrendingUp, TrendingDown, Minus } from 'lucide-react'
|
||||
import type { KpiCardsArtifact } from '@/types/chat'
|
||||
|
||||
interface ChatKpiCardsProps {
|
||||
artifact: KpiCardsArtifact
|
||||
}
|
||||
|
||||
export function ChatKpiCards({ artifact }: ChatKpiCardsProps) {
|
||||
const { title, cards } = artifact
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{title && <h4 className="text-sm font-semibold mb-2">{title}</h4>}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{cards.map((card, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="rounded-lg border border-border bg-card p-3"
|
||||
>
|
||||
<p className="text-xs text-muted-foreground mb-1">{card.label}</p>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-lg font-bold tabular-nums">{card.value}</span>
|
||||
{card.trend && (
|
||||
<span
|
||||
className={`flex items-center gap-0.5 text-xs ${
|
||||
card.trend === 'up'
|
||||
? 'text-success'
|
||||
: card.trend === 'down'
|
||||
? 'text-destructive'
|
||||
: 'text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{card.trend === 'up' && <TrendingUp className="h-3 w-3" />}
|
||||
{card.trend === 'down' && <TrendingDown className="h-3 w-3" />}
|
||||
{card.trend === 'flat' && <Minus className="h-3 w-3" />}
|
||||
{card.change}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export { ChatWidget } from './ChatWidget'
|
||||
export { ChatPanel } from './ChatPanel'
|
||||
export { ChatMessage } from './ChatMessage'
|
||||
export { ChatInput } from './ChatInput'
|
||||
export { useChatStream } from './useChatStream'
|
||||
@@ -1,233 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback, useRef } from 'react'
|
||||
import type { ChatMessage, SourceReference, ArtifactSpec } from '@/types/chat'
|
||||
|
||||
interface UseChatStreamOptions {
|
||||
onError?: (error: string) => void
|
||||
}
|
||||
|
||||
interface UseChatStreamReturn {
|
||||
messages: ChatMessage[]
|
||||
isLoading: boolean
|
||||
isStreaming: boolean
|
||||
sessionId: string | null
|
||||
error: string | null
|
||||
toolsExecuting: string[]
|
||||
sendMessage: (message: string) => Promise<void>
|
||||
loadSession: (sessionId: string) => Promise<void>
|
||||
clearChat: () => void
|
||||
setSessionId: (id: string | null) => void
|
||||
}
|
||||
|
||||
export function useChatStream(options: UseChatStreamOptions = {}): UseChatStreamReturn {
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isStreaming, setIsStreaming] = useState(false)
|
||||
const [sessionId, setSessionId] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [toolsExecuting, setToolsExecuting] = useState<string[]>([])
|
||||
const abortControllerRef = useRef<AbortController | null>(null)
|
||||
|
||||
const sendMessage = useCallback(async (message: string) => {
|
||||
if (!message.trim() || isLoading || isStreaming) return
|
||||
|
||||
setError(null)
|
||||
setIsLoading(true)
|
||||
|
||||
// Add user message immediately
|
||||
const userMessage: ChatMessage = {
|
||||
id: `temp-${Date.now()}`,
|
||||
session_id: sessionId || '',
|
||||
user_id: '',
|
||||
role: 'user',
|
||||
content: message.trim(),
|
||||
sources: [],
|
||||
created_at: new Date().toISOString(),
|
||||
}
|
||||
setMessages((prev) => [...prev, userMessage])
|
||||
|
||||
// Add placeholder for assistant response
|
||||
const assistantPlaceholder: ChatMessage = {
|
||||
id: `temp-assistant-${Date.now()}`,
|
||||
session_id: sessionId || '',
|
||||
user_id: '',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
sources: [],
|
||||
created_at: new Date().toISOString(),
|
||||
}
|
||||
setMessages((prev) => [...prev, assistantPlaceholder])
|
||||
|
||||
try {
|
||||
abortControllerRef.current = new AbortController()
|
||||
|
||||
const response = await fetch('/api/extensions/ext/ai-chat/stream', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
message: message.trim(),
|
||||
session_id: sessionId,
|
||||
}),
|
||||
signal: abortControllerRef.current.signal,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || 'Failed to send message')
|
||||
}
|
||||
|
||||
setIsLoading(false)
|
||||
setIsStreaming(true)
|
||||
|
||||
const reader = response.body?.getReader()
|
||||
if (!reader) throw new Error('No response body')
|
||||
|
||||
const decoder = new TextDecoder()
|
||||
let accumulatedContent = ''
|
||||
let sources: SourceReference[] = []
|
||||
let artifact: ArtifactSpec | null = null
|
||||
let newSessionId = sessionId
|
||||
let messageId: string | null = null
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
const chunk = decoder.decode(value)
|
||||
const lines = chunk.split('\n')
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
try {
|
||||
const data = JSON.parse(line.slice(6))
|
||||
|
||||
if (data.type === 'session') {
|
||||
newSessionId = data.session_id
|
||||
setSessionId(data.session_id)
|
||||
} else if (data.type === 'content') {
|
||||
accumulatedContent += data.content
|
||||
setToolsExecuting([]) // Clear tool indicators when content starts
|
||||
setMessages((prev) => {
|
||||
const newMessages = [...prev]
|
||||
const lastMsg = newMessages[newMessages.length - 1]
|
||||
if (lastMsg.role === 'assistant') {
|
||||
lastMsg.content = accumulatedContent
|
||||
}
|
||||
return newMessages
|
||||
})
|
||||
} else if (data.type === 'sources') {
|
||||
sources = data.sources
|
||||
setMessages((prev) => {
|
||||
const newMessages = [...prev]
|
||||
const lastMsg = newMessages[newMessages.length - 1]
|
||||
if (lastMsg.role === 'assistant') {
|
||||
lastMsg.sources = sources
|
||||
}
|
||||
return newMessages
|
||||
})
|
||||
} else if (data.type === 'tool_start') {
|
||||
setToolsExecuting((prev) => [...prev, data.toolName])
|
||||
} else if (data.type === 'artifact') {
|
||||
artifact = data.artifact
|
||||
setMessages((prev) => {
|
||||
const newMessages = [...prev]
|
||||
const lastMsg = newMessages[newMessages.length - 1]
|
||||
if (lastMsg.role === 'assistant') {
|
||||
lastMsg.artifact = artifact
|
||||
}
|
||||
return newMessages
|
||||
})
|
||||
} else if (data.type === 'done') {
|
||||
messageId = data.message_id
|
||||
setToolsExecuting([])
|
||||
// Update the message IDs with real values
|
||||
setMessages((prev) => {
|
||||
const newMessages = [...prev]
|
||||
const lastMsg = newMessages[newMessages.length - 1]
|
||||
if (lastMsg.role === 'assistant' && messageId) {
|
||||
lastMsg.id = messageId
|
||||
lastMsg.session_id = newSessionId || ''
|
||||
}
|
||||
return newMessages
|
||||
})
|
||||
} else if (data.type === 'error') {
|
||||
throw new Error(data.error)
|
||||
}
|
||||
} catch (parseError) {
|
||||
// Skip invalid JSON lines
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name === 'AbortError') {
|
||||
// User cancelled, remove the placeholder
|
||||
setMessages((prev) => prev.slice(0, -1))
|
||||
} else {
|
||||
const errorMessage = err instanceof Error ? err.message : 'An error occurred'
|
||||
setError(errorMessage)
|
||||
options.onError?.(errorMessage)
|
||||
// Update placeholder with error
|
||||
setMessages((prev) => {
|
||||
const newMessages = [...prev]
|
||||
const lastMsg = newMessages[newMessages.length - 1]
|
||||
if (lastMsg.role === 'assistant') {
|
||||
lastMsg.content = 'Ett fel uppstod. Försök igen.'
|
||||
}
|
||||
return newMessages
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
setIsStreaming(false)
|
||||
abortControllerRef.current = null
|
||||
}
|
||||
}, [sessionId, isLoading, isStreaming, options])
|
||||
|
||||
const loadSession = useCallback(async (id: string) => {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/extensions/ext/ai-chat/sessions/${id}`)
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load session')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
setSessionId(id)
|
||||
setMessages(data.messages || [])
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to load session'
|
||||
setError(errorMessage)
|
||||
options.onError?.(errorMessage)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [options])
|
||||
|
||||
const clearChat = useCallback(() => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort()
|
||||
}
|
||||
setMessages([])
|
||||
setSessionId(null)
|
||||
setError(null)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
messages,
|
||||
isLoading,
|
||||
isStreaming,
|
||||
sessionId,
|
||||
error,
|
||||
toolsExecuting,
|
||||
sendMessage,
|
||||
loadSession,
|
||||
clearChat,
|
||||
setSessionId,
|
||||
}
|
||||
}
|
||||
@@ -217,7 +217,7 @@ export default function DashboardContent({ firstName, settings, summary, onboard
|
||||
if (summary.expiringBankConnections && summary.expiringBankConnections.length > 0) {
|
||||
const conn = summary.expiringBankConnections[0]
|
||||
alertItems.push(
|
||||
<Link key="bank-expiry" href="/settings?tab=banking" className="group">
|
||||
<Link key="bank-expiry" href="/settings/banking" className="group">
|
||||
<Card className="h-full border-warning/30 hover:bg-warning/[0.03] transition-colors">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { AI_DATA_DISCLOSURES, type AiExtensionId } from '@/lib/extensions/ai-consent'
|
||||
import Link from 'next/link'
|
||||
|
||||
interface AiConsentDialogProps {
|
||||
extensionId: AiExtensionId
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onConsented: () => void
|
||||
}
|
||||
|
||||
export function AiConsentDialog({
|
||||
extensionId,
|
||||
open,
|
||||
onOpenChange,
|
||||
onConsented,
|
||||
}: AiConsentDialogProps) {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const disclosure = AI_DATA_DISCLOSURES[extensionId]
|
||||
|
||||
async function handleAccept() {
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
const res = await fetch('/api/ai-consent', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ extension_id: extensionId }),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
onOpenChange(false)
|
||||
onConsented()
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>AI-samtycke krävs</DialogTitle>
|
||||
<DialogDescription>
|
||||
Denna funktion använder AI-tjänster från externa leverantörer.
|
||||
Granska informationen nedan innan du fortsätter.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-1">Leverantör</p>
|
||||
<p className="text-sm text-muted-foreground">{disclosure.provider}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-1">Data som skickas</p>
|
||||
<ul className="text-sm text-muted-foreground list-disc pl-5 space-y-1">
|
||||
{disclosure.dataTypes.map((dt) => (
|
||||
<li key={dt}>{dt}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-1">Syfte</p>
|
||||
<p className="text-sm text-muted-foreground">{disclosure.purpose}</p>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Läs mer i vår{' '}
|
||||
<Link href="/privacy" className="underline underline-offset-4" target="_blank">
|
||||
integritetspolicy
|
||||
</Link>
|
||||
. Du kan när som helst återkalla ditt samtycke i inställningarna.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting}>
|
||||
Avbryt
|
||||
</Button>
|
||||
<Button onClick={handleAccept} disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Sparar...' : 'Jag samtycker'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
|
||||
import { ChatPanel } from '@/components/chat/ChatPanel'
|
||||
|
||||
export default function AiChatWorkspace({ userId }: WorkspaceComponentProps) {
|
||||
return (
|
||||
<div className="h-[calc(100vh-10rem)] max-w-4xl mx-auto">
|
||||
<ChatPanel className="h-full rounded-lg border border-border bg-background shadow-sm" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,371 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
|
||||
import type { InvoiceInboxItem, Supplier, DocumentClassificationType } from '@/types'
|
||||
import type { InvoiceInboxSettings } from '@/extensions/general/invoice-inbox/types'
|
||||
import { PageHeader } from '@/components/ui/page-header'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Settings, Inbox, CheckCircle2, AlertTriangle, Receipt, FileText, RefreshCw } from 'lucide-react'
|
||||
import DocumentInboxCard from '@/components/extensions/general/document-inbox/DocumentInboxCard'
|
||||
import ReceiptInboxDetail from '@/components/extensions/general/document-inbox/ReceiptInboxDetail'
|
||||
import InboxUploadZone from '@/components/extensions/general/invoice-inbox/InboxUploadZone'
|
||||
import InboxDetailDialog from '@/components/extensions/general/invoice-inbox/InboxDetailDialog'
|
||||
import InboxSettingsDialog from '@/components/extensions/general/invoice-inbox/InboxSettingsDialog'
|
||||
|
||||
type TabValue = 'all' | DocumentClassificationType
|
||||
|
||||
const TABS: { value: TabValue; label: string }[] = [
|
||||
{ value: 'all', label: 'Alla' },
|
||||
{ value: 'supplier_invoice', label: 'Fakturor' },
|
||||
{ value: 'receipt', label: 'Kvitton' },
|
||||
{ value: 'government_letter', label: 'Myndighetspost' },
|
||||
{ value: 'unknown', label: 'Övrigt' },
|
||||
]
|
||||
|
||||
const DEFAULT_SETTINGS: InvoiceInboxSettings = {
|
||||
autoProcessEnabled: true,
|
||||
autoMatchSupplierEnabled: true,
|
||||
supplierMatchThreshold: 0.7,
|
||||
inboxEmail: null,
|
||||
}
|
||||
|
||||
export default function DocumentInboxWorkspace({ userId }: WorkspaceComponentProps) {
|
||||
const [items, setItems] = useState<InvoiceInboxItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [activeTab, setActiveTab] = useState<TabValue>('all')
|
||||
const [selectedItem, setSelectedItem] = useState<InvoiceInboxItem | null>(null)
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
const [settings, setSettings] = useState<InvoiceInboxSettings>(DEFAULT_SETTINGS)
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
const [suppliers, setSuppliers] = useState<Supplier[]>([])
|
||||
|
||||
const fetchItems = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/extensions/ext/invoice-inbox/inbox')
|
||||
if (res.ok) {
|
||||
const { data } = await res.json()
|
||||
setItems(data ?? [])
|
||||
}
|
||||
} catch {
|
||||
// Silently fail
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const fetchSettings = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/extensions/ext/invoice-inbox/settings')
|
||||
if (res.ok) {
|
||||
const { data } = await res.json()
|
||||
if (data) setSettings(data)
|
||||
}
|
||||
} catch {
|
||||
// Use defaults
|
||||
}
|
||||
}, [])
|
||||
|
||||
const fetchSuppliers = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/suppliers')
|
||||
if (res.ok) {
|
||||
const { data } = await res.json()
|
||||
setSuppliers(data ?? [])
|
||||
}
|
||||
} catch {
|
||||
// ok
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchItems()
|
||||
fetchSettings()
|
||||
fetchSuppliers()
|
||||
}, [fetchItems, fetchSettings, fetchSuppliers])
|
||||
|
||||
function handleUploadComplete(result: InvoiceInboxItem | InvoiceInboxItem[]) {
|
||||
const newItems = Array.isArray(result) ? result : [result]
|
||||
setItems((prev) => [...newItems, ...prev])
|
||||
for (const item of newItems) {
|
||||
pollItem(item.id)
|
||||
}
|
||||
}
|
||||
|
||||
const [isMatching, setIsMatching] = useState(false)
|
||||
|
||||
async function handleMatchSweep() {
|
||||
setIsMatching(true)
|
||||
try {
|
||||
const res = await fetch('/api/documents/match-sweep', { method: 'POST' })
|
||||
if (res.ok) {
|
||||
await fetchItems()
|
||||
}
|
||||
} catch {
|
||||
// Non-critical
|
||||
} finally {
|
||||
setIsMatching(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function pollItem(itemId: string) {
|
||||
for (let i = 0; i < 20; i++) {
|
||||
await new Promise((r) => setTimeout(r, 3000))
|
||||
try {
|
||||
const res = await fetch(`/api/extensions/ext/invoice-inbox/inbox/${itemId}`)
|
||||
if (!res.ok) continue
|
||||
const { data } = await res.json()
|
||||
if (data && data.status !== 'processing') {
|
||||
setItems((prev) =>
|
||||
prev.map((it) => (it.id === itemId ? data : it))
|
||||
)
|
||||
setSelectedItem((current) =>
|
||||
current?.id === itemId ? data : current
|
||||
)
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleItemClick(item: InvoiceInboxItem) {
|
||||
setSelectedItem(item)
|
||||
}
|
||||
|
||||
async function handleConfirm(itemId: string, supplierId?: string) {
|
||||
const body: Record<string, string> = {}
|
||||
if (supplierId) body.supplier_id = supplierId
|
||||
|
||||
const res = await fetch(`/api/extensions/ext/invoice-inbox/inbox/${itemId}/confirm`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
setItems((prev) =>
|
||||
prev.map((it) => (it.id === itemId ? { ...it, status: 'confirmed' as const } : it))
|
||||
)
|
||||
setSelectedItem(null)
|
||||
fetchSuppliers()
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReject(itemId: string) {
|
||||
const res = await fetch(`/api/extensions/ext/invoice-inbox/inbox/${itemId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
setItems((prev) =>
|
||||
prev.map((it) => (it.id === itemId ? { ...it, status: 'rejected' as const } : it))
|
||||
)
|
||||
setSelectedItem(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReprocess(itemId: string) {
|
||||
setItems((prev) =>
|
||||
prev.map((it) => (it.id === itemId ? { ...it, status: 'processing' as const } : it))
|
||||
)
|
||||
setSelectedItem(null)
|
||||
|
||||
const res = await fetch(`/api/extensions/ext/invoice-inbox/inbox/${itemId}/process`, {
|
||||
method: 'POST',
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
const { data } = await res.json()
|
||||
if (data) {
|
||||
setItems((prev) => prev.map((it) => (it.id === itemId ? data : it)))
|
||||
}
|
||||
} else {
|
||||
fetchItems()
|
||||
}
|
||||
}
|
||||
|
||||
function handleReceiptConfirm() {
|
||||
fetchItems()
|
||||
setSelectedItem(null)
|
||||
}
|
||||
|
||||
async function handleSaveSettings(updated: InvoiceInboxSettings) {
|
||||
const res = await fetch('/api/extensions/ext/invoice-inbox/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updated),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
const { data } = await res.json()
|
||||
if (data) setSettings(data)
|
||||
}
|
||||
}
|
||||
|
||||
const filteredItems =
|
||||
activeTab === 'all'
|
||||
? items
|
||||
: items.filter((it) => (it.document_type ?? 'supplier_invoice') === activeTab)
|
||||
|
||||
const totalPending = items.filter((it) => it.status === 'ready' || it.status === 'pending').length
|
||||
const receiptCount = items.filter((it) => it.document_type === 'receipt' && it.status === 'ready').length
|
||||
const invoiceCount = items.filter((it) => (it.document_type ?? 'supplier_invoice') === 'supplier_invoice' && it.status === 'ready').length
|
||||
|
||||
// Determine which detail dialog to show
|
||||
const isReceiptSelected = selectedItem?.document_type === 'receipt'
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Dokumentinkorg"
|
||||
description="Alla inkommande dokument — fakturor, kvitton och myndighetspost"
|
||||
action={
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleMatchSweep}
|
||||
disabled={isMatching}
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${isMatching ? 'animate-spin' : ''}`} />
|
||||
Matcha alla
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* KPI cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 p-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Inbox className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-medium">{totalPending}</p>
|
||||
<p className="text-xs text-muted-foreground">Att granska</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 p-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-warning/15">
|
||||
<FileText className="h-5 w-5 text-warning-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-medium">{invoiceCount}</p>
|
||||
<p className="text-xs text-muted-foreground">Fakturor att granska</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 p-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-secondary/50">
|
||||
<Receipt className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-medium">{receiptCount}</p>
|
||||
<p className="text-xs text-muted-foreground">Kvitton att granska</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Upload zone */}
|
||||
<InboxUploadZone
|
||||
onUploadComplete={handleUploadComplete}
|
||||
isUploading={isUploading}
|
||||
setIsUploading={setIsUploading}
|
||||
/>
|
||||
|
||||
{/* Tabs by document type */}
|
||||
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as TabValue)}>
|
||||
<TabsList>
|
||||
{TABS.map((tab) => (
|
||||
<TabsTrigger key={tab.value} value={tab.value}>
|
||||
{tab.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
{TABS.map((tab) => (
|
||||
<TabsContent key={tab.value} value={tab.value}>
|
||||
{loading ? (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-20 w-full rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
) : filteredItems.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Inbox className="h-10 w-10 text-muted-foreground/40 mb-3" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{activeTab === 'all'
|
||||
? 'Inga dokument ännu. Ladda upp ett dokument ovan eller skicka via e-post.'
|
||||
: 'Inga dokument av denna typ.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{filteredItems.map((item) => (
|
||||
<DocumentInboxCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
onClick={() => handleItemClick(item)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
|
||||
{/* Receipt detail dialog */}
|
||||
{isReceiptSelected && (
|
||||
<ReceiptInboxDetail
|
||||
item={selectedItem}
|
||||
open={selectedItem != null && isReceiptSelected}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setSelectedItem(null)
|
||||
}}
|
||||
onConfirm={handleReceiptConfirm}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Invoice/other detail dialog (existing) */}
|
||||
{!isReceiptSelected && (
|
||||
<InboxDetailDialog
|
||||
item={selectedItem}
|
||||
open={selectedItem != null && !isReceiptSelected}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setSelectedItem(null)
|
||||
}}
|
||||
onConfirm={handleConfirm}
|
||||
onReject={handleReject}
|
||||
onReprocess={handleReprocess}
|
||||
suppliers={suppliers}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Settings dialog */}
|
||||
<InboxSettingsDialog
|
||||
open={settingsOpen}
|
||||
onOpenChange={setSettingsOpen}
|
||||
settings={settings}
|
||||
onSave={handleSaveSettings}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -14,7 +14,7 @@ export default function EnableBankingWorkspace({ userId }: WorkspaceComponentPro
|
||||
Koppla ditt bankkonto under Inställningar för att synka transaktioner automatiskt.
|
||||
</p>
|
||||
<Button asChild variant="outline" className="mt-4">
|
||||
<Link href="/settings?tab=banking">
|
||||
<Link href="/settings/banking">
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
Gå till bankinställningar
|
||||
</Link>
|
||||
|
||||
@@ -1,328 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
|
||||
import type { InvoiceInboxItem, Supplier, InboxItemStatus } from '@/types'
|
||||
import type { InvoiceInboxSettings } from '@/extensions/general/invoice-inbox/types'
|
||||
import { PageHeader } from '@/components/ui/page-header'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Settings, Inbox, CheckCircle2, AlertTriangle } from 'lucide-react'
|
||||
import InboxItemCard from '@/components/extensions/general/invoice-inbox/InboxItemCard'
|
||||
import InboxUploadZone from '@/components/extensions/general/invoice-inbox/InboxUploadZone'
|
||||
import InboxDetailDialog from '@/components/extensions/general/invoice-inbox/InboxDetailDialog'
|
||||
import InboxSettingsDialog from '@/components/extensions/general/invoice-inbox/InboxSettingsDialog'
|
||||
|
||||
type TabValue = 'all' | InboxItemStatus
|
||||
|
||||
const TABS: { value: TabValue; label: string }[] = [
|
||||
{ value: 'all', label: 'Alla' },
|
||||
{ value: 'pending', label: 'Ny' },
|
||||
{ value: 'ready', label: 'Klar' },
|
||||
{ value: 'confirmed', label: 'Bekräftad' },
|
||||
{ value: 'rejected', label: 'Avvisad' },
|
||||
{ value: 'error', label: 'Fel' },
|
||||
]
|
||||
|
||||
const DEFAULT_SETTINGS: InvoiceInboxSettings = {
|
||||
autoProcessEnabled: true,
|
||||
autoMatchSupplierEnabled: true,
|
||||
supplierMatchThreshold: 0.7,
|
||||
inboxEmail: null,
|
||||
}
|
||||
|
||||
export default function InvoiceInboxWorkspace({ userId }: WorkspaceComponentProps) {
|
||||
const [items, setItems] = useState<InvoiceInboxItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [activeTab, setActiveTab] = useState<TabValue>('all')
|
||||
const [selectedItem, setSelectedItem] = useState<InvoiceInboxItem | null>(null)
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
const [settings, setSettings] = useState<InvoiceInboxSettings>(DEFAULT_SETTINGS)
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
const [suppliers, setSuppliers] = useState<Supplier[]>([])
|
||||
|
||||
const fetchItems = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/extensions/ext/invoice-inbox/inbox')
|
||||
if (res.ok) {
|
||||
const { data } = await res.json()
|
||||
setItems(data ?? [])
|
||||
}
|
||||
} catch {
|
||||
// Silently fail — user sees empty state
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const fetchSettings = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/extensions/ext/invoice-inbox/settings')
|
||||
if (res.ok) {
|
||||
const { data } = await res.json()
|
||||
if (data) setSettings(data)
|
||||
}
|
||||
} catch {
|
||||
// Use defaults
|
||||
}
|
||||
}, [])
|
||||
|
||||
const fetchSuppliers = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/suppliers')
|
||||
if (res.ok) {
|
||||
const { data } = await res.json()
|
||||
setSuppliers(data ?? [])
|
||||
}
|
||||
} catch {
|
||||
// ok
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchItems()
|
||||
fetchSettings()
|
||||
fetchSuppliers()
|
||||
}, [fetchItems, fetchSettings, fetchSuppliers])
|
||||
|
||||
function handleUploadComplete(result: InvoiceInboxItem | InvoiceInboxItem[]) {
|
||||
const newItems = Array.isArray(result) ? result : [result]
|
||||
setItems((prev) => [...newItems, ...prev])
|
||||
for (const item of newItems) {
|
||||
pollItem(item.id)
|
||||
}
|
||||
}
|
||||
|
||||
async function pollItem(itemId: string) {
|
||||
for (let i = 0; i < 20; i++) {
|
||||
await new Promise((r) => setTimeout(r, 3000))
|
||||
try {
|
||||
const res = await fetch(`/api/extensions/ext/invoice-inbox/inbox/${itemId}`)
|
||||
if (!res.ok) continue
|
||||
const { data } = await res.json()
|
||||
if (data && data.status !== 'processing') {
|
||||
setItems((prev) =>
|
||||
prev.map((it) => (it.id === itemId ? data : it))
|
||||
)
|
||||
// Also update the detail dialog if it's open for this item
|
||||
setSelectedItem((current) =>
|
||||
current?.id === itemId ? data : current
|
||||
)
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// continue polling
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleConfirm(
|
||||
itemId: string,
|
||||
supplierId?: string,
|
||||
newSupplierData?: import('@/components/extensions/general/invoice-inbox/InboxDetailDialog').NewSupplierData
|
||||
) {
|
||||
const body: Record<string, unknown> = {}
|
||||
if (supplierId) {
|
||||
body.supplier_id = supplierId
|
||||
} else if (newSupplierData) {
|
||||
body.new_supplier = newSupplierData
|
||||
}
|
||||
|
||||
const res = await fetch(`/api/extensions/ext/invoice-inbox/inbox/${itemId}/confirm`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
setItems((prev) =>
|
||||
prev.map((it) => (it.id === itemId ? { ...it, status: 'confirmed' as const } : it))
|
||||
)
|
||||
setSelectedItem(null)
|
||||
fetchSuppliers() // New supplier may have been created
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReject(itemId: string) {
|
||||
const res = await fetch(`/api/extensions/ext/invoice-inbox/inbox/${itemId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
setItems((prev) =>
|
||||
prev.map((it) => (it.id === itemId ? { ...it, status: 'rejected' as const } : it))
|
||||
)
|
||||
setSelectedItem(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReprocess(itemId: string) {
|
||||
setItems((prev) =>
|
||||
prev.map((it) => (it.id === itemId ? { ...it, status: 'processing' as const } : it))
|
||||
)
|
||||
setSelectedItem(null)
|
||||
|
||||
const res = await fetch(`/api/extensions/ext/invoice-inbox/inbox/${itemId}/process`, {
|
||||
method: 'POST',
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
const { data } = await res.json()
|
||||
if (data) {
|
||||
setItems((prev) => prev.map((it) => (it.id === itemId ? data : it)))
|
||||
}
|
||||
} else {
|
||||
// Refetch in case of error update
|
||||
fetchItems()
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveSettings(updated: InvoiceInboxSettings) {
|
||||
const res = await fetch('/api/extensions/ext/invoice-inbox/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updated),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
const { data } = await res.json()
|
||||
if (data) setSettings(data)
|
||||
}
|
||||
}
|
||||
|
||||
const filteredItems =
|
||||
activeTab === 'all'
|
||||
? items
|
||||
: items.filter((it) => it.status === activeTab)
|
||||
|
||||
const totalCount = items.length
|
||||
const readyCount = items.filter((it) => it.status === 'ready').length
|
||||
const errorCount = items.filter((it) => it.status === 'error').length
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Fakturainkorgen"
|
||||
description="Ladda upp leverantörsfakturor och låt AI extrahera data automatiskt"
|
||||
action={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* KPI cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 p-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Inbox className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-medium">{totalCount}</p>
|
||||
<p className="text-xs text-muted-foreground">Totalt</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 p-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-warning/15">
|
||||
<CheckCircle2 className="h-5 w-5 text-warning-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-medium">{readyCount}</p>
|
||||
<p className="text-xs text-muted-foreground">Att granska</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 p-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-destructive/10">
|
||||
<AlertTriangle className="h-5 w-5 text-destructive" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-medium">{errorCount}</p>
|
||||
<p className="text-xs text-muted-foreground">Fel</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Upload zone */}
|
||||
<InboxUploadZone
|
||||
onUploadComplete={handleUploadComplete}
|
||||
isUploading={isUploading}
|
||||
setIsUploading={setIsUploading}
|
||||
/>
|
||||
|
||||
{/* Tabs + item list */}
|
||||
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as TabValue)}>
|
||||
<TabsList>
|
||||
{TABS.map((tab) => (
|
||||
<TabsTrigger key={tab.value} value={tab.value}>
|
||||
{tab.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
{TABS.map((tab) => (
|
||||
<TabsContent key={tab.value} value={tab.value}>
|
||||
{loading ? (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-20 w-full rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
) : filteredItems.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Inbox className="h-10 w-10 text-muted-foreground/40 mb-3" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{activeTab === 'all'
|
||||
? 'Inga fakturor ännu. Ladda upp en faktura ovan.'
|
||||
: 'Inga fakturor med denna status.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{filteredItems.map((item) => (
|
||||
<InboxItemCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
onClick={() => setSelectedItem(item)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
|
||||
{/* Detail dialog */}
|
||||
<InboxDetailDialog
|
||||
item={selectedItem}
|
||||
open={selectedItem != null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setSelectedItem(null)
|
||||
}}
|
||||
onConfirm={handleConfirm}
|
||||
onReject={handleReject}
|
||||
onReprocess={handleReprocess}
|
||||
suppliers={suppliers}
|
||||
/>
|
||||
|
||||
{/* Settings dialog */}
|
||||
<InboxSettingsDialog
|
||||
open={settingsOpen}
|
||||
onOpenChange={setSettingsOpen}
|
||||
settings={settings}
|
||||
onSave={handleSaveSettings}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { InvoiceInboxItem, DocumentClassificationType } from '@/types'
|
||||
import type { InvoiceExtractionResult } from '@/extensions/general/invoice-inbox/types'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
getStatusLabel,
|
||||
getStatusVariant,
|
||||
getConfidenceLabel,
|
||||
formatExtractionSummary,
|
||||
getDocumentTypeLabel,
|
||||
getDocumentTypeVariant,
|
||||
} from '@/lib/extensions/invoice-inbox-utils'
|
||||
import { Mail, Upload, FileText, Receipt, Landmark } from 'lucide-react'
|
||||
|
||||
interface DocumentInboxCardProps {
|
||||
item: InvoiceInboxItem
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
function formatRelativeTime(dateStr: string): string {
|
||||
const now = new Date()
|
||||
const date = new Date(dateStr)
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
const diffMin = Math.floor(diffMs / 60000)
|
||||
if (diffMin < 1) return 'Just nu'
|
||||
if (diffMin < 60) return `${diffMin} min sedan`
|
||||
const diffH = Math.floor(diffMin / 60)
|
||||
if (diffH < 24) return `${diffH} tim sedan`
|
||||
const diffD = Math.floor(diffH / 24)
|
||||
if (diffD === 1) return 'Igår'
|
||||
return `${diffD} dagar sedan`
|
||||
}
|
||||
|
||||
function formatAmount(amount: number, currency: string = 'SEK'): string {
|
||||
return new Intl.NumberFormat('sv-SE', {
|
||||
style: 'currency',
|
||||
currency,
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount)
|
||||
}
|
||||
|
||||
function getDocumentIcon(type: DocumentClassificationType) {
|
||||
switch (type) {
|
||||
case 'receipt':
|
||||
return <Receipt className="h-5 w-5 text-muted-foreground" />
|
||||
case 'government_letter':
|
||||
return <Landmark className="h-5 w-5 text-muted-foreground" />
|
||||
default:
|
||||
return <FileText className="h-5 w-5 text-muted-foreground" />
|
||||
}
|
||||
}
|
||||
|
||||
function getSummaryText(item: InvoiceInboxItem): { label: string; total: number; currency: string } {
|
||||
const type = item.document_type ?? 'supplier_invoice'
|
||||
|
||||
switch (type) {
|
||||
case 'supplier_invoice': {
|
||||
const extraction = item.extracted_data as unknown as InvoiceExtractionResult | null
|
||||
const summary = formatExtractionSummary(extraction)
|
||||
return {
|
||||
label: (item.supplier as { name?: string } | undefined)?.name ?? (summary.supplierName || 'Okänd leverantör'),
|
||||
total: summary.total,
|
||||
currency: summary.currency,
|
||||
}
|
||||
}
|
||||
case 'receipt': {
|
||||
const receipt = item.receipt as { merchant_name?: string; total_amount?: number; currency?: string } | undefined
|
||||
return {
|
||||
label: receipt?.merchant_name ?? 'Okänd handlare',
|
||||
total: receipt?.total_amount ?? 0,
|
||||
currency: receipt?.currency || 'SEK',
|
||||
}
|
||||
}
|
||||
case 'government_letter': {
|
||||
return {
|
||||
label: item.email_from ?? 'Okänd avsändare',
|
||||
total: 0,
|
||||
currency: 'SEK',
|
||||
}
|
||||
}
|
||||
default: {
|
||||
return { label: 'Granska manuellt', total: 0, currency: 'SEK' }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default function DocumentInboxCard({ item, onClick }: DocumentInboxCardProps) {
|
||||
const confidence = getConfidenceLabel(item.confidence)
|
||||
const statusVariant = getStatusVariant(item.status) as 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning'
|
||||
const confidenceVariant = confidence.variant as 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning'
|
||||
const docType = (item.document_type ?? 'supplier_invoice') as DocumentClassificationType
|
||||
const docTypeVariant = getDocumentTypeVariant(docType) as 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning'
|
||||
|
||||
const fileName = (item.document as { file_name?: string } | undefined)?.file_name ?? 'Okänd fil'
|
||||
const { label: summaryLabel, total, currency } = getSummaryText(item)
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="cursor-pointer transition-colors hover:bg-accent/50"
|
||||
onClick={onClick}
|
||||
>
|
||||
<CardContent className="flex items-center gap-4 p-4">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-muted">
|
||||
{item.source === 'email' ? (
|
||||
<Mail className="h-5 w-5 text-muted-foreground" />
|
||||
) : (
|
||||
<Upload className="h-5 w-5 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
{getDocumentIcon(docType)}
|
||||
<span className="text-sm font-medium truncate">{fileName}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className={`text-sm truncate ${summaryLabel === 'Granska manuellt' ? 'text-muted-foreground/60 italic' : 'text-muted-foreground'}`}>
|
||||
{summaryLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-end gap-1 shrink-0">
|
||||
{total > 0 && (
|
||||
<span className="text-sm font-medium">{formatAmount(total, currency)}</span>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Badge variant={docTypeVariant} className="text-[10px] px-1.5 py-0">
|
||||
{getDocumentTypeLabel(docType)}
|
||||
</Badge>
|
||||
{item.confidence != null && (
|
||||
<Badge variant={confidenceVariant} className="text-[10px] px-1.5 py-0">
|
||||
{confidence.label}
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant={statusVariant}>
|
||||
{getStatusLabel(item.status)}
|
||||
</Badge>
|
||||
</div>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{formatRelativeTime(item.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,311 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import type { InvoiceInboxItem } from '@/types'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { CheckCircle2, Receipt, LinkIcon } from 'lucide-react'
|
||||
|
||||
interface ReceiptLineItem {
|
||||
id: string
|
||||
description: string
|
||||
line_total: number
|
||||
vat_rate: number | null
|
||||
is_business: boolean | null
|
||||
category: string | null
|
||||
bas_account: string | null
|
||||
}
|
||||
|
||||
interface ReceiptInboxDetailProps {
|
||||
item: InvoiceInboxItem | null
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onConfirm: () => void
|
||||
}
|
||||
|
||||
function formatAmount(amount: number, currency: string = 'SEK'): string {
|
||||
return new Intl.NumberFormat('sv-SE', {
|
||||
style: 'currency',
|
||||
currency,
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(amount)
|
||||
}
|
||||
|
||||
export default function ReceiptInboxDetail({
|
||||
item,
|
||||
open,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
}: ReceiptInboxDetailProps) {
|
||||
const [lineItems, setLineItems] = useState<ReceiptLineItem[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [confirming, setConfirming] = useState(false)
|
||||
const [representationPersons, setRepresentationPersons] = useState<number | null>(null)
|
||||
const [representationPurpose, setRepresentationPurpose] = useState('')
|
||||
const [representationBusinessConnection, setRepresentationBusinessConnection] = useState('')
|
||||
|
||||
const receipt = item?.receipt as {
|
||||
id?: string
|
||||
merchant_name?: string
|
||||
total_amount?: number
|
||||
receipt_date?: string
|
||||
status?: string
|
||||
matched_transaction_id?: string
|
||||
} | undefined
|
||||
|
||||
// Fetch line items when dialog opens
|
||||
async function fetchLineItems() {
|
||||
if (!item?.linked_receipt_id) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch(`/api/extensions/ext/receipt-ocr/${item.linked_receipt_id}`)
|
||||
if (res.ok) {
|
||||
const { data } = await res.json()
|
||||
if (data?.line_items) {
|
||||
setLineItems(data.line_items)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ok
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleOpenChange(isOpen: boolean) {
|
||||
if (isOpen && item?.linked_receipt_id) {
|
||||
fetchLineItems()
|
||||
}
|
||||
onOpenChange(isOpen)
|
||||
}
|
||||
|
||||
function toggleBusiness(lineItemId: string) {
|
||||
setLineItems((prev) =>
|
||||
prev.map((li) =>
|
||||
li.id === lineItemId
|
||||
? { ...li, is_business: li.is_business === true ? false : true }
|
||||
: li
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
async function handleConfirm() {
|
||||
if (!item?.id || !item.linked_receipt_id) return
|
||||
setConfirming(true)
|
||||
|
||||
try {
|
||||
const body: Record<string, unknown> = {
|
||||
line_items: lineItems.map((li) => ({
|
||||
id: li.id,
|
||||
is_business: li.is_business,
|
||||
category: li.category,
|
||||
bas_account: li.bas_account,
|
||||
})),
|
||||
}
|
||||
|
||||
if (receipt?.matched_transaction_id) {
|
||||
body.matched_transaction_id = receipt.matched_transaction_id
|
||||
}
|
||||
if (representationPersons != null && representationPersons > 0) {
|
||||
body.representation_persons = representationPersons
|
||||
}
|
||||
if (representationPurpose) {
|
||||
body.representation_purpose = representationPurpose
|
||||
}
|
||||
if (representationBusinessConnection) {
|
||||
body.representation_business_connection = representationBusinessConnection
|
||||
}
|
||||
|
||||
const res = await fetch(
|
||||
`/api/extensions/ext/invoice-inbox/inbox/${item.id}/confirm-receipt`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
}
|
||||
)
|
||||
|
||||
if (res.ok) {
|
||||
onConfirm()
|
||||
onOpenChange(false)
|
||||
}
|
||||
} catch {
|
||||
// ok
|
||||
} finally {
|
||||
setConfirming(false)
|
||||
}
|
||||
}
|
||||
|
||||
const businessTotal = lineItems
|
||||
.filter((li) => li.is_business === true)
|
||||
.reduce((sum, li) => sum + li.line_total, 0)
|
||||
const privateTotal = lineItems
|
||||
.filter((li) => li.is_business === false)
|
||||
.reduce((sum, li) => sum + li.line_total, 0)
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Receipt className="h-5 w-5" />
|
||||
Kvitto via e-post
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{receipt && (
|
||||
<div className="space-y-4">
|
||||
{/* Summary */}
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Handlare</span>
|
||||
<p className="font-medium">{receipt.merchant_name ?? 'Okänd'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Datum</span>
|
||||
<p className="font-medium">{receipt.receipt_date ?? '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Totalbelopp</span>
|
||||
<p className="font-medium">
|
||||
{receipt.total_amount ? formatAmount(receipt.total_amount) : '-'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Transaktionsmatch</span>
|
||||
<p className="font-medium flex items-center gap-1">
|
||||
{receipt.matched_transaction_id ? (
|
||||
<>
|
||||
<LinkIcon className="h-3 w-3 text-success" />
|
||||
<span className="text-success">Matchad</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-muted-foreground">Ingen match</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Line items with business/private toggle */}
|
||||
<div className="space-y-2">
|
||||
<Label>Artikelrader</Label>
|
||||
{loading ? (
|
||||
<p className="text-sm text-muted-foreground">Laddar...</p>
|
||||
) : lineItems.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Inga rader extraherade</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{lineItems.map((li) => (
|
||||
<div
|
||||
key={li.id}
|
||||
className="flex items-center justify-between gap-3 rounded-lg border p-3"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm truncate">{li.description}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatAmount(li.line_total)}
|
||||
{li.vat_rate != null && ` (${li.vat_rate}% moms)`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="text-xs text-muted-foreground">Företag</span>
|
||||
<Switch
|
||||
checked={li.is_business === true}
|
||||
onCheckedChange={() => toggleBusiness(li.id)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Totals */}
|
||||
{lineItems.length > 0 && (
|
||||
<div className="flex gap-4 text-sm">
|
||||
<Badge variant="default">Företag: {formatAmount(Math.round(businessTotal * 100) / 100)}</Badge>
|
||||
<Badge variant="secondary">Privat: {formatAmount(Math.round(privateTotal * 100) / 100)}</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Representation fields */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-xs text-muted-foreground uppercase tracking-wide">
|
||||
Representation (vid restaurangkvitto)
|
||||
</Label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label htmlFor="rep-persons" className="text-sm">
|
||||
Antal personer
|
||||
</Label>
|
||||
<Input
|
||||
id="rep-persons"
|
||||
type="number"
|
||||
min={0}
|
||||
value={representationPersons ?? ''}
|
||||
onChange={(e) =>
|
||||
setRepresentationPersons(
|
||||
e.target.value ? parseInt(e.target.value) : null
|
||||
)
|
||||
}
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="rep-purpose" className="text-sm">
|
||||
Syfte
|
||||
</Label>
|
||||
<Input
|
||||
id="rep-purpose"
|
||||
value={representationPurpose}
|
||||
onChange={(e) => setRepresentationPurpose(e.target.value)}
|
||||
placeholder="T.ex. kundmöte"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="rep-connection" className="text-sm">
|
||||
Affärsmässig koppling (BFNAR)
|
||||
</Label>
|
||||
<Input
|
||||
id="rep-connection"
|
||||
value={representationBusinessConnection}
|
||||
onChange={(e) =>
|
||||
setRepresentationBusinessConnection(e.target.value)
|
||||
}
|
||||
placeholder="T.ex. potentiell kund, pågående projekt"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Avbryt
|
||||
</Button>
|
||||
<Button onClick={handleConfirm} disabled={confirming}>
|
||||
<CheckCircle2 className="h-4 w-4 mr-1.5" />
|
||||
{confirming ? 'Bekräftar...' : 'Bekräfta kvitto'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,454 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import type { InvoiceInboxItem, Supplier, SupplierType } from '@/types'
|
||||
import type { InvoiceExtractionResult } from '@/extensions/general/invoice-inbox/types'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import {
|
||||
getStatusLabel,
|
||||
getStatusVariant,
|
||||
getConfidenceLabel,
|
||||
} from '@/lib/extensions/invoice-inbox-utils'
|
||||
import { Loader2, RefreshCw, Check, X, ChevronDown } from 'lucide-react'
|
||||
|
||||
export interface NewSupplierData {
|
||||
name: string
|
||||
supplier_type: SupplierType
|
||||
org_number: string
|
||||
vat_number: string
|
||||
bankgiro: string
|
||||
plusgiro: string
|
||||
default_expense_account: string
|
||||
default_currency: string
|
||||
}
|
||||
|
||||
interface InboxDetailDialogProps {
|
||||
item: InvoiceInboxItem | null
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onConfirm: (itemId: string, supplierId?: string, newSupplierData?: NewSupplierData) => Promise<void>
|
||||
onReject: (itemId: string) => Promise<void>
|
||||
onReprocess: (itemId: string) => Promise<void>
|
||||
suppliers: Supplier[]
|
||||
}
|
||||
|
||||
function formatAmount(amount: number | null, currency: string = 'SEK'): string {
|
||||
if (amount == null) return '-'
|
||||
return new Intl.NumberFormat('sv-SE', {
|
||||
style: 'currency',
|
||||
currency,
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(amount)
|
||||
}
|
||||
|
||||
export default function InboxDetailDialog({
|
||||
item,
|
||||
open,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
onReject,
|
||||
onReprocess,
|
||||
suppliers,
|
||||
}: InboxDetailDialogProps) {
|
||||
const [loading, setLoading] = useState<'confirm' | 'reject' | 'reprocess' | null>(null)
|
||||
const [selectedSupplierId, setSelectedSupplierId] = useState<string | undefined>(undefined)
|
||||
const [supplierFormOpen, setSupplierFormOpen] = useState(false)
|
||||
const [newSupplier, setNewSupplier] = useState<NewSupplierData>({
|
||||
name: '',
|
||||
supplier_type: 'swedish_business',
|
||||
org_number: '',
|
||||
vat_number: '',
|
||||
bankgiro: '',
|
||||
plusgiro: '',
|
||||
default_expense_account: '6200',
|
||||
default_currency: 'SEK',
|
||||
})
|
||||
|
||||
// Pre-populate supplier form when item changes
|
||||
const extractionForEffect = item?.extracted_data as unknown as InvoiceExtractionResult | null
|
||||
useEffect(() => {
|
||||
if (!extractionForEffect?.supplier) return
|
||||
const s = extractionForEffect.supplier
|
||||
setNewSupplier({
|
||||
name: s.name ?? '',
|
||||
supplier_type: 'swedish_business',
|
||||
org_number: s.orgNumber ?? '',
|
||||
vat_number: s.vatNumber ?? '',
|
||||
bankgiro: s.bankgiro ?? '',
|
||||
plusgiro: s.plusgiro ?? '',
|
||||
default_expense_account: '6200',
|
||||
default_currency: extractionForEffect.invoice?.currency || 'SEK',
|
||||
})
|
||||
}, [extractionForEffect])
|
||||
|
||||
if (!item) return null
|
||||
|
||||
const extraction = item.extracted_data as unknown as InvoiceExtractionResult | null
|
||||
const currency = extraction?.invoice.currency || 'SEK'
|
||||
const confidence = getConfidenceLabel(item.confidence)
|
||||
const confidenceVariant = confidence.variant as 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning'
|
||||
const statusVariant = getStatusVariant(item.status) as 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning'
|
||||
|
||||
const matchedSupplierName = (item.supplier as { name?: string } | undefined)?.name
|
||||
const supplierId = selectedSupplierId ?? item.matched_supplier_id ?? undefined
|
||||
const isCreatingNewSupplier = !supplierId
|
||||
|
||||
const canConfirm = item.status === 'ready' && extraction != null
|
||||
const canReprocess = item.status !== 'confirmed'
|
||||
const canReject = item.status !== 'confirmed' && item.status !== 'rejected'
|
||||
|
||||
function updateSupplierField<K extends keyof NewSupplierData>(field: K, value: NewSupplierData[K]) {
|
||||
setNewSupplier((prev) => ({ ...prev, [field]: value }))
|
||||
}
|
||||
|
||||
async function handleAction(action: 'confirm' | 'reject' | 'reprocess') {
|
||||
setLoading(action)
|
||||
try {
|
||||
if (action === 'confirm') {
|
||||
await onConfirm(
|
||||
item!.id,
|
||||
supplierId,
|
||||
isCreatingNewSupplier ? newSupplier : undefined
|
||||
)
|
||||
} else if (action === 'reject') {
|
||||
await onReject(item!.id)
|
||||
} else {
|
||||
await onReprocess(item!.id)
|
||||
}
|
||||
} finally {
|
||||
setLoading(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-2xl max-h-[95dvh] sm:max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<DialogTitle>Granska faktura</DialogTitle>
|
||||
<Badge variant={statusVariant}>
|
||||
{getStatusLabel(item.status)}
|
||||
</Badge>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Confidence */}
|
||||
{item.confidence != null && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">AI-konfidens</span>
|
||||
<Badge variant={confidenceVariant}>{confidence.label} ({Math.round(item.confidence * 100)}%)</Badge>
|
||||
</div>
|
||||
<Progress value={item.confidence * 100} className="h-1.5" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.error_message && (
|
||||
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{item.error_message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{extraction && (
|
||||
<>
|
||||
<Separator />
|
||||
|
||||
{/* Supplier info */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">Leverantör</h4>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Namn</span>
|
||||
<p className="font-medium">{extraction.supplier.name ?? '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Org.nr</span>
|
||||
<p className="font-medium">{extraction.supplier.orgNumber ?? '-'}</p>
|
||||
</div>
|
||||
{extraction.supplier.bankgiro && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">Bankgiro</span>
|
||||
<p className="font-medium">{extraction.supplier.bankgiro}</p>
|
||||
</div>
|
||||
)}
|
||||
{extraction.supplier.plusgiro && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">Plusgiro</span>
|
||||
<p className="font-medium">{extraction.supplier.plusgiro}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Supplier match override */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm text-muted-foreground">
|
||||
Matchad leverantör
|
||||
{matchedSupplierName && (
|
||||
<span className="ml-1 text-xs">
|
||||
(auto: {matchedSupplierName})
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<Select
|
||||
value={supplierId ?? '__new__'}
|
||||
onValueChange={(v) => setSelectedSupplierId(v === '__new__' ? undefined : v)}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Skapa ny leverantör" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__new__">Skapa ny leverantör</SelectItem>
|
||||
{suppliers.map((s) => (
|
||||
<SelectItem key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
{s.org_number ? ` (${s.org_number})` : ''}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Inline new supplier form */}
|
||||
{isCreatingNewSupplier && (
|
||||
<div className="rounded-md border bg-muted/30 p-3 space-y-3">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between text-sm font-medium"
|
||||
onClick={() => setSupplierFormOpen((o) => !o)}
|
||||
>
|
||||
<span>Granska leverantörsuppgifter</span>
|
||||
<ChevronDown className={`h-4 w-4 transition-transform ${supplierFormOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
{supplierFormOpen && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="col-span-2 space-y-1">
|
||||
<Label className="text-xs">Namn</Label>
|
||||
<Input
|
||||
value={newSupplier.name}
|
||||
onChange={(e) => updateSupplierField('name', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Typ</Label>
|
||||
<Select
|
||||
value={newSupplier.supplier_type}
|
||||
onValueChange={(v) => updateSupplierField('supplier_type', v as SupplierType)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="swedish_business">Svenskt företag</SelectItem>
|
||||
<SelectItem value="eu_business">EU-företag</SelectItem>
|
||||
<SelectItem value="non_eu_business">Utomeuropeiskt</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Org.nr</Label>
|
||||
<Input
|
||||
value={newSupplier.org_number}
|
||||
onChange={(e) => updateSupplierField('org_number', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Bankgiro</Label>
|
||||
<Input
|
||||
value={newSupplier.bankgiro}
|
||||
onChange={(e) => updateSupplierField('bankgiro', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Plusgiro</Label>
|
||||
<Input
|
||||
value={newSupplier.plusgiro}
|
||||
onChange={(e) => updateSupplierField('plusgiro', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Kostnadskonto</Label>
|
||||
<Input
|
||||
value={newSupplier.default_expense_account}
|
||||
onChange={(e) => updateSupplierField('default_expense_account', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Valuta</Label>
|
||||
<Input
|
||||
value={newSupplier.default_currency}
|
||||
onChange={(e) => updateSupplierField('default_currency', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Invoice details */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">Fakturadetaljer</h4>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Fakturanummer</span>
|
||||
<p className="font-medium">{extraction.invoice.invoiceNumber ?? '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Valuta</span>
|
||||
<p className="font-medium">{extraction.invoice.currency}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Fakturadatum</span>
|
||||
<p className="font-medium">{extraction.invoice.invoiceDate ?? '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Förfallodatum</span>
|
||||
<p className="font-medium">{extraction.invoice.dueDate ?? '-'}</p>
|
||||
</div>
|
||||
{extraction.invoice.paymentReference && (
|
||||
<div className="col-span-2">
|
||||
<span className="text-muted-foreground">Betalningsreferens</span>
|
||||
<p className="font-medium font-mono">{extraction.invoice.paymentReference}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Line items */}
|
||||
{extraction.lineItems.length > 0 && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">Rader</h4>
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Beskrivning</TableHead>
|
||||
<TableHead className="text-right w-16">Antal</TableHead>
|
||||
<TableHead className="text-right w-24">À-pris</TableHead>
|
||||
<TableHead className="text-right w-24">Belopp</TableHead>
|
||||
<TableHead className="text-right w-16">Moms</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{extraction.lineItems.map((line, i) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell className="text-sm">{line.description}</TableCell>
|
||||
<TableCell className="text-right text-sm">{line.quantity}</TableCell>
|
||||
<TableCell className="text-right text-sm">
|
||||
{line.unitPrice != null ? formatAmount(line.unitPrice, currency) : '-'}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-sm font-medium">
|
||||
{formatAmount(line.lineTotal, currency)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-sm">
|
||||
{line.vatRate != null ? `${line.vatRate}%` : '-'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Totals */}
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Netto</span>
|
||||
<span>{formatAmount(extraction.totals.subtotal, currency)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Moms</span>
|
||||
<span>{formatAmount(extraction.totals.vatAmount, currency)}</span>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex justify-between font-medium text-base">
|
||||
<span>Totalt</span>
|
||||
<span>{formatAmount(extraction.totals.total, currency)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
{canReject && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleAction('reject')}
|
||||
disabled={loading !== null}
|
||||
>
|
||||
{loading === 'reject' ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
) : (
|
||||
<X className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Avvisa
|
||||
</Button>
|
||||
)}
|
||||
{canReprocess && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleAction('reprocess')}
|
||||
disabled={loading !== null}
|
||||
>
|
||||
{loading === 'reprocess' ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Bearbeta igen
|
||||
</Button>
|
||||
)}
|
||||
{canConfirm && (
|
||||
<Button
|
||||
onClick={() => handleAction('confirm')}
|
||||
disabled={loading !== null}
|
||||
>
|
||||
{loading === 'confirm' ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
) : (
|
||||
<Check className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Bekräfta
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { InvoiceInboxItem } from '@/types'
|
||||
import type { InvoiceExtractionResult } from '@/extensions/general/invoice-inbox/types'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
getStatusLabel,
|
||||
getStatusVariant,
|
||||
getConfidenceLabel,
|
||||
formatExtractionSummary,
|
||||
} from '@/lib/extensions/invoice-inbox-utils'
|
||||
import { Mail, Upload, FileText } from 'lucide-react'
|
||||
|
||||
interface InboxItemCardProps {
|
||||
item: InvoiceInboxItem
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
function formatRelativeTime(dateStr: string): string {
|
||||
const now = new Date()
|
||||
const date = new Date(dateStr)
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
const diffMin = Math.floor(diffMs / 60000)
|
||||
if (diffMin < 1) return 'Just nu'
|
||||
if (diffMin < 60) return `${diffMin} min sedan`
|
||||
const diffH = Math.floor(diffMin / 60)
|
||||
if (diffH < 24) return `${diffH} tim sedan`
|
||||
const diffD = Math.floor(diffH / 24)
|
||||
if (diffD === 1) return 'Igår'
|
||||
return `${diffD} dagar sedan`
|
||||
}
|
||||
|
||||
function formatAmount(amount: number, currency: string = 'SEK'): string {
|
||||
return new Intl.NumberFormat('sv-SE', {
|
||||
style: 'currency',
|
||||
currency,
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount)
|
||||
}
|
||||
|
||||
export default function InboxItemCard({ item, onClick }: InboxItemCardProps) {
|
||||
const summary = formatExtractionSummary(item.extracted_data as unknown as InvoiceExtractionResult | null)
|
||||
const confidence = getConfidenceLabel(item.confidence)
|
||||
const statusVariant = getStatusVariant(item.status) as 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning'
|
||||
const confidenceVariant = confidence.variant as 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning'
|
||||
|
||||
const fileName = (item.document as { file_name?: string } | undefined)?.file_name ?? 'Okänd fil'
|
||||
const supplierName = (item.supplier as { name?: string } | undefined)?.name ?? summary.supplierName
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="cursor-pointer transition-colors hover:bg-accent/50"
|
||||
onClick={onClick}
|
||||
>
|
||||
<CardContent className="flex items-center gap-4 p-4">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-muted">
|
||||
{item.source === 'email' ? (
|
||||
<Mail className="h-5 w-5 text-muted-foreground" />
|
||||
) : (
|
||||
<Upload className="h-5 w-5 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||
<span className="text-sm font-medium truncate">{fileName}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
{supplierName ? (
|
||||
<span className="text-sm text-muted-foreground truncate">{supplierName}</span>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground/60 italic">Okänd leverantör</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-end gap-1 shrink-0">
|
||||
{summary.total > 0 && (
|
||||
<span className="text-sm font-medium">{formatAmount(summary.total, summary.currency)}</span>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{item.confidence != null && (
|
||||
<Badge variant={confidenceVariant} className="text-[10px] px-1.5 py-0">
|
||||
{confidence.label}
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant={statusVariant}>
|
||||
{getStatusLabel(item.status)}
|
||||
</Badge>
|
||||
</div>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{formatRelativeTime(item.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import type { InvoiceInboxSettings } from '@/extensions/general/invoice-inbox/types'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Loader2, Copy, Check } from 'lucide-react'
|
||||
|
||||
interface InboxSettingsDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
settings: InvoiceInboxSettings
|
||||
onSave: (settings: InvoiceInboxSettings) => Promise<void>
|
||||
}
|
||||
|
||||
export default function InboxSettingsDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
settings,
|
||||
onSave,
|
||||
}: InboxSettingsDialogProps) {
|
||||
const [local, setLocal] = useState<InvoiceInboxSettings>(settings)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
// Reset local state when dialog opens with new settings
|
||||
function handleOpenChange(isOpen: boolean) {
|
||||
if (isOpen) {
|
||||
setLocal(settings)
|
||||
}
|
||||
onOpenChange(isOpen)
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true)
|
||||
try {
|
||||
await onSave(local)
|
||||
onOpenChange(false)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleCopyEmail() {
|
||||
if (local.inboxEmail) {
|
||||
navigator.clipboard.writeText(local.inboxEmail)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Inställningar</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 py-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>Automatisk bearbetning</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Analysera fakturor automatiskt vid uppladdning
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={local.autoProcessEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
setLocal((prev) => ({ ...prev, autoProcessEnabled: checked }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>Automatisk leverantörsmatchning</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Matcha extraherade uppgifter mot befintliga leverantörer
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={local.autoMatchSupplierEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
setLocal((prev) => ({ ...prev, autoMatchSupplierEnabled: checked }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Matchningströskel</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Lägsta konfidens för automatisk leverantörsmatchning (0-1)
|
||||
</p>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={local.supplierMatchThreshold}
|
||||
onChange={(e) =>
|
||||
setLocal((prev) => ({
|
||||
...prev,
|
||||
supplierMatchThreshold: Math.min(1, Math.max(0, parseFloat(e.target.value) || 0)),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{local.inboxEmail && (
|
||||
<div className="space-y-2">
|
||||
<Label>Inkorg-e-post</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Vidarebefodra fakturor till denna adress
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={local.inboxEmail}
|
||||
readOnly
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleCopyEmail}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-4 w-4 text-success" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Avbryt
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving && <Loader2 className="h-4 w-4 animate-spin mr-2" />}
|
||||
Spara
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import type { InvoiceInboxItem } from '@/types'
|
||||
import { Upload, Loader2, FileUp, CheckCircle2, AlertCircle } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface InboxUploadZoneProps {
|
||||
onUploadComplete: (item: InvoiceInboxItem | InvoiceInboxItem[]) => void
|
||||
isUploading: boolean
|
||||
setIsUploading: (v: boolean) => void
|
||||
}
|
||||
|
||||
interface FileProgress {
|
||||
name: string
|
||||
status: 'pending' | 'uploading' | 'done' | 'error'
|
||||
error?: string
|
||||
}
|
||||
|
||||
const ACCEPTED_TYPES = ['application/pdf', 'image/jpeg', 'image/png', 'image/webp']
|
||||
const MAX_SIZE = 10 * 1024 * 1024 // 10 MB
|
||||
|
||||
export default function InboxUploadZone({
|
||||
onUploadComplete,
|
||||
isUploading,
|
||||
setIsUploading,
|
||||
}: InboxUploadZoneProps) {
|
||||
const [isDragOver, setIsDragOver] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [fileProgress, setFileProgress] = useState<FileProgress[]>([])
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const uploadFiles = useCallback(
|
||||
async (files: File[]) => {
|
||||
setError(null)
|
||||
|
||||
// Validate all files first
|
||||
const validFiles: File[] = []
|
||||
for (const file of files) {
|
||||
if (!ACCEPTED_TYPES.includes(file.type)) {
|
||||
setError(`${file.name}: filtypen stöds inte. Välj PDF, JPEG, PNG eller WebP.`)
|
||||
return
|
||||
}
|
||||
if (file.size > MAX_SIZE) {
|
||||
setError(`${file.name}: filen är för stor. Max 10 MB.`)
|
||||
return
|
||||
}
|
||||
validFiles.push(file)
|
||||
}
|
||||
|
||||
if (validFiles.length === 0) return
|
||||
|
||||
setIsUploading(true)
|
||||
|
||||
// Show per-file progress for multi-file uploads
|
||||
if (validFiles.length > 1) {
|
||||
setFileProgress(validFiles.map((f) => ({ name: f.name, status: 'uploading' })))
|
||||
}
|
||||
|
||||
try {
|
||||
const formData = new FormData()
|
||||
|
||||
if (validFiles.length === 1) {
|
||||
// Single file: use legacy `file` key for backward compat
|
||||
formData.append('file', validFiles[0])
|
||||
} else {
|
||||
// Multiple files: use `files` key
|
||||
for (const file of validFiles) {
|
||||
formData.append('files', file)
|
||||
}
|
||||
}
|
||||
|
||||
const res = await fetch('/api/extensions/ext/invoice-inbox/inbox', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({ error: 'Uppladdning misslyckades' }))
|
||||
setError(body.error ?? 'Uppladdning misslyckades')
|
||||
if (validFiles.length > 1) {
|
||||
setFileProgress((prev) => prev.map((f) => ({ ...f, status: 'error' as const })))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const body = await res.json()
|
||||
|
||||
if (validFiles.length === 1) {
|
||||
onUploadComplete(body.data)
|
||||
setFileProgress([])
|
||||
} else {
|
||||
// Mark individual files
|
||||
const items: InvoiceInboxItem[] = body.data || []
|
||||
const errors: string[] = body.errors || []
|
||||
|
||||
setFileProgress((prev) =>
|
||||
prev.map((fp, i) => {
|
||||
// Check if this file had an error
|
||||
const errMsg = errors.find((e) => e.startsWith(fp.name))
|
||||
if (errMsg) {
|
||||
return { ...fp, status: 'error' as const, error: errMsg }
|
||||
}
|
||||
return { ...fp, status: 'done' as const }
|
||||
})
|
||||
)
|
||||
|
||||
if (items.length > 0) {
|
||||
onUploadComplete(items)
|
||||
}
|
||||
|
||||
// Clear progress after a delay
|
||||
setTimeout(() => setFileProgress([]), 3000)
|
||||
}
|
||||
} catch {
|
||||
setError('Nätverksfel vid uppladdning')
|
||||
setFileProgress([])
|
||||
} finally {
|
||||
setIsUploading(false)
|
||||
}
|
||||
},
|
||||
[onUploadComplete, setIsUploading]
|
||||
)
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragOver(false)
|
||||
const files = Array.from(e.dataTransfer.files)
|
||||
if (files.length > 0) uploadFiles(files)
|
||||
},
|
||||
[uploadFiles]
|
||||
)
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragOver(true)
|
||||
}, [])
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragOver(false)
|
||||
}, [])
|
||||
|
||||
const handleFileSelect = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files || [])
|
||||
if (files.length > 0) uploadFiles(files)
|
||||
e.target.value = ''
|
||||
},
|
||||
[uploadFiles]
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onClick={() => !isUploading && inputRef.current?.click()}
|
||||
className={cn(
|
||||
'relative flex flex-col items-center justify-center rounded-lg border-2 border-dashed p-6 transition-colors cursor-pointer',
|
||||
isDragOver
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border hover:border-primary/50 hover:bg-accent/30',
|
||||
isUploading && 'pointer-events-none opacity-60'
|
||||
)}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept=".pdf,.jpg,.jpeg,.png,.webp"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleFileSelect}
|
||||
disabled={isUploading}
|
||||
/>
|
||||
|
||||
{isUploading ? (
|
||||
<>
|
||||
<Loader2 className="h-8 w-8 text-primary animate-spin mb-2" />
|
||||
<p className="text-sm text-muted-foreground">Laddar upp och analyserar...</p>
|
||||
</>
|
||||
) : isDragOver ? (
|
||||
<>
|
||||
<FileUp className="h-8 w-8 text-primary mb-2" />
|
||||
<p className="text-sm font-medium text-primary">Släpp filerna här</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="h-8 w-8 text-muted-foreground/60 mb-2" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Dra och släpp fakturor, eller{' '}
|
||||
<span className="font-medium text-primary">välj filer</span>
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground/60 mt-1">
|
||||
PDF, JPEG, PNG eller WebP (max 10 MB per fil)
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{fileProgress.length > 0 && (
|
||||
<div className="mt-3 space-y-1">
|
||||
{fileProgress.map((fp) => (
|
||||
<div key={fp.name} className="flex items-center gap-2 text-sm">
|
||||
{fp.status === 'uploading' && <Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />}
|
||||
{fp.status === 'done' && <CheckCircle2 className="h-3.5 w-3.5 text-success" />}
|
||||
{fp.status === 'error' && <AlertCircle className="h-3.5 w-3.5 text-destructive" />}
|
||||
<span className={cn(
|
||||
'truncate',
|
||||
fp.status === 'error' && 'text-destructive'
|
||||
)}>
|
||||
{fp.name}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-destructive mt-2">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -81,18 +81,14 @@ export default function SIEPreviewStep({
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Start</p>
|
||||
<p className="font-medium">
|
||||
{preview.fiscalYearStart
|
||||
? new Date(preview.fiscalYearStart).toLocaleDateString('sv-SE')
|
||||
: 'Okänt'}
|
||||
{preview.fiscalYearStart ?? 'Okänt'}
|
||||
</p>
|
||||
</div>
|
||||
<ArrowRight className="h-4 w-4 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Slut</p>
|
||||
<p className="font-medium">
|
||||
{preview.fiscalYearEnd
|
||||
? new Date(preview.fiscalYearEnd).toLocaleDateString('sv-SE')
|
||||
: 'Okänt'}
|
||||
{preview.fiscalYearEnd ?? 'Okänt'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { AlertTriangle, ExternalLink, Loader2 } from 'lucide-react'
|
||||
import { SupportLink } from '@/components/ui/support-link'
|
||||
|
||||
export function AccountDangerZone() {
|
||||
const router = useRouter()
|
||||
const [showDialog, setShowDialog] = useState(false)
|
||||
const [confirmText, setConfirmText] = useState('')
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
|
||||
async function handleDelete() {
|
||||
if (confirmText !== 'RADERA') return
|
||||
setIsDeleting(true)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/account/delete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ confirm: 'RADERA' }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const result = await response.json()
|
||||
throw new Error(result.error || 'Kunde inte radera kontot')
|
||||
}
|
||||
|
||||
router.push('/login')
|
||||
} catch {
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="space-y-4 border-t border-border/8 pt-8">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-destructive/80">
|
||||
Radera konto
|
||||
</h2>
|
||||
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4">
|
||||
<div className="flex gap-3">
|
||||
<AlertTriangle className="h-5 w-5 text-destructive shrink-0 mt-0.5" />
|
||||
<div className="space-y-2 text-sm">
|
||||
<p className="font-medium text-destructive">Denna åtgärd kan inte ångras</p>
|
||||
<p className="text-muted-foreground">
|
||||
All din data raderas permanent — bokföring, fakturor, verifikationer, dokument och inställningar.
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
Enligt bokföringslagen (BFL 7 kap. 2§) ska räkenskapsinformation bevaras i 7 år.
|
||||
Du ansvarar själv för att exportera och arkivera din bokföringsdata.
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
Har du frågor?{' '}
|
||||
<SupportLink variant="inline" subject="Fråga om kontoradering" />
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<Button variant="outline" className="w-full sm:w-auto" asChild>
|
||||
<Link href="/reports?type=sie">
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
Exportera bokföringsdata (SIE)
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="w-full sm:w-auto"
|
||||
onClick={() => setShowDialog(true)}
|
||||
>
|
||||
Radera mitt konto
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Dialog open={showDialog} onOpenChange={(open) => {
|
||||
setShowDialog(open)
|
||||
if (!open) setConfirmText('')
|
||||
}}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Radera konto permanent</DialogTitle>
|
||||
<DialogDescription>
|
||||
All din data raderas permanent. Skriv <strong>RADERA</strong> nedan för att bekräfta.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="delete-confirm">Bekräfta genom att skriva RADERA</Label>
|
||||
<Input
|
||||
id="delete-confirm"
|
||||
value={confirmText}
|
||||
onChange={(e) => setConfirmText(e.target.value)}
|
||||
placeholder="RADERA"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => { setShowDialog(false); setConfirmText('') }}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
Avbryt
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDelete}
|
||||
disabled={confirmText !== 'RADERA' || isDeleting}
|
||||
>
|
||||
{isDeleting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Raderar...
|
||||
</>
|
||||
) : (
|
||||
'Radera permanent'
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { BankNameCombobox } from '@/components/settings/BankNameCombobox'
|
||||
import { validateBankgiroNumber, formatBankgiroNumber } from '@/lib/bankgiro/luhn'
|
||||
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
|
||||
import type { CompanySettings } from '@/types'
|
||||
|
||||
interface BankDetailsFormProps {
|
||||
settings: CompanySettings
|
||||
}
|
||||
|
||||
export function BankDetailsForm({ settings }: BankDetailsFormProps) {
|
||||
const [bankgiroError, setBankgiroError] = useState<string | null>(null)
|
||||
const [clearingError, setClearingError] = useState<string | null>(null)
|
||||
const [accountNumberError, setAccountNumberError] = useState<string | null>(null)
|
||||
const hasBankingExtension = ENABLED_EXTENSION_IDS.has('enable-banking')
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Bankuppgifter
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground -mt-2">
|
||||
Visas på dina fakturor
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Bank</Label>
|
||||
<BankNameCombobox
|
||||
defaultValue={settings.bank_name || ''}
|
||||
enableBankingEnabled={hasBankingExtension}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="clearing_number">Clearing</Label>
|
||||
<Input
|
||||
id="clearing_number"
|
||||
name="clearing_number"
|
||||
inputMode="numeric"
|
||||
placeholder="XXXX"
|
||||
maxLength={5}
|
||||
defaultValue={settings.clearing_number || ''}
|
||||
onChange={(e) => {
|
||||
e.target.value = e.target.value.replace(/\D/g, '')
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
const val = e.target.value.trim()
|
||||
if (!val) { setClearingError(null); return }
|
||||
setClearingError(!/^\d{4,5}$/.test(val) ? 'Måste vara 4-5 siffror' : null)
|
||||
}}
|
||||
/>
|
||||
{clearingError && <p className="text-xs text-destructive">{clearingError}</p>}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="account_number">Kontonummer</Label>
|
||||
<Input
|
||||
id="account_number"
|
||||
name="account_number"
|
||||
inputMode="numeric"
|
||||
placeholder="XXXXXXX"
|
||||
maxLength={12}
|
||||
defaultValue={settings.account_number || ''}
|
||||
onChange={(e) => {
|
||||
e.target.value = e.target.value.replace(/\D/g, '')
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
const val = e.target.value.trim()
|
||||
if (!val) { setAccountNumberError(null); return }
|
||||
setAccountNumberError(!/^\d{6,12}$/.test(val) ? 'Måste vara 6-12 siffror' : null)
|
||||
}}
|
||||
/>
|
||||
{accountNumberError && <p className="text-xs text-destructive">{accountNumberError}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-xs space-y-2">
|
||||
<Label htmlFor="bankgiro">Bankgiro</Label>
|
||||
<Input
|
||||
id="bankgiro"
|
||||
name="bankgiro"
|
||||
placeholder="XXX-XXXX"
|
||||
defaultValue={settings.bankgiro || ''}
|
||||
onBlur={(e) => {
|
||||
const val = e.target.value.trim()
|
||||
if (!val) { setBankgiroError(null); return }
|
||||
if (validateBankgiroNumber(val)) {
|
||||
e.target.value = formatBankgiroNumber(val)
|
||||
setBankgiroError(null)
|
||||
} else {
|
||||
setBankgiroError('Ogiltigt bankgironummer')
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{bankgiroError && <p className="text-xs text-destructive">{bankgiroError}</p>}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/** Validate bank fields from FormData. Returns error messages or null. */
|
||||
export function validateBankFields(formData: FormData): { field: string; message: string }[] {
|
||||
const errors: { field: string; message: string }[] = []
|
||||
const clearing = (formData.get('clearing_number') as string || '').trim()
|
||||
const account = (formData.get('account_number') as string || '').trim()
|
||||
const bankgiro = (formData.get('bankgiro') as string || '').trim()
|
||||
|
||||
if (clearing && !/^\d{4,5}$/.test(clearing)) {
|
||||
errors.push({ field: 'clearing_number', message: 'Clearingnummer måste vara 4-5 siffror' })
|
||||
}
|
||||
if (account && !/^\d{6,12}$/.test(account)) {
|
||||
errors.push({ field: 'account_number', message: 'Kontonummer måste vara 6-12 siffror' })
|
||||
}
|
||||
if (bankgiro && !validateBankgiroNumber(bankgiro)) {
|
||||
errors.push({ field: 'bankgiro', message: 'Ogiltigt bankgironummer' })
|
||||
}
|
||||
return errors
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
'use client'
|
||||
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import type { CompanySettings } from '@/types'
|
||||
|
||||
interface CompanyInfoFormProps {
|
||||
settings: CompanySettings
|
||||
}
|
||||
|
||||
export function CompanyInfoForm({ settings }: CompanyInfoFormProps) {
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Företagsuppgifter
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="company_name">Företagsnamn</Label>
|
||||
<Input
|
||||
id="company_name"
|
||||
name="company_name"
|
||||
defaultValue={settings.company_name || ''}
|
||||
disabled={settings.onboarding_complete === true}
|
||||
/>
|
||||
{settings.onboarding_complete && (
|
||||
<p className="text-xs text-muted-foreground">Kan inte ändras efter att kontot skapats</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="org_number">Organisationsnummer</Label>
|
||||
<Input
|
||||
id="org_number"
|
||||
name="org_number"
|
||||
defaultValue={settings.org_number || ''}
|
||||
disabled={settings.onboarding_complete === true}
|
||||
/>
|
||||
{settings.onboarding_complete && (
|
||||
<p className="text-xs text-muted-foreground">Kan inte ändras efter att kontot skapats</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="address_line1">Adress</Label>
|
||||
<Input
|
||||
id="address_line1"
|
||||
name="address_line1"
|
||||
defaultValue={settings.address_line1 || ''}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="postal_code">Postnummer</Label>
|
||||
<Input
|
||||
id="postal_code"
|
||||
name="postal_code"
|
||||
defaultValue={settings.postal_code || ''}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="city">Ort</Label>
|
||||
<Input
|
||||
id="city"
|
||||
name="city"
|
||||
defaultValue={settings.city || ''}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="phone">Telefon</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
name="phone"
|
||||
type="tel"
|
||||
defaultValue={settings.phone || ''}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">E-post</Label>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
defaultValue={settings.email || ''}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="website">Webbplats</Label>
|
||||
<Input
|
||||
id="website"
|
||||
name="website"
|
||||
defaultValue={settings.website || ''}
|
||||
placeholder="https://"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
'use client'
|
||||
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import type { CompanySettings } from '@/types'
|
||||
|
||||
interface InvoiceSettingsFormProps {
|
||||
settings: CompanySettings
|
||||
}
|
||||
|
||||
export function InvoiceSettingsForm({ settings }: InvoiceSettingsFormProps) {
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Fakturainställningar
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 items-end">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invoice_prefix">Fakturaprefix</Label>
|
||||
<Input
|
||||
id="invoice_prefix"
|
||||
name="invoice_prefix"
|
||||
placeholder="t.ex. F-"
|
||||
defaultValue={settings.invoice_prefix || ''}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="next_invoice_number">Nästa fakturanummer</Label>
|
||||
<Input
|
||||
id="next_invoice_number"
|
||||
name="next_invoice_number"
|
||||
type="number"
|
||||
min="1"
|
||||
defaultValue={settings.next_invoice_number || 1}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invoice_default_days">Betalningsvillkor (dagar)</Label>
|
||||
<Input
|
||||
id="invoice_default_days"
|
||||
name="invoice_default_days"
|
||||
type="number"
|
||||
min="0"
|
||||
defaultValue={settings.invoice_default_days || 30}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invoice_default_notes">Standardtext på fakturor</Label>
|
||||
<Textarea
|
||||
id="invoice_default_notes"
|
||||
name="invoice_default_notes"
|
||||
rows={3}
|
||||
placeholder="T.ex. betalningsvillkor, leveransinfo..."
|
||||
defaultValue={settings.invoice_default_notes || ''}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Föreslås automatiskt vid ny faktura.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { Loader2, Upload, Trash2 } from 'lucide-react'
|
||||
|
||||
interface LogoUploadProps {
|
||||
logoUrl: string | null
|
||||
onUpdate: (logoUrl: string | null) => void
|
||||
}
|
||||
|
||||
export function LogoUpload({ logoUrl, onUpdate }: LogoUploadProps) {
|
||||
const { toast } = useToast()
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [preview, setPreview] = useState<string | null>(logoUrl)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
async function handleUpload(file: File) {
|
||||
setIsUploading(true)
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/settings/logo', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result.error || 'Uppladdning misslyckades')
|
||||
}
|
||||
|
||||
setPreview(result.data.logo_url)
|
||||
onUpdate(result.data.logo_url)
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Kunde inte ladda upp',
|
||||
description: error instanceof Error ? error.message : 'Försök igen.',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
|
||||
setIsUploading(false)
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
setIsDeleting(true)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/settings/logo', { method: 'DELETE' })
|
||||
if (!response.ok) throw new Error()
|
||||
|
||||
setPreview(null)
|
||||
onUpdate(null)
|
||||
if (inputRef.current) inputRef.current.value = ''
|
||||
} catch {
|
||||
toast({ title: 'Kunde inte ta bort logotyp', variant: 'destructive' })
|
||||
}
|
||||
|
||||
setIsDeleting(false)
|
||||
}
|
||||
|
||||
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
if (file.size > 2 * 1024 * 1024) {
|
||||
toast({ title: 'Filen är för stor (max 2 MB)', variant: 'destructive' })
|
||||
return
|
||||
}
|
||||
|
||||
handleUpload(file)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Logotyp
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground -mt-2">
|
||||
Visas i sidhuvudet på dina fakturor. Max 2 MB, PNG/JPG/SVG.
|
||||
</p>
|
||||
|
||||
{preview ? (
|
||||
<div className="space-y-3">
|
||||
<div className="inline-block rounded-lg border border-border/60 bg-muted/30 p-4">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={preview}
|
||||
alt="Företagslogotyp"
|
||||
className="max-h-16 max-w-[200px] object-contain"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
disabled={isUploading}
|
||||
>
|
||||
{isUploading ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : <Upload className="mr-2 h-3.5 w-3.5" />}
|
||||
Byt logotyp
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
{isDeleting ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : <Trash2 className="mr-2 h-3.5 w-3.5" />}
|
||||
Ta bort
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
disabled={isUploading}
|
||||
className="flex flex-col items-center justify-center w-full max-w-xs rounded-lg border-2 border-dashed border-border/60 py-8 px-4 text-center transition-colors hover:border-border hover:bg-muted/20 disabled:opacity-50"
|
||||
>
|
||||
{isUploading ? (
|
||||
<Loader2 className="h-6 w-6 text-muted-foreground animate-spin mb-2" />
|
||||
) : (
|
||||
<Upload className="h-6 w-6 text-muted-foreground/50 mb-2" />
|
||||
)}
|
||||
<Label className="text-sm text-muted-foreground cursor-pointer">
|
||||
{isUploading ? 'Laddar upp...' : 'Välj fil eller dra hit'}
|
||||
</Label>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/svg+xml,image/webp"
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback } from 'react'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import type { CompanySettings } from '@/types'
|
||||
|
||||
interface PdfPrintSettingsProps {
|
||||
settings: CompanySettings
|
||||
onUpdate: (updates: Partial<CompanySettings>) => void
|
||||
}
|
||||
|
||||
export function PdfPrintSettings({ settings, onUpdate }: PdfPrintSettingsProps) {
|
||||
const { toast } = useToast()
|
||||
const [lateFeeText, setLateFeeText] = useState(settings.invoice_late_fee_text || '')
|
||||
const [creditTermsText, setCreditTermsText] = useState(settings.invoice_credit_terms_text || '')
|
||||
|
||||
const saveToggle = useCallback(async (field: string, value: boolean) => {
|
||||
try {
|
||||
const response = await fetch('/api/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ [field]: value }),
|
||||
})
|
||||
if (!response.ok) throw new Error()
|
||||
onUpdate({ [field]: value } as Partial<CompanySettings>)
|
||||
} catch {
|
||||
toast({ title: 'Kunde inte spara', variant: 'destructive' })
|
||||
}
|
||||
}, [onUpdate, toast])
|
||||
|
||||
const saveText = useCallback(async (field: string, value: string) => {
|
||||
try {
|
||||
const response = await fetch('/api/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ [field]: value || null }),
|
||||
})
|
||||
if (!response.ok) throw new Error()
|
||||
onUpdate({ [field]: value || null } as Partial<CompanySettings>)
|
||||
} catch {
|
||||
toast({ title: 'Kunde inte spara', variant: 'destructive' })
|
||||
}
|
||||
}, [onUpdate, toast])
|
||||
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Utskrift & PDF
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>Öresavrundning</Label>
|
||||
<p className="text-xs text-muted-foreground">Avrunda fakturatotal till hel krona</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.ore_rounding ?? true}
|
||||
onCheckedChange={(v) => saveToggle('ore_rounding', v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>Visa OCR-referens</Label>
|
||||
<p className="text-xs text-muted-foreground">Visa OCR-nummer på fakturautskrift</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.invoice_show_ocr ?? true}
|
||||
onCheckedChange={(v) => saveToggle('invoice_show_ocr', v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>Visa bankgiro</Label>
|
||||
<p className="text-xs text-muted-foreground">Visa bankgironummer på fakturautskrift</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.invoice_show_bankgiro ?? true}
|
||||
onCheckedChange={(v) => saveToggle('invoice_show_bankgiro', v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>Visa plusgiro</Label>
|
||||
<p className="text-xs text-muted-foreground">Visa plusgironummer på fakturautskrift</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.invoice_show_plusgiro ?? true}
|
||||
onCheckedChange={(v) => saveToggle('invoice_show_plusgiro', v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 pt-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invoice_late_fee_text">Dröjsmålsränta</Label>
|
||||
<Textarea
|
||||
id="invoice_late_fee_text"
|
||||
rows={2}
|
||||
placeholder="T.ex. Vid betalning efter förfallodagen debiteras ränta enligt räntelagen."
|
||||
value={lateFeeText}
|
||||
onChange={(e) => setLateFeeText(e.target.value)}
|
||||
onBlur={() => saveText('invoice_late_fee_text', lateFeeText)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invoice_credit_terms_text">Betalningsvillkor (fotnot)</Label>
|
||||
<Textarea
|
||||
id="invoice_credit_terms_text"
|
||||
rows={2}
|
||||
placeholder="T.ex. Betalning sker till angivet bankgiro."
|
||||
value={creditTermsText}
|
||||
onChange={(e) => setCreditTermsText(e.target.value)}
|
||||
onBlur={() => saveText('invoice_credit_terms_text', creditTermsText)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
'use client'
|
||||
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import type { CompanySettings } from '@/types'
|
||||
|
||||
interface PeriodLockingSettingsProps {
|
||||
settings: CompanySettings
|
||||
}
|
||||
|
||||
export function PeriodLockingSettings({ settings }: PeriodLockingSettingsProps) {
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Periodlåsning
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bookkeeping_locked_through">Bokföring låst t.o.m.</Label>
|
||||
<Input
|
||||
id="bookkeeping_locked_through"
|
||||
name="bookkeeping_locked_through"
|
||||
type="date"
|
||||
defaultValue={settings.bookkeeping_locked_through || ''}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Verifikationer med datum före detta datum kan inte skapas eller ändras.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="auto_lock_period_days">Automatisk låsning efter</Label>
|
||||
<Select
|
||||
name="auto_lock_period_days"
|
||||
defaultValue={settings.auto_lock_period_days?.toString() || 'none'}
|
||||
>
|
||||
<SelectTrigger id="auto_lock_period_days">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">Ingen automatisk låsning</SelectItem>
|
||||
<SelectItem value="30">30 dagar efter periodens slut</SelectItem>
|
||||
<SelectItem value="60">60 dagar efter periodens slut</SelectItem>
|
||||
<SelectItem value="90">90 dagar efter periodens slut</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Låser automatiskt perioder efter valt antal dagar.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -254,7 +254,7 @@ export function SecuritySettings() {
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => router.push(`/mfa/enroll?returnTo=${encodeURIComponent('/settings?tab=security')}`)}
|
||||
onClick={() => router.push(`/mfa/enroll?returnTo=${encodeURIComponent('/settings/account')}`)}
|
||||
>
|
||||
<ShieldCheck className="mr-2 h-4 w-4" />
|
||||
Aktivera 2FA
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Loader2, Check } from 'lucide-react'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
|
||||
interface SettingsFormWrapperProps {
|
||||
children: React.ReactNode
|
||||
onSave?: (formData: FormData) => Record<string, unknown>
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function SettingsFormWrapper({ children, onSave, className }: SettingsFormWrapperProps) {
|
||||
const { toast } = useToast()
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [saved, setSaved] = useState(false)
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleSubmit = useCallback(async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
if (!onSave) return
|
||||
|
||||
const formData = new FormData(e.currentTarget)
|
||||
const updates = onSave(formData)
|
||||
|
||||
if (!updates || Object.keys(updates).length === 0) return
|
||||
|
||||
setIsSaving(true)
|
||||
setSaved(false)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updates),
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result.error || 'Kunde inte spara inställningar')
|
||||
}
|
||||
|
||||
setSaved(true)
|
||||
timerRef.current = setTimeout(() => setSaved(false), 2000)
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Kunde inte spara',
|
||||
description: error instanceof Error ? error.message : 'Försök igen.',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
|
||||
setIsSaving(false)
|
||||
}, [onSave, toast])
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className={className}>
|
||||
{children}
|
||||
|
||||
<div className="flex items-center justify-end gap-3 mt-8">
|
||||
{saved && (
|
||||
<span className="flex items-center gap-1.5 text-sm text-muted-foreground animate-in fade-in duration-200">
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
Sparat
|
||||
</span>
|
||||
)}
|
||||
<Button type="submit" disabled={isSaving} size="sm">
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
|
||||
Sparar...
|
||||
</>
|
||||
) : (
|
||||
'Spara ändringar'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export function SettingsLoadingSkeleton() {
|
||||
return (
|
||||
<div className="space-y-8 animate-in fade-in duration-300">
|
||||
{[1, 2].map(i => (
|
||||
<div key={i} className="space-y-4">
|
||||
<div className="h-3.5 bg-muted rounded w-24 animate-pulse" />
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<div className="h-3.5 bg-muted rounded w-20 animate-pulse" />
|
||||
<div className="h-10 bg-muted rounded animate-pulse" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="h-3.5 bg-muted rounded w-28 animate-pulse" />
|
||||
<div className="h-10 bg-muted rounded animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="h-3.5 bg-muted rounded w-16 animate-pulse" />
|
||||
<div className="h-10 bg-muted rounded animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
|
||||
|
||||
interface NavItem {
|
||||
href: string
|
||||
label: string
|
||||
show: boolean
|
||||
}
|
||||
|
||||
export function SettingsNav({ isSandbox }: { isSandbox?: boolean }) {
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
const { company, isTeamMember } = useCompany()
|
||||
|
||||
const hasCompany = !!company
|
||||
const hasBankingExtension = ENABLED_EXTENSION_IDS.has('enable-banking')
|
||||
const hasMcpExtension = ENABLED_EXTENSION_IDS.has('mcp-server')
|
||||
|
||||
const items: NavItem[] = [
|
||||
{ href: '/settings/company', label: 'Företag', show: hasCompany },
|
||||
{ href: '/settings/invoicing', label: 'Fakturering', show: hasCompany },
|
||||
{ href: '/settings/bookkeeping', label: 'Bokföring', show: hasCompany },
|
||||
{ href: '/settings/tax', label: 'Skatt', show: hasCompany },
|
||||
{ href: '/settings/team', label: 'Lag', show: isTeamMember },
|
||||
{ href: '/settings/banking', label: 'Bank (PSD2)', show: hasCompany && !isSandbox && hasBankingExtension },
|
||||
{ href: '/settings/templates', label: 'Mallar', show: hasCompany },
|
||||
{ href: '/settings/account', label: 'Konto', show: true },
|
||||
{ href: '/settings/api', label: 'API', show: hasCompany && hasMcpExtension },
|
||||
].filter(item => item.show)
|
||||
|
||||
const activeHref = items.find(item => pathname.startsWith(item.href))?.href || items[0]?.href
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile: select dropdown */}
|
||||
<div className="sm:hidden">
|
||||
<Select value={activeHref} onValueChange={(v) => router.push(v)}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{items.map(item => (
|
||||
<SelectItem key={item.href} value={item.href}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Desktop: horizontal tabs with bottom border */}
|
||||
<nav
|
||||
className="hidden sm:block overflow-x-auto scrollbar-none border-b border-border"
|
||||
aria-label="Inställningar"
|
||||
>
|
||||
<ul className="flex gap-0 -mb-px">
|
||||
{items.map(item => {
|
||||
const isActive = pathname.startsWith(item.href)
|
||||
return (
|
||||
<li key={item.href}>
|
||||
<Link
|
||||
href={item.href}
|
||||
className={`block whitespace-nowrap px-3 py-2 text-sm transition-colors border-b-2 ${
|
||||
isActive
|
||||
? 'font-medium text-foreground border-foreground'
|
||||
: 'text-muted-foreground border-transparent hover:text-foreground hover:border-border'
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// Keep old name as alias for backward compat during transition
|
||||
export const SettingsSidebar = SettingsNav
|
||||
@@ -0,0 +1,34 @@
|
||||
'use client'
|
||||
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import type { CompanySettings } from '@/types'
|
||||
|
||||
interface TaxSettingsFormProps {
|
||||
settings: CompanySettings
|
||||
}
|
||||
|
||||
export function TaxSettingsForm({ settings }: TaxSettingsFormProps) {
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Preliminärskatt
|
||||
</h2>
|
||||
|
||||
<div className="max-w-xs space-y-2">
|
||||
<Label htmlFor="preliminary_tax_monthly">
|
||||
Månatlig preliminärskatt (F-skatt)
|
||||
</Label>
|
||||
<Input
|
||||
id="preliminary_tax_monthly"
|
||||
name="preliminary_tax_monthly"
|
||||
type="number"
|
||||
defaultValue={settings.preliminary_tax_monthly || ''}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Belopp i SEK som betalas varje månad.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import { Label } from '@/components/ui/label'
|
||||
|
||||
interface VoucherSeries {
|
||||
voucher_series: string
|
||||
last_number: number
|
||||
fiscal_period_id: string
|
||||
}
|
||||
|
||||
export function VoucherSeriesManager() {
|
||||
const { company } = useCompany()
|
||||
const [series, setSeries] = useState<VoucherSeries[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
const fetchSeries = useCallback(async () => {
|
||||
if (!company?.id) return
|
||||
const supabase = createClient()
|
||||
const { data } = await supabase
|
||||
.from('voucher_sequences')
|
||||
.select('voucher_series, last_number, fiscal_period_id')
|
||||
.eq('company_id', company.id)
|
||||
.order('voucher_series')
|
||||
setSeries(data || [])
|
||||
setIsLoading(false)
|
||||
}, [company?.id])
|
||||
|
||||
useEffect(() => { fetchSeries() }, [fetchSeries])
|
||||
|
||||
// Group by series letter, show the highest last_number
|
||||
const grouped = series.reduce<Record<string, number>>((acc, s) => {
|
||||
const existing = acc[s.voucher_series] || 0
|
||||
acc[s.voucher_series] = Math.max(existing, s.last_number)
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
const seriesEntries = Object.entries(grouped).sort(([a], [b]) => a.localeCompare(b))
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Verifikationsserier
|
||||
</h2>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 bg-muted rounded w-32 animate-pulse" />
|
||||
<div className="h-4 bg-muted rounded w-24 animate-pulse" />
|
||||
</div>
|
||||
) : seriesEntries.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Inga verifikationsserier ännu. Serie A skapas automatiskt vid första verifikationen.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground">Aktiva serier</Label>
|
||||
<div className="divide-y divide-border/8">
|
||||
{seriesEntries.map(([letter, lastNum]) => (
|
||||
<div key={letter} className="flex items-center justify-between py-2">
|
||||
<span className="text-sm font-medium tabular-nums">Serie {letter}</span>
|
||||
<span className="text-sm text-muted-foreground tabular-nums">
|
||||
Senaste nr: {lastNum}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import type { CompanySettings } from '@/types'
|
||||
|
||||
export function useSettings() {
|
||||
const router = useRouter()
|
||||
const { company } = useCompany()
|
||||
const [settings, setSettings] = useState<CompanySettings | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
const fetchSettings = useCallback(async () => {
|
||||
const supabase = createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) { router.push('/login'); return }
|
||||
|
||||
if (company?.id) {
|
||||
const { data } = await supabase
|
||||
.from('company_settings')
|
||||
.select('*')
|
||||
.eq('company_id', company.id)
|
||||
.single()
|
||||
setSettings(data)
|
||||
}
|
||||
|
||||
setIsLoading(false)
|
||||
}, [company?.id, router])
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings()
|
||||
}, [fetchSettings])
|
||||
|
||||
const updateSettings = useCallback((updates: Partial<CompanySettings>) => {
|
||||
setSettings(prev => prev ? { ...prev, ...updates } as CompanySettings : null)
|
||||
}, [])
|
||||
|
||||
return { settings, isLoading, updateSettings, refetch: fetchSettings }
|
||||
}
|
||||
@@ -21,7 +21,6 @@ import DescribeTransactionDialog from './DescribeTransactionDialog'
|
||||
import { formatAccountWithName } from '@/lib/bookkeeping/client-account-names'
|
||||
import type { TransactionCategory, VatTreatment, BASAccount, EntityType } from '@/types'
|
||||
import type { SuggestedCategory, SuggestedTemplate } from '@/lib/transactions/category-suggestions'
|
||||
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
|
||||
import type { TransactionWithInvoice, CategorizeHandler, MatchInvoiceHandler } from './transaction-types'
|
||||
import { EXPENSE_CATEGORIES, INCOME_CATEGORIES, VAT_TREATMENT_OPTIONS } from './transaction-types'
|
||||
|
||||
@@ -848,18 +847,16 @@ export default function SwipeCategorizationView({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Describe transaction button — only when AI categorization is enabled */}
|
||||
{ENABLED_EXTENSION_IDS.has('ai-categorization') && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => setShowDescribeDialog(true)}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
<MessageSquareText className="mr-2 h-4 w-4" />
|
||||
Beskriv transaktion...
|
||||
</Button>
|
||||
)}
|
||||
{/* Describe transaction button */}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => setShowDescribeDialog(true)}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
<MessageSquareText className="mr-2 h-4 w-4" />
|
||||
Beskriv transaktion...
|
||||
</Button>
|
||||
|
||||
{/* Categorization button */}
|
||||
<Button
|
||||
|
||||
Reference in New Issue
Block a user