Added extension functionality

This commit is contained in:
Emil
2026-02-21 11:39:45 +01:00
parent 2f83f232c6
commit 026497ed75
132 changed files with 3533 additions and 60 deletions
@@ -0,0 +1,336 @@
'use client'
import { useState, useRef, useCallback, useEffect } from 'react'
import { Button } from '@/components/ui/button'
import { Camera, X, RotateCcw, Check, Loader2, SunMedium, Move, Focus } from 'lucide-react'
import type { CameraQualityFeedback } from '@/types'
interface ReceiptCameraProps {
onCapture: (imageData: string, mimeType: string) => Promise<void>
onClose: () => void
}
export default function ReceiptCamera({ onCapture, onClose }: ReceiptCameraProps) {
const videoRef = useRef<HTMLVideoElement>(null)
const canvasRef = useRef<HTMLCanvasElement>(null)
const streamRef = useRef<MediaStream | null>(null)
const [isStreaming, setIsStreaming] = useState(false)
const [capturedImage, setCapturedImage] = useState<string | null>(null)
const [isProcessing, setIsProcessing] = useState(false)
const [error, setError] = useState<string | null>(null)
const [quality, setQuality] = useState<CameraQualityFeedback>({
lightingOk: false,
distanceOk: false,
focusOk: false,
readyToCapture: false,
})
// Start camera stream
const startCamera = useCallback(async () => {
try {
setError(null)
const constraints: MediaStreamConstraints = {
video: {
facingMode: 'environment', // Use back camera on mobile
width: { ideal: 1920 },
height: { ideal: 1080 },
},
}
const stream = await navigator.mediaDevices.getUserMedia(constraints)
streamRef.current = stream
if (videoRef.current) {
videoRef.current.srcObject = stream
try {
await videoRef.current.play()
setIsStreaming(true)
} catch (playErr) {
// Ignore AbortError - this happens when stream is interrupted (e.g., component unmount)
if (playErr instanceof Error && playErr.name === 'AbortError') {
console.log('Video play interrupted - this is normal during navigation')
return
}
throw playErr
}
}
} catch (err) {
console.error('Camera access error:', err)
// Don't show error for AbortError
if (err instanceof Error && err.name === 'AbortError') return
setError('Kunde inte komma åt kameran. Kontrollera att du har gett tillåtelse.')
}
}, [])
// Stop camera stream
const stopCamera = useCallback(() => {
if (streamRef.current) {
streamRef.current.getTracks().forEach((track) => track.stop())
streamRef.current = null
}
setIsStreaming(false)
}, [])
// Analyze video frame for quality feedback
const analyzeQuality = useCallback(() => {
if (!videoRef.current || !canvasRef.current || !isStreaming) return
const video = videoRef.current
const canvas = canvasRef.current
const ctx = canvas.getContext('2d')
if (!ctx) return
// Set canvas size to match video
canvas.width = video.videoWidth / 4 // Downsample for analysis
canvas.height = video.videoHeight / 4
// Draw current frame
ctx.drawImage(video, 0, 0, canvas.width, canvas.height)
// Get image data for analysis
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
const data = imageData.data
// Calculate average brightness
let totalBrightness = 0
for (let i = 0; i < data.length; i += 4) {
const r = data[i]
const g = data[i + 1]
const b = data[i + 2]
totalBrightness += (r + g + b) / 3
}
const avgBrightness = totalBrightness / (data.length / 4)
// Calculate contrast (standard deviation of brightness)
let sumSquares = 0
for (let i = 0; i < data.length; i += 4) {
const r = data[i]
const g = data[i + 1]
const b = data[i + 2]
const brightness = (r + g + b) / 3
sumSquares += Math.pow(brightness - avgBrightness, 2)
}
const contrast = Math.sqrt(sumSquares / (data.length / 4))
// Quality assessment
const lightingOk = avgBrightness > 80 && avgBrightness < 200
const focusOk = contrast > 30 // Higher contrast suggests better focus
const distanceOk = true // Would need edge detection for proper distance check
const newQuality: CameraQualityFeedback = {
lightingOk,
distanceOk,
focusOk,
readyToCapture: lightingOk && focusOk && distanceOk,
}
if (!lightingOk) {
newQuality.message = avgBrightness < 80 ? 'För mörkt - öka ljuset' : 'För ljust - minska ljuset'
} else if (!focusOk) {
newQuality.message = 'Försök hålla kameran stilla'
}
setQuality(newQuality)
}, [isStreaming])
// Quality analysis loop
useEffect(() => {
if (!isStreaming) return
const interval = setInterval(analyzeQuality, 500)
return () => clearInterval(interval)
}, [isStreaming, analyzeQuality])
// Start camera on mount
useEffect(() => {
startCamera()
return () => stopCamera()
}, [startCamera, stopCamera])
// Capture image
const captureImage = useCallback(() => {
if (!videoRef.current || !canvasRef.current) return
const video = videoRef.current
const canvas = canvasRef.current
const ctx = canvas.getContext('2d')
if (!ctx) return
// Set canvas to full resolution
canvas.width = video.videoWidth
canvas.height = video.videoHeight
// Draw the current video frame
ctx.drawImage(video, 0, 0)
// Convert to base64
const imageData = canvas.toDataURL('image/jpeg', 0.9)
setCapturedImage(imageData)
stopCamera()
}, [stopCamera])
// Retake photo
const retake = useCallback(() => {
setCapturedImage(null)
startCamera()
}, [startCamera])
// Confirm and upload
const confirmCapture = async () => {
if (!capturedImage) return
setIsProcessing(true)
try {
// Extract base64 data (remove data:image/jpeg;base64, prefix)
const base64Data = capturedImage.split(',')[1]
await onCapture(base64Data, 'image/jpeg')
} catch (err) {
console.error('Upload error:', err)
setError('Kunde inte ladda upp bilden. Försök igen.')
setIsProcessing(false)
}
}
// Render captured image review
if (capturedImage) {
return (
<div className="fixed inset-0 bg-black z-[100] overflow-hidden">
{/* Header - fixed at top */}
<div className="absolute top-0 left-0 right-0 z-20 flex items-center justify-between p-4 bg-gradient-to-b from-black/70 to-transparent">
<Button variant="ghost" size="icon" onClick={retake} disabled={isProcessing}>
<RotateCcw className="h-5 w-5 text-white" />
</Button>
<h2 className="text-white font-medium">Granska bild</h2>
<Button variant="ghost" size="icon" onClick={onClose} disabled={isProcessing}>
<X className="h-5 w-5 text-white" />
</Button>
</div>
{/* Image preview - centered */}
<div className="absolute inset-0 flex items-center justify-center p-4 pt-20 pb-32">
<img
src={capturedImage}
alt="Captured receipt"
className="max-h-full max-w-full object-contain rounded-lg"
/>
</div>
{/* Action buttons - fixed at bottom */}
<div
className="absolute bottom-0 left-0 right-0 z-20 flex gap-4 justify-center bg-gradient-to-t from-black/70 to-transparent pt-8 pb-8 px-4"
style={{ paddingBottom: 'max(2rem, env(safe-area-inset-bottom, 2rem))' }}
>
<Button variant="outline" onClick={retake} disabled={isProcessing} className="bg-white/10 border-white text-white hover:bg-white/20">
<RotateCcw className="mr-2 h-4 w-4" />
Ta om
</Button>
<Button onClick={confirmCapture} disabled={isProcessing} className="bg-white text-black hover:bg-white/90">
{isProcessing ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Analyserar...
</>
) : (
<>
<Check className="mr-2 h-4 w-4" />
Använd bild
</>
)}
</Button>
</div>
</div>
)
}
// Render camera view
return (
<div className="fixed inset-0 bg-black z-[100] overflow-hidden">
{/* Header - fixed at top */}
<div className="absolute top-0 left-0 right-0 z-20 flex items-center justify-between p-4 bg-gradient-to-b from-black/70 to-transparent">
<div className="w-10" />
<h2 className="text-white font-medium">Skanna kvitto</h2>
<Button variant="ghost" size="icon" onClick={onClose}>
<X className="h-5 w-5 text-white" />
</Button>
</div>
{/* Video preview - fills the screen */}
<video
ref={videoRef}
autoPlay
playsInline
muted
className="absolute inset-0 h-full w-full object-cover"
/>
{/* Quality feedback overlay */}
{isStreaming && (
<div className="absolute bottom-36 left-0 right-0 flex justify-center z-10">
<div className="bg-black/70 rounded-full px-4 py-2 flex items-center gap-4">
<div className={`flex items-center gap-1 ${quality.lightingOk ? 'text-green-400' : 'text-yellow-400'}`}>
<SunMedium className="h-4 w-4" />
<span className="text-xs">Ljus</span>
</div>
<div className={`flex items-center gap-1 ${quality.distanceOk ? 'text-green-400' : 'text-yellow-400'}`}>
<Move className="h-4 w-4" />
<span className="text-xs">Avstånd</span>
</div>
<div className={`flex items-center gap-1 ${quality.focusOk ? 'text-green-400' : 'text-yellow-400'}`}>
<Focus className="h-4 w-4" />
<span className="text-xs">Fokus</span>
</div>
</div>
</div>
)}
{/* Quality message */}
{quality.message && (
<div className="absolute top-20 left-0 right-0 flex justify-center z-10">
<div className="bg-warning text-warning-foreground px-4 py-2 rounded-full text-sm font-medium">
{quality.message}
</div>
</div>
)}
{/* Error message */}
{error && (
<div className="absolute inset-0 flex items-center justify-center bg-black/80 z-30">
<div className="text-center p-4">
<p className="text-white mb-4">{error}</p>
<Button onClick={startCamera}>Försök igen</Button>
</div>
</div>
)}
{/* Guide overlay */}
{isStreaming && (
<div className="absolute inset-0 pointer-events-none z-10">
<div className="absolute top-20 left-8 right-8 bottom-36 border-2 border-white/30 rounded-lg">
<div className="absolute top-0 left-0 w-8 h-8 border-t-2 border-l-2 border-white rounded-tl-lg" />
<div className="absolute top-0 right-0 w-8 h-8 border-t-2 border-r-2 border-white rounded-tr-lg" />
<div className="absolute bottom-0 left-0 w-8 h-8 border-b-2 border-l-2 border-white rounded-bl-lg" />
<div className="absolute bottom-0 right-0 w-8 h-8 border-b-2 border-r-2 border-white rounded-br-lg" />
</div>
</div>
)}
{/* Capture button - fixed at bottom */}
<div
className="absolute bottom-0 left-0 right-0 z-20 flex justify-center bg-gradient-to-t from-black/70 to-transparent pt-8 pb-8"
style={{ paddingBottom: 'max(2rem, env(safe-area-inset-bottom, 2rem))' }}
>
<button
onClick={captureImage}
disabled={!isStreaming}
className="h-20 w-20 rounded-full border-4 border-white flex items-center justify-center bg-white/20 active:bg-white/40 disabled:opacity-50 transition-colors shadow-2xl backdrop-blur-sm"
>
<Camera className="h-8 w-8 text-white" />
</button>
</div>
{/* Hidden canvas for image processing */}
<canvas ref={canvasRef} className="hidden" />
</div>
)
}
@@ -0,0 +1,169 @@
'use client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Flame, Receipt, CreditCard, Camera, ArrowRight } from 'lucide-react'
import type { ReceiptQueueSummary } from '@/types'
interface ReceiptDashboardProps {
summary: ReceiptQueueSummary
onScanReceipt: () => void
onViewReceiptQueue: () => void
onViewTransactionQueue: () => void
}
export default function ReceiptDashboard({
summary,
onScanReceipt,
onViewReceiptQueue,
onViewTransactionQueue,
}: ReceiptDashboardProps) {
const hasWork =
summary.unmatched_receipts_count > 0 ||
summary.unmatched_transactions_count > 0 ||
summary.pending_review_count > 0
return (
<div className="space-y-4">
{/* Quick actions */}
<div className="grid grid-cols-1 gap-3">
<Button
variant="default"
className="h-auto py-4 flex-col gap-2"
onClick={onScanReceipt}
>
<Camera className="h-6 w-6" />
<span>Skanna kvitto</span>
</Button>
</div>
{/* Streak counter */}
{summary.streak_count > 0 && (
<Card>
<CardContent className="pt-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="h-12 w-12 rounded-full bg-muted flex items-center justify-center">
<Flame className="h-6 w-6 text-muted-foreground" />
</div>
<div>
<p className="text-2xl font-bold">{summary.streak_count}</p>
<p className="text-sm text-muted-foreground">dagars streak!</p>
</div>
</div>
<p className="text-xs text-muted-foreground max-w-24 text-right">
Fortsätt kategorisera varje dag
</p>
</div>
</CardContent>
</Card>
)}
{/* Queue cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{/* Pending review */}
{summary.pending_review_count > 0 && (
<Card
className="cursor-pointer hover:border-primary/50 transition-colors"
onClick={onViewReceiptQueue}
>
<CardContent className="pt-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-full bg-muted flex items-center justify-center">
<Receipt className="h-5 w-5 text-muted-foreground" />
</div>
<div>
<p className="font-medium">Att granska</p>
<p className="text-sm text-muted-foreground">
{summary.pending_review_count} kvitton
</p>
</div>
</div>
<Badge variant="secondary">{summary.pending_review_count}</Badge>
</div>
</CardContent>
</Card>
)}
{/* Unmatched receipts */}
{summary.unmatched_receipts_count > 0 && (
<Card
className="cursor-pointer hover:border-primary/50 transition-colors"
onClick={onViewReceiptQueue}
>
<CardContent className="pt-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-full bg-primary/10 flex items-center justify-center">
<Receipt className="h-5 w-5 text-primary" />
</div>
<div>
<p className="font-medium">Kvitton utan transaktion</p>
<p className="text-sm text-muted-foreground">
{summary.unmatched_receipts_count} st
</p>
</div>
</div>
<ArrowRight className="h-5 w-5 text-muted-foreground" />
</div>
</CardContent>
</Card>
)}
{/* Unmatched transactions */}
{summary.unmatched_transactions_count > 0 && (
<Card
className="cursor-pointer hover:border-primary/50 transition-colors"
onClick={onViewTransactionQueue}
>
<CardContent className="pt-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-full bg-muted flex items-center justify-center">
<CreditCard className="h-5 w-5 text-muted-foreground" />
</div>
<div>
<p className="font-medium">Transaktioner utan kvitto</p>
<p className="text-sm text-muted-foreground">
{summary.unmatched_transactions_count} st
</p>
</div>
</div>
<ArrowRight className="h-5 w-5 text-muted-foreground" />
</div>
</CardContent>
</Card>
)}
</div>
{/* All done state */}
{!hasWork && (
<Card className="bg-success/5 border-success/20">
<CardContent className="pt-4 text-center py-8">
<div className="h-16 w-16 rounded-full bg-success/10 flex items-center justify-center mx-auto mb-4">
<svg
className="h-8 w-8 text-success"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
</div>
<h3 className="font-semibold text-lg">Allt är uppdaterat!</h3>
<p className="text-sm text-muted-foreground mt-1">
Inga kvitton eller transaktioner att granska
</p>
</CardContent>
</Card>
)}
</div>
)
}
@@ -0,0 +1,99 @@
'use client'
import { Switch } from '@/components/ui/switch'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Badge } from '@/components/ui/badge'
import { formatCurrency } from '@/lib/utils'
import { getExpenseCategories, CATEGORY_LABELS } from '../lib/receipt-categorizer'
import type { ReceiptLineItem, TransactionCategory } from '@/types'
interface ReceiptLineItemRowProps {
item: ReceiptLineItem
onToggleBusiness: (id: string, isBusiness: boolean) => void
onCategoryChange: (id: string, category: TransactionCategory) => void
disabled?: boolean
}
export default function ReceiptLineItemRow({
item,
onToggleBusiness,
onCategoryChange,
disabled = false,
}: ReceiptLineItemRowProps) {
const expenseCategories = getExpenseCategories()
const isBusiness = item.is_business === true
return (
<div className="flex flex-col gap-2 p-3 border rounded-lg bg-card">
{/* Description and amount */}
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<p className="font-medium text-sm truncate">{item.description}</p>
{item.quantity !== 1 && (
<p className="text-xs text-muted-foreground">
{item.quantity} x {item.unit_price ? formatCurrency(item.unit_price, 'SEK') : '—'}
</p>
)}
</div>
<div className="text-right flex-shrink-0">
<p className="font-semibold">{formatCurrency(item.line_total, 'SEK')}</p>
{item.vat_rate && (
<p className="text-xs text-muted-foreground">{item.vat_rate}% moms</p>
)}
</div>
</div>
{/* Classification controls */}
<div className="flex items-center gap-3 pt-2 border-t">
{/* Business/Private toggle */}
<div className="flex items-center gap-2">
<span className={`text-xs ${!isBusiness ? 'text-muted-foreground' : 'font-medium'}`}>
Privat
</span>
<Switch
checked={isBusiness}
onCheckedChange={(checked) => onToggleBusiness(item.id, checked)}
disabled={disabled}
/>
<span className={`text-xs ${isBusiness ? 'font-medium' : 'text-muted-foreground'}`}>
Företag
</span>
</div>
{/* Category selector (only for business) */}
{isBusiness && (
<Select
value={item.category || undefined}
onValueChange={(value) => onCategoryChange(item.id, value as TransactionCategory)}
disabled={disabled}
>
<SelectTrigger className="h-8 w-32 text-xs">
<SelectValue placeholder="Kategori" />
</SelectTrigger>
<SelectContent>
{expenseCategories.map((cat) => (
<SelectItem key={cat.value} value={cat.value}>
{cat.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{/* Confidence badge */}
{item.extraction_confidence !== null && item.extraction_confidence < 0.8 && (
<Badge variant="outline" className="text-xs">
{Math.round((item.extraction_confidence || 0) * 100)}%
</Badge>
)}
{/* AI suggestion indicator */}
{item.suggested_category && item.is_business === null && (
<Badge variant="secondary" className="text-xs">
Förslag: {CATEGORY_LABELS[`expense_${item.suggested_category}` as TransactionCategory] || item.suggested_category}
</Badge>
)}
</div>
</div>
)
}
@@ -0,0 +1,418 @@
'use client'
import { useState, useMemo } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
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 { Textarea } from '@/components/ui/textarea'
import {
AlertTriangle,
Check,
X,
Building,
User,
Wine,
Utensils,
Globe,
ChevronUp,
ChevronDown,
Loader2,
} from 'lucide-react'
import { formatCurrency, formatDate } from '@/lib/utils'
import { calculateReceiptSplit, getDefaultClassification } from '../lib/receipt-categorizer'
import { calculateRepresentationLimits } from '../lib/receipt-utils'
import ReceiptLineItemRow from './ReceiptLineItemRow'
import type { Receipt, ReceiptLineItem, TransactionCategory, ConfirmLineItemInput } from '@/types'
interface ReceiptReviewViewProps {
receipt: Receipt & { line_items: ReceiptLineItem[] }
onConfirm: (data: {
line_items: ConfirmLineItemInput[]
representation_persons?: number
representation_purpose?: string
}) => Promise<void>
onCancel: () => void
onFindMatches?: () => void
}
export default function ReceiptReviewView({
receipt,
onConfirm,
onCancel,
onFindMatches,
}: ReceiptReviewViewProps) {
// Initialize line items with their current state
const [lineItems, setLineItems] = useState<ReceiptLineItem[]>(receipt.line_items || [])
const [isProcessing, setIsProcessing] = useState(false)
const [showImage, setShowImage] = useState(false)
// Restaurant representation state
const [representationPersons, setRepresentationPersons] = useState<number | undefined>(
receipt.representation_persons || undefined
)
const [representationPurpose, setRepresentationPurpose] = useState<string>(
receipt.representation_purpose || ''
)
// Get default classification info
const defaultClassification = getDefaultClassification(
receipt.is_restaurant,
receipt.is_systembolaget
)
// Calculate split summary
const splitSummary = useMemo(() => {
return calculateReceiptSplit(lineItems.map(item => ({
lineTotal: item.line_total,
is_business: item.is_business,
})))
}, [lineItems])
// Calculate representation limits if restaurant
const representationCalc = useMemo(() => {
if (!receipt.is_restaurant || !representationPersons || !receipt.total_amount) return null
return calculateRepresentationLimits(receipt.total_amount, representationPersons)
}, [receipt.is_restaurant, receipt.total_amount, representationPersons])
// Toggle business status for a line item
const handleToggleBusiness = (id: string, isBusiness: boolean) => {
setLineItems((items) =>
items.map((item) =>
item.id === id
? { ...item, is_business: isBusiness, category: isBusiness ? item.category : null }
: item
)
)
}
// Change category for a line item
const handleCategoryChange = (id: string, category: TransactionCategory) => {
setLineItems((items) =>
items.map((item) => (item.id === id ? { ...item, category } : item))
)
}
// Mark all as business
const handleAllBusiness = () => {
setLineItems((items) =>
items.map((item) => ({
...item,
is_business: true,
category: item.category || 'expense_other',
}))
)
}
// Mark all as private
const handleAllPrivate = () => {
setLineItems((items) =>
items.map((item) => ({
...item,
is_business: false,
category: null,
}))
)
}
// Confirm and save
const handleConfirm = async () => {
// Validate restaurant representation
if (receipt.is_restaurant && splitSummary.businessTotal > 0) {
if (!representationPersons || representationPersons < 1) {
alert('Ange antal personer för restaurangrepresentation')
return
}
if (!representationPurpose.trim()) {
alert('Ange syfte för restaurangrepresentation')
return
}
}
setIsProcessing(true)
try {
await onConfirm({
line_items: lineItems.map((item) => ({
id: item.id,
is_business: item.is_business ?? false,
category: item.category || undefined,
bas_account: item.bas_account || undefined,
})),
representation_persons: representationPersons,
representation_purpose: representationPurpose.trim() || undefined,
})
} catch (error) {
console.error('Confirm error:', error)
} finally {
setIsProcessing(false)
}
}
return (
<div className="fixed inset-0 bg-background z-50 flex flex-col overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b flex-shrink-0">
<Button variant="ghost" size="icon" onClick={onCancel} disabled={isProcessing} aria-label="Avbryt">
<X className="h-5 w-5" />
</Button>
<h1 className="font-semibold">Granska kvitto</h1>
<div className="w-10" />
</div>
{/* Content */}
<div className="flex-1 overflow-auto p-4 space-y-4">
{/* Merchant and total card */}
<Card>
<CardContent className="pt-4">
<div className="flex items-start justify-between">
<div>
<h2 className="font-semibold text-lg">{receipt.merchant_name || 'Okänd handlare'}</h2>
{receipt.receipt_date && (
<p className="text-sm text-muted-foreground">{formatDate(receipt.receipt_date)}</p>
)}
</div>
<div className="text-right">
<p className="text-2xl font-bold">
{formatCurrency(receipt.total_amount || 0, receipt.currency)}
</p>
{receipt.vat_amount && (
<p className="text-sm text-muted-foreground">
varav moms {formatCurrency(receipt.vat_amount, receipt.currency)}
</p>
)}
</div>
</div>
{/* Special flags */}
<div className="flex flex-wrap gap-2 mt-3">
{receipt.is_restaurant && (
<Badge variant="secondary">
<Utensils className="mr-1 h-3 w-3" /> Restaurang
</Badge>
)}
{receipt.is_systembolaget && (
<Badge variant="secondary">
<Wine className="mr-1 h-3 w-3" /> Systembolaget
</Badge>
)}
{receipt.is_foreign_merchant && (
<Badge variant="secondary">
<Globe className="mr-1 h-3 w-3" /> Utländskt
</Badge>
)}
{receipt.extraction_confidence && (
<Badge
variant={receipt.extraction_confidence > 0.8 ? 'outline' : receipt.extraction_confidence > 0.5 ? 'warning' : 'destructive'}
className={receipt.extraction_confidence > 0.8 ? 'border-success/50 text-success' : ''}
>
{Math.round(receipt.extraction_confidence * 100)}% säkerhet
{receipt.extraction_confidence <= 0.8 && ' — kontrollera'}
</Badge>
)}
</div>
{/* Toggle image preview */}
<Button
variant="ghost"
size="sm"
className="mt-2 text-xs"
onClick={() => setShowImage(!showImage)}
>
{showImage ? <ChevronUp className="mr-1 h-3 w-3" /> : <ChevronDown className="mr-1 h-3 w-3" />}
{showImage ? 'Dölj bild' : 'Visa bild'}
</Button>
{showImage && receipt.image_url && (
<img
src={receipt.image_url}
alt="Receipt"
className="mt-2 max-h-48 rounded-lg object-contain"
/>
)}
</CardContent>
</Card>
{/* Warning message */}
{defaultClassification.warningMessage && (
<div className="flex items-start gap-2 p-3 rounded-lg bg-warning/5 border border-warning/30">
<AlertTriangle className="h-5 w-5 text-warning-foreground flex-shrink-0 mt-0.5" />
<p className="text-sm text-warning-foreground">
{defaultClassification.warningMessage}
</p>
</div>
)}
{/* Restaurant representation section */}
{receipt.is_restaurant && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base flex items-center gap-2">
<Utensils className="h-4 w-4" />
Representation
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<Label htmlFor="persons" className="text-xs">
Antal personer
</Label>
<Input
id="persons"
type="number"
min={1}
value={representationPersons || ''}
onChange={(e) => setRepresentationPersons(parseInt(e.target.value) || undefined)}
placeholder="2"
/>
</div>
<div>
<Label className="text-xs">Per person</Label>
<p className="h-10 flex items-center font-medium">
{representationPersons && receipt.total_amount
? formatCurrency(receipt.total_amount / representationPersons, receipt.currency)
: '—'}
</p>
</div>
</div>
<div>
<Label htmlFor="purpose" className="text-xs">
Syfte med representationen
</Label>
<Textarea
id="purpose"
value={representationPurpose}
onChange={(e) => setRepresentationPurpose(e.target.value)}
placeholder="T.ex. kundmöte, kontraktsförhandling..."
rows={2}
/>
</div>
{representationCalc && (
<div className="p-2 rounded bg-muted text-xs space-y-1">
<p>
<span className="text-muted-foreground">Avdragsgill del:</span>{' '}
<span className="font-medium text-green-600">
{formatCurrency(representationCalc.deductibleAmount, receipt.currency)}
</span>
{' '}(max {representationCalc.maxDeductiblePerPerson} kr/person)
</p>
{representationCalc.nonDeductibleAmount > 0 && (
<p>
<span className="text-muted-foreground">Ej avdragsgill del:</span>{' '}
<span className="font-medium text-orange-600">
{formatCurrency(representationCalc.nonDeductibleAmount, receipt.currency)}
</span>
</p>
)}
</div>
)}
</CardContent>
</Card>
)}
{/* Quick actions */}
<div className="flex gap-2">
<Button variant="outline" className="flex-1" onClick={handleAllBusiness}>
<Building className="mr-2 h-4 w-4" />
Allt företag
</Button>
<Button variant="outline" className="flex-1" onClick={handleAllPrivate}>
<User className="mr-2 h-4 w-4" />
Allt privat
</Button>
</div>
{/* Line items */}
<div className="space-y-2">
<h3 className="font-medium text-sm flex items-center justify-between">
Artiklar ({lineItems.length})
<span className="text-muted-foreground font-normal">
Svajpa eller klicka för att klassificera
</span>
</h3>
{lineItems.map((item) => (
<ReceiptLineItemRow
key={item.id}
item={item}
onToggleBusiness={handleToggleBusiness}
onCategoryChange={handleCategoryChange}
disabled={isProcessing}
/>
))}
</div>
{/* Summary */}
<Card>
<CardContent className="pt-4">
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Företag:</span>
<span className="font-medium text-green-600">
{formatCurrency(splitSummary.businessTotal, receipt.currency)}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Privat:</span>
<span className="font-medium text-orange-600">
{formatCurrency(splitSummary.privateTotal, receipt.currency)}
</span>
</div>
{splitSummary.unclassifiedTotal > 0 && (
<div className="flex justify-between">
<span className="text-muted-foreground">Oklassificerat:</span>
<span className="font-medium text-yellow-600">
{formatCurrency(splitSummary.unclassifiedTotal, receipt.currency)}
</span>
</div>
)}
<div className="pt-2 border-t flex justify-between font-medium">
<span>Totalt:</span>
<span>{formatCurrency(receipt.total_amount || 0, receipt.currency)}</span>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Footer actions - large touch targets for mobile */}
<div className="p-4 border-t flex-shrink-0 space-y-2 safe-area-bottom">
{onFindMatches && (
<Button
variant="outline"
className="w-full h-12 text-base"
onClick={onFindMatches}
disabled={isProcessing}
>
Hitta matchande transaktion
</Button>
)}
<Button
className="w-full h-14 text-base font-medium"
onClick={handleConfirm}
disabled={isProcessing || splitSummary.unclassifiedTotal > 0}
>
{isProcessing ? (
<>
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
Sparar...
</>
) : (
<>
<Check className="mr-2 h-5 w-5" />
Bekräfta klassificering
</>
)}
</Button>
{splitSummary.unclassifiedTotal > 0 && (
<p className="text-xs text-center text-muted-foreground">
Klassificera alla artiklar innan du bekräftar
</p>
)}
</div>
</div>
)
}
@@ -0,0 +1,229 @@
'use client'
import { useState, useEffect } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { formatCurrency, formatDate } from '@/lib/utils'
import { X, Check, Link2, AlertCircle, Search, Loader2 } from 'lucide-react'
import type { Receipt, ReceiptMatchCandidate, Transaction } from '@/types'
interface TransactionMatcherProps {
receipt: Receipt
onMatch: (transactionId: string, confidence: number) => Promise<void>
onSkip: () => void
onClose: () => void
}
export default function TransactionMatcher({
receipt,
onMatch,
onSkip,
onClose,
}: TransactionMatcherProps) {
const [matches, setMatches] = useState<ReceiptMatchCandidate[]>([])
const [isLoading, setIsLoading] = useState(true)
const [isMatching, setIsMatching] = useState(false)
const [selectedMatch, setSelectedMatch] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
// Fetch matches on mount
useEffect(() => {
const fetchMatches = async () => {
setIsLoading(true)
setError(null)
try {
const response = await fetch(`/api/extensions/receipt-ocr/${receipt.id}/match`, {
method: 'POST',
})
const data = await response.json()
if (response.ok && data.data?.matches) {
setMatches(data.data.matches)
} else {
setError(data.error || 'Kunde inte hämta matchningar')
}
} catch {
setError('Nätverksfel vid hämtning av matchningar')
} finally {
setIsLoading(false)
}
}
fetchMatches()
}, [receipt.id])
// Handle match confirmation
const handleConfirmMatch = async () => {
if (!selectedMatch) return
const match = matches.find((m) => m.transaction.id === selectedMatch)
if (!match) return
setIsMatching(true)
try {
await onMatch(selectedMatch, match.confidence)
} catch {
setError('Kunde inte koppla kvitto till transaktion')
} finally {
setIsMatching(false)
}
}
return (
<div className="fixed inset-0 bg-background z-50 flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b">
<Button variant="ghost" size="icon" onClick={onClose}>
<X className="h-5 w-5" />
</Button>
<h1 className="font-semibold">Matcha transaktion</h1>
<div className="w-10" />
</div>
{/* Receipt summary */}
<Card className="m-4 mb-2">
<CardContent className="pt-4">
<div className="flex items-center justify-between">
<div>
<p className="font-medium">{receipt.merchant_name || 'Kvitto'}</p>
{receipt.receipt_date && (
<p className="text-sm text-muted-foreground">{formatDate(receipt.receipt_date)}</p>
)}
</div>
<p className="text-xl font-bold">
{formatCurrency(receipt.total_amount || 0, receipt.currency)}
</p>
</div>
</CardContent>
</Card>
{/* Content */}
<div className="flex-1 overflow-auto px-4 pb-4">
<h2 className="text-sm font-medium text-muted-foreground mb-3 flex items-center gap-2">
<Search className="h-4 w-4" />
Möjliga matchningar
</h2>
{isLoading ? (
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<Card key={i}>
<CardContent className="pt-4">
<Skeleton className="h-4 w-2/3 mb-2" />
<Skeleton className="h-6 w-1/3 mb-2" />
<Skeleton className="h-3 w-1/2" />
</CardContent>
</Card>
))}
</div>
) : error ? (
<Card className="border-destructive">
<CardContent className="pt-4 flex items-center gap-3 text-destructive">
<AlertCircle className="h-5 w-5" />
<p>{error}</p>
</CardContent>
</Card>
) : matches.length === 0 ? (
<Card>
<CardContent className="pt-4 text-center py-8">
<Search className="h-12 w-12 text-muted-foreground mx-auto mb-3" />
<p className="font-medium">Inga matchande transaktioner hittades</p>
<p className="text-sm text-muted-foreground mt-1">
Det finns ingen banktransaktion som matchar detta kvitto
</p>
</CardContent>
</Card>
) : (
<div className="space-y-3">
{matches.map((match) => (
<Card
key={match.transaction.id}
className={`cursor-pointer transition-colors ${
selectedMatch === match.transaction.id
? 'border-primary ring-2 ring-primary/20'
: 'hover:border-primary/50'
}`}
onClick={() => setSelectedMatch(match.transaction.id)}
>
<CardContent className="pt-4">
<div className="flex items-start justify-between mb-2">
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{match.transaction.description}</p>
<p className="text-sm text-muted-foreground">
{formatDate(match.transaction.date)}
</p>
</div>
<div className="text-right flex-shrink-0">
<p className="font-bold">
{formatCurrency(Math.abs(match.transaction.amount), match.transaction.currency)}
</p>
<Badge
variant={match.confidence > 0.8 ? 'default' : match.confidence > 0.6 ? 'secondary' : 'outline'}
>
{Math.round(match.confidence * 100)}% match
</Badge>
</div>
</div>
{/* Match reasons */}
<div className="flex flex-wrap gap-1 mt-2">
{match.matchReasons.map((reason, idx) => (
<Badge key={idx} variant="outline" className="text-xs">
{reason}
</Badge>
))}
</div>
{/* Variance info */}
<div className="flex gap-4 mt-2 text-xs text-muted-foreground">
{match.dateVariance > 0 && (
<span>Datum: ±{Math.round(match.dateVariance)} dagar</span>
)}
{match.amountVariance > 0.01 && (
<span>Belopp: ±{Math.round(match.amountVariance * 100)}%</span>
)}
</div>
{/* Selected indicator */}
{selectedMatch === match.transaction.id && (
<div className="absolute top-2 right-2">
<div className="h-6 w-6 rounded-full bg-primary flex items-center justify-center">
<Check className="h-4 w-4 text-primary-foreground" />
</div>
</div>
)}
</CardContent>
</Card>
))}
</div>
)}
</div>
{/* Footer actions */}
<div className="p-4 border-t flex gap-3">
<Button variant="outline" className="flex-1" onClick={onSkip}>
Hoppa över
</Button>
<Button
className="flex-1"
onClick={handleConfirmMatch}
disabled={!selectedMatch || isMatching}
>
{isMatching ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Kopplar...
</>
) : (
<>
<Link2 className="mr-2 h-4 w-4" />
Koppla transaktion
</>
)}
</Button>
</div>
</div>
)
}
@@ -0,0 +1,5 @@
export { default as ReceiptCamera } from './ReceiptCamera'
export { default as ReceiptLineItemRow } from './ReceiptLineItemRow'
export { default as ReceiptReviewView } from './ReceiptReviewView'
export { default as TransactionMatcher } from './TransactionMatcher'
export { default as ReceiptDashboard } from './ReceiptDashboard'