Added extension functionality
This commit is contained in:
@@ -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'
|
||||
Reference in New Issue
Block a user