- 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>
158 lines
4.6 KiB
TypeScript
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} />
|
|
</>
|
|
)
|
|
}
|