Files
accounted/extensions/general/receipt-ocr/pages/scan/ScanReceiptPage.tsx
T
Jakob WennbergandClaude Opus 4.6 03b569d708 refactor: consolidate extension system to general-only with manifest-driven architecture
- Remove all sector-specific extensions (construction, ecommerce, export,
  hotel, restaurant, tech) — only general-purpose extensions remain
- Move NE-bilaga and SRU export from extensions to core reports (lib/reports/)
- Move moms-box-mapping from extensions/export/shared to lib/vat/
- Replace per-extension API routes with catch-all dispatcher
  (app/api/extensions/ext/[...path]/route.ts)
- Add manifest.json for each extension with metadata, env vars, and deps
- Add api-routes.ts pattern for extension-defined API endpoints
- Add code generation scripts (generate-extension-registry, create-extension)
- Add extensions.config.json for opt-in extension loading
- Add extensions.schema.json for config validation
- Add email service interface with noop default (lib/email/service.ts)
- Add CI workflow (core-build.yml) to verify core builds with zero extensions
- Add migration 045: expand account_type CHECK for untaxed_reserves
- Update CLAUDE.md with comprehensive extension system documentation
- Update all report engines and bookkeeping services for new imports
- Clean up extensions.schema.json to only list existing extensions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 14:32:56 +01:00

158 lines
4.6 KiB
TypeScript

'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import ReceiptCamera from '../../components/ReceiptCamera'
import ReceiptReviewView from '../../components/ReceiptReviewView'
import TransactionMatcher from '../../components/TransactionMatcher'
import { Loader2 } from 'lucide-react'
import type { Receipt, ReceiptLineItem, ConfirmLineItemInput } from '@/types'
type PageState = 'camera' | 'uploading' | 'review' | 'match' | 'done'
export default function ScanReceiptPage() {
const router = useRouter()
const [pageState, setPageState] = useState<PageState>('camera')
const [uploadError, setUploadError] = useState<string | null>(null)
const [receipt, setReceipt] = useState<(Receipt & { line_items: ReceiptLineItem[] }) | null>(null)
// Handle image capture
const handleCapture = async (imageData: string, mimeType: string) => {
setPageState('uploading')
setUploadError(null)
try {
// Convert base64 to blob
const byteCharacters = atob(imageData)
const byteNumbers = new Array(byteCharacters.length)
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i)
}
const byteArray = new Uint8Array(byteNumbers)
const blob = new Blob([byteArray], { type: mimeType })
// Create form data
const formData = new FormData()
formData.append('image', blob, 'receipt.jpg')
// Upload and analyze
const response = await fetch('/api/extensions/ext/receipt-ocr/upload', {
method: 'POST',
body: formData,
})
const data = await response.json()
if (response.ok && data.data) {
setReceipt(data.data)
setPageState('review')
} else {
setUploadError(data.error || 'Kunde inte analysera kvittot')
setPageState('camera')
}
} catch (error) {
console.error('Upload error:', error)
setUploadError('Nätverksfel. Försök igen.')
setPageState('camera')
}
}
// Handle close/cancel
const handleClose = () => {
router.push('/receipts')
}
// Handle confirm receipt
const handleConfirm = async (data: {
line_items: ConfirmLineItemInput[]
representation_persons?: number
representation_purpose?: string
}) => {
if (!receipt) return
const response = await fetch(`/api/extensions/ext/receipt-ocr/${receipt.id}/confirm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (response.ok) {
const updatedData = await response.json()
setReceipt(updatedData.data)
setPageState('match')
} else {
const errorData = await response.json()
throw new Error(errorData.error || 'Kunde inte bekräfta kvitto')
}
}
// Handle match to transaction
const handleMatch = async (transactionId: string, confidence: number) => {
if (!receipt) return
const response = await fetch(`/api/extensions/ext/receipt-ocr/${receipt.id}/match`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ transaction_id: transactionId, match_confidence: confidence }),
})
if (response.ok) {
router.push('/receipts')
}
}
// Handle skip matching
const handleSkipMatch = () => {
router.push('/receipts')
}
// Render uploading state
if (pageState === 'uploading') {
return (
<div className="fixed inset-0 bg-background z-50 flex flex-col items-center justify-center">
<Loader2 className="h-12 w-12 animate-spin text-primary mb-4" />
<p className="text-lg font-medium">Analyserar kvitto...</p>
<p className="text-sm text-muted-foreground mt-1">
AI läser av artiklar och belopp
</p>
</div>
)
}
// Render review view
if (pageState === 'review' && receipt) {
return (
<ReceiptReviewView
receipt={receipt}
onConfirm={handleConfirm}
onCancel={handleClose}
onFindMatches={() => setPageState('match')}
/>
)
}
// Render match view
if (pageState === 'match' && receipt) {
return (
<TransactionMatcher
receipt={receipt}
onMatch={handleMatch}
onSkip={handleSkipMatch}
onClose={handleClose}
/>
)
}
// Render camera (default)
return (
<>
{uploadError && (
<div className="fixed top-4 left-4 right-4 z-[60] bg-destructive text-destructive-foreground p-3 rounded-lg text-sm">
{uploadError}
</div>
)}
<ReceiptCamera onCapture={handleCapture} onClose={handleClose} />
</>
)
}