Files
accounted/extensions/general/receipt-ocr/pages/ReceiptsPage.tsx
T
Jakob WennbergandClaude Opus 4.6 091d043c85 feat: UI polish, lint fixes, onboarding redesign, help page expansion, and test improvements
Broad update across dashboard pages, components, extensions, and lib code. Includes ESLint config additions, onboarding flow redesign, settings page refactor, help page content expansion, dead code removal, and test mock fixes. Adds dev docs and public assets.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 23:05:49 +01:00

345 lines
12 KiB
TypeScript

'use client'
import { useState, useEffect, useCallback } from 'react'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
Camera,
Receipt,
Check,
Clock,
AlertCircle,
ArrowRight,
} from 'lucide-react'
import { formatCurrency, formatDate } from '@/lib/utils'
import { useRouter } from 'next/navigation'
import ReceiptDashboard from '../components/ReceiptDashboard'
import ReceiptReviewView from '../components/ReceiptReviewView'
import TransactionMatcher from '../components/TransactionMatcher'
import type { Receipt as ReceiptType, ReceiptLineItem, ReceiptQueueSummary, ConfirmLineItemInput } from '@/types'
type ViewMode = 'dashboard' | 'list' | 'review' | 'match'
type ListFilter = 'all' | 'pending' | 'confirmed'
export default function ReceiptsPage() {
const router = useRouter()
const [viewMode, setViewMode] = useState<ViewMode>('dashboard')
const [listFilter, setListFilter] = useState<ListFilter>('all')
const [receipts, setReceipts] = useState<(ReceiptType & { line_items: ReceiptLineItem[] })[]>([])
const [summary, setSummary] = useState<ReceiptQueueSummary | null>(null)
const [isLoading, setIsLoading] = useState(true)
// Selected receipt for review/match
const [selectedReceipt, setSelectedReceipt] = useState<(ReceiptType & { line_items: ReceiptLineItem[] }) | null>(null)
// Fetch receipts and summary
const fetchData = useCallback(async () => {
setIsLoading(true)
try {
const [receiptsRes, queueRes] = await Promise.all([
fetch('/api/extensions/ext/receipt-ocr'),
fetch('/api/extensions/ext/receipt-ocr/queue'),
])
const [receiptsData, queueData] = await Promise.all([
receiptsRes.json(),
queueRes.json(),
])
if (receiptsData.data) {
setReceipts(receiptsData.data)
}
if (queueData.data?.summary) {
setSummary(queueData.data.summary)
}
} catch (error) {
console.error('Fetch error:', error)
} finally {
setIsLoading(false)
}
}, [])
useEffect(() => {
fetchData()
}, [fetchData])
// Filter receipts based on tab
const filteredReceipts = receipts.filter((r) => {
if (listFilter === 'pending') return r.status === 'extracted'
if (listFilter === 'confirmed') return r.status === 'confirmed'
return true
})
// Handle scan receipt
const handleScanReceipt = () => {
router.push('/receipts/scan')
}
// Handle view receipt queue
const handleViewReceiptQueue = () => {
setListFilter('pending')
setViewMode('list')
}
// Handle view transaction queue
const handleViewTransactionQueue = () => {
router.push('/transactions?filter=unmatched')
}
// Handle receipt selection for review
const handleSelectReceipt = (receipt: ReceiptType & { line_items: ReceiptLineItem[] }) => {
setSelectedReceipt(receipt)
if (receipt.status === 'extracted') {
setViewMode('review')
} else {
setViewMode('match')
}
}
// Handle confirm receipt
const handleConfirmReceipt = async (data: {
line_items: ConfirmLineItemInput[]
representation_persons?: number
representation_purpose?: string
}) => {
if (!selectedReceipt) return
const response = await fetch(`/api/extensions/ext/receipt-ocr/${selectedReceipt.id}/confirm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (response.ok) {
await fetchData()
setSelectedReceipt(null)
setViewMode('dashboard')
} else {
const errorData = await response.json()
throw new Error(errorData.error || 'Kunde inte bekräfta kvitto')
}
}
// Handle match receipt to transaction
const handleMatchReceipt = async (transactionId: string, confidence: number) => {
if (!selectedReceipt) return
const response = await fetch(`/api/extensions/ext/receipt-ocr/${selectedReceipt.id}/match`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ transaction_id: transactionId, match_confidence: confidence }),
})
if (response.ok) {
await fetchData()
setSelectedReceipt(null)
setViewMode('dashboard')
}
}
// Render receipt review view
if (viewMode === 'review' && selectedReceipt) {
return (
<ReceiptReviewView
receipt={selectedReceipt}
onConfirm={handleConfirmReceipt}
onCancel={() => {
setSelectedReceipt(null)
setViewMode('dashboard')
}}
onFindMatches={() => setViewMode('match')}
/>
)
}
// Render transaction matcher
if (viewMode === 'match' && selectedReceipt) {
return (
<TransactionMatcher
receipt={selectedReceipt}
onMatch={handleMatchReceipt}
onSkip={() => {
setSelectedReceipt(null)
setViewMode('dashboard')
}}
onClose={() => {
setSelectedReceipt(null)
setViewMode('dashboard')
}}
/>
)
}
// Render main page
return (
<div className="container max-w-4xl mx-auto p-4 space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Kvittohantering</h1>
<Button onClick={handleScanReceipt}>
<Camera className="mr-2 h-4 w-4" />
Skanna kvitto
</Button>
</div>
{/* Dashboard or List view toggle */}
<Tabs
value={viewMode === 'list' ? 'list' : 'dashboard'}
onValueChange={(v) => setViewMode(v as ViewMode)}
>
<TabsList>
<TabsTrigger value="dashboard">Översikt</TabsTrigger>
<TabsTrigger value="list">Alla kvitton</TabsTrigger>
</TabsList>
</Tabs>
{viewMode === 'dashboard' && (
<>
{isLoading ? (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<Skeleton className="h-24" />
<Skeleton className="h-24" />
</div>
<Skeleton className="h-32" />
<Skeleton className="h-24" />
</div>
) : summary ? (
<ReceiptDashboard
summary={summary}
onScanReceipt={handleScanReceipt}
onViewReceiptQueue={handleViewReceiptQueue}
onViewTransactionQueue={handleViewTransactionQueue}
/>
) : (
<Card>
<CardContent className="pt-4 text-center py-8">
<AlertCircle className="h-12 w-12 text-muted-foreground mx-auto mb-3" />
<p>Kunde inte ladda data</p>
<Button variant="outline" className="mt-4" onClick={fetchData}>
Försök igen
</Button>
</CardContent>
</Card>
)}
</>
)}
{viewMode === 'list' && (
<>
{/* List filters */}
<div className="flex gap-2">
<Button
variant={listFilter === 'all' ? 'default' : 'outline'}
size="sm"
onClick={() => setListFilter('all')}
>
Alla
</Button>
<Button
variant={listFilter === 'pending' ? 'default' : 'outline'}
size="sm"
onClick={() => setListFilter('pending')}
>
<Clock className="mr-1 h-3 w-3" />
Att granska
</Button>
<Button
variant={listFilter === 'confirmed' ? 'default' : 'outline'}
size="sm"
onClick={() => setListFilter('confirmed')}
>
<Check className="mr-1 h-3 w-3" />
Bekräftade
</Button>
</div>
{/* Receipt list */}
{isLoading ? (
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-24" />
))}
</div>
) : filteredReceipts.length === 0 ? (
<Card>
<CardContent className="pt-4 text-center py-8">
<Receipt className="h-12 w-12 text-muted-foreground mx-auto mb-3" />
<p className="text-muted-foreground">Inga kvitton hittades</p>
</CardContent>
</Card>
) : (
<div className="space-y-3">
{filteredReceipts.map((receipt) => (
<Card
key={receipt.id}
className="cursor-pointer hover:border-primary/50 transition-colors"
onClick={() => handleSelectReceipt(receipt)}
>
<CardContent className="pt-4">
<div className="flex items-start justify-between">
<div className="flex items-start gap-3">
{receipt.image_url ? (
<img
src={receipt.image_url}
alt="Receipt"
className="h-16 w-12 object-cover rounded"
/>
) : (
<div className="h-16 w-12 bg-muted rounded flex items-center justify-center">
<Receipt className="h-6 w-6 text-muted-foreground" />
</div>
)}
<div>
<p className="font-medium">{receipt.merchant_name || 'Okänt kvitto'}</p>
{receipt.receipt_date && (
<p className="text-sm text-muted-foreground">
{formatDate(receipt.receipt_date)}
</p>
)}
<div className="flex flex-wrap gap-1 mt-1">
<Badge
variant={
receipt.status === 'confirmed'
? 'default'
: receipt.status === 'extracted'
? 'secondary'
: receipt.status === 'error'
? 'destructive'
: 'outline'
}
>
{receipt.status === 'confirmed' && 'Bekräftat'}
{receipt.status === 'extracted' && 'Att granska'}
{receipt.status === 'processing' && 'Analyserar...'}
{receipt.status === 'pending' && 'Väntar'}
{receipt.status === 'error' && 'Fel'}
</Badge>
{receipt.matched_transaction_id && (
<Badge variant="outline">Kopplat</Badge>
)}
</div>
</div>
</div>
<div className="text-right">
<p className="font-bold">
{formatCurrency(receipt.total_amount || 0, receipt.currency)}
</p>
<ArrowRight className="h-4 w-4 text-muted-foreground mt-2 ml-auto" />
</div>
</div>
</CardContent>
</Card>
))}
</div>
)}
</>
)}
</div>
)
}