03b569d708
- 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>
230 lines
8.0 KiB
TypeScript
230 lines
8.0 KiB
TypeScript
'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/ext/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>
|
|
)
|
|
}
|