5cb8c3c1e4
* feat: event log, pending operations, and MCP staging - Event log system: persist bus events to event_log table for external automation platforms. Batch insert for transaction.synced. Daily cleanup cron at 02:00 UTC. - Pending operations: MCP write tools (categorize, create customer, create invoice) now stage to pending_operations instead of executing directly. Users review and commit/reject from /pending in the web UI. - Granskning page: card-based review UI with expandable previews, commit/reject dialogs. Only shown in nav when pending ops exist. - Commit route re-executes using core lib functions (no extension imports). Guards against stale state (double-commit, deleted entities). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: stage new MCP write tools after main merge Add staging for 4 new write tools from #133: - mark_invoice_paid, send_invoice, mark_invoice_sent, match_transaction_invoice - Expand pending_operations CHECK constraint - Add commit executors with full execution logic - Add UI labels and generic preview component - Remove confirm parameter from categorize (single-call staging) - Fix UUID in pending op title (fetch transaction description) - Hide Granskning nav when no pending ops Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address PR review feedback - Fix TS build error: use `select('*, customer:customers(*)')` for match_transaction_invoice to avoid array type inference - Add status guard to commitSendInvoice (prevents duplicate sends) - Replace auth.admin.getUserById with user email from session auth - Restore optimistic lock check in commitMatchTransactionInvoice - Fix tool description typo: expense_software → expense_office Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add support contact links and improve SIE import UX Add a SupportLink component with a contact dialog throughout the app (nav, help page, settings, MFA, error pages, empty states). Improve SIE import flow with phased loading states, structured skip breakdowns, and an elapsed-time counter. Fix MFA enroll stale factor cleanup and URL encoding for settings return path. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address PR review — open redirect, XSS, test cleanup, fallback email - Validate returnTo is a relative path in MFA enroll (prevents open redirect) - Add afterEach import to event-log-handler tests (fixes handler leak) - HTML-escape user-supplied subject and message in support email body - Replace hardcoded personal email with support@gnubok.se fallback Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
336 lines
12 KiB
TypeScript
336 lines
12 KiB
TypeScript
'use client'
|
|
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { Button } from '@/components/ui/button'
|
|
import { formatCurrency } from '@/lib/utils'
|
|
import {
|
|
Building2,
|
|
Calendar,
|
|
FileText,
|
|
CheckCircle,
|
|
AlertCircle,
|
|
XCircle,
|
|
ArrowRight,
|
|
BarChart3,
|
|
Info,
|
|
} from 'lucide-react'
|
|
import type { ImportPreview, ParseIssue } from '@/lib/import/types'
|
|
|
|
interface SIEPreviewStepProps {
|
|
preview: ImportPreview
|
|
issues: ParseIssue[]
|
|
missingAccounts: { number: string; name: string }[]
|
|
onCreateAccounts: () => Promise<void>
|
|
isCreatingAccounts: boolean
|
|
onContinue: () => void
|
|
onBack: () => void
|
|
}
|
|
|
|
export default function SIEPreviewStep({
|
|
preview,
|
|
issues,
|
|
missingAccounts,
|
|
onCreateAccounts,
|
|
isCreatingAccounts,
|
|
onContinue,
|
|
onBack,
|
|
}: SIEPreviewStepProps) {
|
|
const errors = issues.filter((i) => i.severity === 'error')
|
|
const warnings = issues.filter((i) => i.severity === 'warning')
|
|
|
|
// Only block on actual parsing errors, not unmapped accounts
|
|
// (users need to proceed to mapping step to fix unmapped accounts)
|
|
const hasBlockingErrors = errors.length > 0
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Company info */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<Building2 className="h-5 w-5" />
|
|
Företagsinformation
|
|
</CardTitle>
|
|
<CardDescription>Information från SIE-filen</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
<div>
|
|
<p className="text-sm text-muted-foreground">Företagsnamn</p>
|
|
<p className="font-medium">{preview.companyName || 'Ej angivet'}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-sm text-muted-foreground">Organisationsnummer</p>
|
|
<p className="font-medium">{preview.orgNumber || 'Ej angivet'}</p>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Fiscal year */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<Calendar className="h-5 w-5" />
|
|
Räkenskapsår
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="flex items-center gap-4">
|
|
<div>
|
|
<p className="text-sm text-muted-foreground">Start</p>
|
|
<p className="font-medium">
|
|
{preview.fiscalYearStart
|
|
? new Date(preview.fiscalYearStart).toLocaleDateString('sv-SE')
|
|
: 'Okänt'}
|
|
</p>
|
|
</div>
|
|
<ArrowRight className="h-4 w-4 text-muted-foreground" />
|
|
<div>
|
|
<p className="text-sm text-muted-foreground">Slut</p>
|
|
<p className="font-medium">
|
|
{preview.fiscalYearEnd
|
|
? new Date(preview.fiscalYearEnd).toLocaleDateString('sv-SE')
|
|
: 'Okänt'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Statistics */}
|
|
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
|
<Card>
|
|
<CardContent className="pt-6">
|
|
<div className="flex items-center gap-2 text-muted-foreground mb-1">
|
|
<FileText className="h-4 w-4" />
|
|
<span className="text-sm">Konton</span>
|
|
</div>
|
|
<p className="text-2xl font-display font-medium tabular-nums">{preview.accountCount}</p>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardContent className="pt-6">
|
|
<div className="flex items-center gap-2 text-muted-foreground mb-1">
|
|
<BarChart3 className="h-4 w-4" />
|
|
<span className="text-sm">Verifikationer</span>
|
|
</div>
|
|
<p className="text-2xl font-display font-medium tabular-nums">{preview.voucherCount}</p>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardContent className="pt-6">
|
|
<div className="flex items-center gap-2 text-muted-foreground mb-1">
|
|
<span className="text-sm">Transaktionsrader</span>
|
|
</div>
|
|
<p className="text-2xl font-display font-medium tabular-nums">{preview.transactionLineCount}</p>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardContent className="pt-6">
|
|
<div className="flex items-center gap-2 text-muted-foreground mb-1">
|
|
<span className="text-sm">IB Summa</span>
|
|
</div>
|
|
<p className="text-2xl font-display font-medium tabular-nums">{formatCurrency(preview.openingBalanceTotal)}</p>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Trial balance check */}
|
|
<Card className={preview.trialBalance.isBalanced ? 'border-success/50' : 'border-warning/50'}>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
{preview.trialBalance.isBalanced ? (
|
|
<CheckCircle className="h-5 w-5 text-success" />
|
|
) : (
|
|
<AlertCircle className="h-5 w-5 text-warning" />
|
|
)}
|
|
Balansräkning (IB)
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="grid gap-4 md:grid-cols-3">
|
|
<div>
|
|
<p className="text-sm text-muted-foreground">Total debet</p>
|
|
<p className="font-medium">{formatCurrency(preview.trialBalance.totalDebit)}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-sm text-muted-foreground">Total kredit</p>
|
|
<p className="font-medium">{formatCurrency(preview.trialBalance.totalCredit)}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-sm text-muted-foreground">Status</p>
|
|
{preview.trialBalance.isBalanced ? (
|
|
<Badge variant="default" className="bg-success">Balanserar</Badge>
|
|
) : (
|
|
<Badge variant="secondary">
|
|
Diff: {formatCurrency(preview.trialBalance.totalDebit - preview.trialBalance.totalCredit)}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Mapping status */}
|
|
<Card
|
|
className={
|
|
preview.mappingStatus.unmapped > 0
|
|
? 'border-destructive/50'
|
|
: preview.mappingStatus.lowConfidence > 0
|
|
? 'border-warning/50'
|
|
: 'border-success/50'
|
|
}
|
|
>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
{preview.mappingStatus.unmapped > 0 ? (
|
|
<XCircle className="h-5 w-5 text-destructive" />
|
|
) : preview.mappingStatus.lowConfidence > 0 ? (
|
|
<AlertCircle className="h-5 w-5 text-warning" />
|
|
) : (
|
|
<CheckCircle className="h-5 w-5 text-success" />
|
|
)}
|
|
Kontomappning
|
|
</CardTitle>
|
|
<CardDescription>
|
|
Hur väl kunde kontona i filen matchas mot din kontoplan
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
|
<div>
|
|
<p className="text-sm text-muted-foreground">Totalt</p>
|
|
<p className="font-medium">{preview.mappingStatus.total}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-sm text-muted-foreground">Mappade</p>
|
|
<p className="font-medium text-success">{preview.mappingStatus.mapped}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-sm text-muted-foreground">Ej mappade</p>
|
|
<p className={`font-medium ${preview.mappingStatus.unmapped > 0 ? 'text-destructive' : ''}`}>
|
|
{preview.mappingStatus.unmapped}
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-sm text-muted-foreground">Osäkra</p>
|
|
<p className={`font-medium ${preview.mappingStatus.lowConfidence > 0 ? 'text-warning' : ''}`}>
|
|
{preview.mappingStatus.lowConfidence}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Excluded system accounts info */}
|
|
{preview.excludedSystemAccounts.length > 0 && (
|
|
<div className="flex items-start gap-2 rounded-lg border bg-muted/50 px-4 py-3 text-sm text-muted-foreground">
|
|
<Info className="h-4 w-4 mt-0.5 flex-shrink-0" />
|
|
<span>
|
|
{preview.excludedSystemAccounts.length} internt systemkonto från källsystemet exkluderades ({preview.excludedSystemAccounts.map((a) => a.number).join(', ')}) — inte bokföringskonton
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Create missing accounts */}
|
|
{missingAccounts.length > 0 ? (
|
|
<Card className="border-primary/50">
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<FileText className="h-5 w-5 text-primary" />
|
|
Skapa saknade konton
|
|
</CardTitle>
|
|
<CardDescription>
|
|
{missingAccounts.length} konton från SIE-filen finns inte i din kontoplan ännu.
|
|
Klicka nedan för att skapa dem — de kopplas sedan automatiskt i nästa steg.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-4">
|
|
<div className="max-h-32 overflow-y-auto text-sm space-y-1">
|
|
{missingAccounts.slice(0, 10).map((acc) => (
|
|
<div key={acc.number} className="flex gap-2 text-muted-foreground">
|
|
<span className="font-mono">{acc.number}</span>
|
|
<span>{acc.name}</span>
|
|
</div>
|
|
))}
|
|
{missingAccounts.length > 10 && (
|
|
<div className="text-muted-foreground">
|
|
... och {missingAccounts.length - 10} till
|
|
</div>
|
|
)}
|
|
</div>
|
|
<Button
|
|
onClick={onCreateAccounts}
|
|
disabled={isCreatingAccounts}
|
|
className="w-full"
|
|
>
|
|
{isCreatingAccounts ? (
|
|
<>Skapar konton...</>
|
|
) : (
|
|
<>Skapa {missingAccounts.length} konton</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
) : preview.mappingStatus.mapped === preview.mappingStatus.total && preview.mappingStatus.total > 0 && (
|
|
<div className="flex items-center gap-2 rounded-lg border border-success/50 bg-success/5 px-4 py-3 text-sm">
|
|
<CheckCircle className="h-4 w-4 text-success flex-shrink-0" />
|
|
<span>Alla konton skapade och automatiskt kopplade</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Issues */}
|
|
{(errors.length > 0 || warnings.length > 0) && (
|
|
<Card className={errors.length > 0 ? 'border-destructive/50' : 'border-warning/50'}>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
{errors.length > 0 ? (
|
|
<XCircle className="h-5 w-5 text-destructive" />
|
|
) : (
|
|
<AlertCircle className="h-5 w-5 text-warning" />
|
|
)}
|
|
{errors.length > 0 ? 'Fel' : 'Varningar'}
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-2 max-h-48 overflow-y-auto">
|
|
{errors.map((issue, i) => (
|
|
<div key={`error-${i}`} className="text-sm flex gap-2 text-destructive">
|
|
<XCircle className="h-4 w-4 flex-shrink-0 mt-0.5" />
|
|
<span>Rad {issue.line}: {issue.message}</span>
|
|
</div>
|
|
))}
|
|
{warnings.map((issue, i) => (
|
|
<div key={`warning-${i}`} className="text-sm flex gap-2 text-warning">
|
|
<AlertCircle className="h-4 w-4 flex-shrink-0 mt-0.5" />
|
|
<span>Rad {issue.line}: {issue.message}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Actions */}
|
|
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-between">
|
|
<Button variant="outline" className="min-h-11" onClick={onBack}>
|
|
Tillbaka
|
|
</Button>
|
|
<Button className="min-h-11" onClick={onContinue} disabled={hasBlockingErrors}>
|
|
{preview.mappingStatus.lowConfidence > 0 || preview.mappingStatus.unmapped > 0
|
|
? 'Granska mappningar'
|
|
: 'Fortsätt'}
|
|
<ArrowRight className="ml-2 h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|