feat: implement 4 export extensions with engines, API routes, and workspaces

- EU Sales List: engine, CSV/XML generators, Skatteverket format, tests
- VAT Monitor: moms box mapping, revenue breakdown, validation, tests
- Intrastat: product registry, SCB CSV generator, threshold tracking, tests
- Currency Receivables: FX exposure, unrealized gain/loss calc, tests
- Shared utilities: EU country list, moms box mapping
- API routes for report generation and file downloads
- Workspace UI components for all 4 extensions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Emil
2026-02-24 14:10:16 +01:00
co-authored by Claude Opus 4.6
parent e8042381f7
commit 0764bc11a6
30 changed files with 8138 additions and 0 deletions
@@ -0,0 +1,648 @@
'use client'
import { useState, useEffect, useMemo, useCallback } from 'react'
import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
import ExtensionLoadingSkeleton from '@/components/extensions/shared/ExtensionLoadingSkeleton'
import KPICard from '@/components/extensions/shared/KPICard'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select'
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from '@/components/ui/table'
import {
AlertTriangle, CheckCircle2, FileSpreadsheet, FileCode,
Clock, ChevronDown, ChevronUp, Users, Package, Briefcase,
} from 'lucide-react'
import { cn } from '@/lib/utils'
// ── Types ─────────────────────────────────────────────────────
interface ECSalesListLine {
customerVatNumber: string
customerName: string
customerCountry: string
customerId: string
goodsAmount: number
servicesAmount: number
triangulationAmount: number
invoiceCount: number
}
interface ECSalesListWarning {
type: string
severity: 'error' | 'warning'
invoiceId?: string
invoiceNumber?: string
customerId?: string
customerName?: string
message: string
}
interface CrossCheckResult {
box35Match: boolean
box35ReportTotal: number
box35GLTotal: number
box39Match: boolean
box39ReportTotal: number
box39GLTotal: number
}
interface ReportData {
period: { year: number; month?: number; quarter?: number }
filingType: 'monthly' | 'quarterly'
reporterVatNumber: string
reporterName: string
lines: ECSalesListLine[]
totals: { goods: number; services: number; triangulation: number; total: number }
warnings: ECSalesListWarning[]
crossCheck: CrossCheckResult | null
invoiceCount: number
customerCount: number
deadline: string
daysUntilDeadline: number
}
type SortField = 'country' | 'vatNumber' | 'goods' | 'services' | 'invoices'
type SortDir = 'asc' | 'desc'
// ── Helpers ───────────────────────────────────────────────────
function formatSEK(amount: number): string {
return Math.round(amount).toLocaleString('sv-SE')
}
function formatDeadlineDate(dateStr: string): string {
const d = new Date(dateStr + 'T00:00:00')
return d.toLocaleDateString('sv-SE', { year: 'numeric', month: 'long', day: 'numeric' })
}
const MONTHS = [
'Januari', 'Februari', 'Mars', 'April', 'Maj', 'Juni',
'Juli', 'Augusti', 'September', 'Oktober', 'November', 'December',
]
const QUARTERS = ['Q1 (jan–mar)', 'Q2 (apr–jun)', 'Q3 (jul–sep)', 'Q4 (okt–dec)']
function currentYear(): number {
return new Date().getFullYear()
}
function currentMonth(): number {
return new Date().getMonth() + 1
}
function currentQuarter(): number {
return Math.ceil(currentMonth() / 3)
}
// ── Component ─────────────────────────────────────────────────
export default function EuSalesListWorkspace({ userId }: WorkspaceComponentProps) {
void userId
// Period selection state
const [year, setYear] = useState(currentYear())
const [periodType, setPeriodType] = useState<'monthly' | 'quarterly'>('quarterly')
const [month, setMonth] = useState(currentMonth())
const [quarter, setQuarter] = useState(currentQuarter())
// Report state
const [report, setReport] = useState<ReportData | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// Table sort
const [sortField, setSortField] = useState<SortField>('country')
const [sortDir, setSortDir] = useState<SortDir>('asc')
// Warning expansion
const [warningsExpanded, setWarningsExpanded] = useState(false)
// Download state
const [downloading, setDownloading] = useState<'csv' | 'xml' | null>(null)
// Available years (current year and 2 previous)
const years = useMemo(() => {
const cy = currentYear()
return [cy, cy - 1, cy - 2]
}, [])
// Fetch report
const fetchReport = useCallback(async () => {
setIsLoading(true)
setError(null)
const params = new URLSearchParams({ year: String(year) })
if (periodType === 'monthly') {
params.set('month', String(month))
} else {
params.set('quarter', String(quarter))
}
try {
const res = await fetch(`/api/extensions/export/eu-sales-list/report?${params}`)
if (!res.ok) {
const json = await res.json()
setError(json.error || 'Kunde inte generera rapporten')
setReport(null)
return
}
const json = await res.json()
setReport(json.data)
} catch {
setError('Nätverksfel — kunde inte hämta rapporten')
setReport(null)
} finally {
setIsLoading(false)
}
}, [year, month, quarter, periodType])
useEffect(() => {
fetchReport()
}, [fetchReport])
// Sort lines
const sortedLines = useMemo(() => {
if (!report) return []
const lines = [...report.lines]
lines.sort((a, b) => {
let cmp = 0
switch (sortField) {
case 'country':
cmp = a.customerCountry.localeCompare(b.customerCountry)
break
case 'vatNumber':
cmp = a.customerVatNumber.localeCompare(b.customerVatNumber)
break
case 'goods':
cmp = a.goodsAmount - b.goodsAmount
break
case 'services':
cmp = a.servicesAmount - b.servicesAmount
break
case 'invoices':
cmp = a.invoiceCount - b.invoiceCount
break
}
return sortDir === 'asc' ? cmp : -cmp
})
return lines
}, [report, sortField, sortDir])
// Toggle sort
const toggleSort = (field: SortField) => {
if (sortField === field) {
setSortDir(d => d === 'asc' ? 'desc' : 'asc')
} else {
setSortField(field)
setSortDir('asc')
}
}
// Download handler
const handleDownload = async (format: 'csv' | 'xml') => {
setDownloading(format)
const params = new URLSearchParams({ year: String(year), format })
if (periodType === 'monthly') {
params.set('month', String(month))
} else {
params.set('quarter', String(quarter))
}
try {
const res = await fetch(`/api/extensions/export/eu-sales-list/download?${params}`)
if (!res.ok) {
const json = await res.json()
setError(json.error || 'Kunde inte ladda ner filen')
return
}
const blob = await res.blob()
const disposition = res.headers.get('Content-Disposition') || ''
const filenameMatch = disposition.match(/filename="(.+)"/)
const filename = filenameMatch ? filenameMatch[1] : `PS_${year}.${format}`
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
} catch {
setError('Kunde inte ladda ner filen')
} finally {
setDownloading(null)
}
}
// Derived counts
const errorCount = report?.warnings.filter(w => w.severity === 'error').length ?? 0
const warningCount = report?.warnings.filter(w => w.severity === 'warning').length ?? 0
if (isLoading && !report) {
return <ExtensionLoadingSkeleton />
}
return (
<div className="space-y-6">
{/* ── Period Selector ─────────────────────────────────── */}
<div className="flex flex-wrap items-end gap-3">
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">År</label>
<Select value={String(year)} onValueChange={v => setYear(parseInt(v, 10))}>
<SelectTrigger className="w-[100px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{years.map(y => (
<SelectItem key={y} value={String(y)}>{y}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">Periodtyp</label>
<Select value={periodType} onValueChange={v => setPeriodType(v as 'monthly' | 'quarterly')}>
<SelectTrigger className="w-[130px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="monthly">Månad</SelectItem>
<SelectItem value="quarterly">Kvartal</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">
{periodType === 'monthly' ? 'Månad' : 'Kvartal'}
</label>
{periodType === 'monthly' ? (
<Select value={String(month)} onValueChange={v => setMonth(parseInt(v, 10))}>
<SelectTrigger className="w-[150px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{MONTHS.map((name, i) => (
<SelectItem key={i + 1} value={String(i + 1)}>{name}</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Select value={String(quarter)} onValueChange={v => setQuarter(parseInt(v, 10))}>
<SelectTrigger className="w-[150px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{QUARTERS.map((name, i) => (
<SelectItem key={i + 1} value={String(i + 1)}>{name}</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
{/* Download buttons */}
<div className="flex gap-2 ml-auto">
<Button
variant="outline"
size="sm"
onClick={() => handleDownload('csv')}
disabled={downloading !== null || !report || report.lines.length === 0}
>
<FileSpreadsheet className="h-4 w-4 mr-1.5" />
{downloading === 'csv' ? 'Laddar...' : 'CSV'}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleDownload('xml')}
disabled={downloading !== null || !report || report.lines.length === 0}
>
<FileCode className="h-4 w-4 mr-1.5" />
{downloading === 'xml' ? 'Laddar...' : 'SKV XML'}
</Button>
</div>
</div>
{/* ── Error state ────────────────────────────────────── */}
{error && (
<Card className="border-destructive/50 bg-destructive/5">
<CardContent className="pt-6">
<p className="text-sm text-destructive">{error}</p>
</CardContent>
</Card>
)}
{report && (
<>
{/* ── KPI Cards ────────────────────────────────────── */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<KPICard
label="Varuförsäljning EU"
value={formatSEK(report.totals.goods)}
suffix="SEK"
/>
<KPICard
label="Tjänsteförsäljning EU"
value={formatSEK(report.totals.services)}
suffix="SEK"
/>
<KPICard
label="Trepartshandel"
value={formatSEK(report.totals.triangulation)}
suffix="SEK"
/>
<KPICard
label="Kunder"
value={report.customerCount}
suffix={`(${report.invoiceCount} fakturor)`}
/>
</div>
{/* ── Deadline + Cross-Check Row ────────────────────── */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Deadline */}
<Card>
<CardContent className="pt-6">
<div className="flex items-start gap-3">
<Clock className="h-5 w-5 text-muted-foreground mt-0.5 shrink-0" />
<div>
<p className="text-sm font-medium">Inlämningsdeadline</p>
<p className="text-lg font-semibold mt-0.5">
{formatDeadlineDate(report.deadline)}
</p>
<p className={cn(
'text-sm mt-1',
report.daysUntilDeadline <= 7 ? 'text-destructive font-medium' :
report.daysUntilDeadline <= 14 ? 'text-warning-foreground' :
'text-muted-foreground'
)}>
{report.daysUntilDeadline > 0
? `${report.daysUntilDeadline} dagar kvar`
: report.daysUntilDeadline === 0
? 'Deadline idag!'
: `${Math.abs(report.daysUntilDeadline)} dagar försenad`
}
</p>
</div>
</div>
</CardContent>
</Card>
{/* Cross-check */}
<Card>
<CardContent className="pt-6">
<p className="text-sm font-medium mb-3">Avstämning mot huvudbok</p>
{report.crossCheck ? (
<div className="space-y-2">
<CrossCheckRow
label="Ruta 35 — varor"
reportTotal={report.crossCheck.box35ReportTotal}
glTotal={report.crossCheck.box35GLTotal}
match={report.crossCheck.box35Match}
/>
<CrossCheckRow
label="Ruta 39 — tjänster"
reportTotal={report.crossCheck.box39ReportTotal}
glTotal={report.crossCheck.box39GLTotal}
match={report.crossCheck.box39Match}
/>
</div>
) : (
<p className="text-sm text-muted-foreground">
Ingen bokföringsdata tillgänglig för perioden.
</p>
)}
</CardContent>
</Card>
</div>
{/* ── Warnings ─────────────────────────────────────── */}
{report.warnings.length > 0 && (
<Card className={cn(
'border-l-4',
errorCount > 0 ? 'border-l-destructive' : 'border-l-warning'
)}>
<CardContent className="pt-6">
<button
className="flex items-center gap-2 w-full text-left"
onClick={() => setWarningsExpanded(!warningsExpanded)}
>
<AlertTriangle className={cn(
'h-4 w-4 shrink-0',
errorCount > 0 ? 'text-destructive' : 'text-warning-foreground'
)} />
<span className="text-sm font-medium flex-1">
{errorCount > 0 && (
<span className="text-destructive">{errorCount} fel</span>
)}
{errorCount > 0 && warningCount > 0 && ', '}
{warningCount > 0 && (
<span className="text-warning-foreground">{warningCount} varningar</span>
)}
</span>
{warningsExpanded
? <ChevronUp className="h-4 w-4 text-muted-foreground" />
: <ChevronDown className="h-4 w-4 text-muted-foreground" />
}
</button>
{warningsExpanded && (
<div className="mt-4 space-y-2">
{report.warnings.map((w, i) => (
<div
key={i}
className={cn(
'flex items-start gap-2 text-sm py-2 px-3 rounded-md',
w.severity === 'error'
? 'bg-destructive/5 text-destructive'
: 'bg-warning/10 text-warning-foreground'
)}
>
<AlertTriangle className="h-3.5 w-3.5 mt-0.5 shrink-0" />
<span>{w.message}</span>
</div>
))}
</div>
)}
</CardContent>
</Card>
)}
{/* ── Customer Table ────────────────────────────────── */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Users className="h-4 w-4" />
Kunder per land
</CardTitle>
</CardHeader>
<CardContent>
{sortedLines.length === 0 ? (
<p className="text-sm text-muted-foreground py-8 text-center">
Inga EU-försäljningar hittades för vald period.
</p>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<SortableHead field="country" current={sortField} dir={sortDir} onSort={toggleSort}>
Land
</SortableHead>
<SortableHead field="vatNumber" current={sortField} dir={sortDir} onSort={toggleSort}>
VAT-nummer
</SortableHead>
<TableHead className="text-left">Kund</TableHead>
<SortableHead field="goods" current={sortField} dir={sortDir} onSort={toggleSort} className="text-right">
<span className="inline-flex items-center gap-1">
<Package className="h-3.5 w-3.5" />
Varor (ruta 35)
</span>
</SortableHead>
<SortableHead field="services" current={sortField} dir={sortDir} onSort={toggleSort} className="text-right">
<span className="inline-flex items-center gap-1">
<Briefcase className="h-3.5 w-3.5" />
Tjänster (ruta 39)
</span>
</SortableHead>
<SortableHead field="invoices" current={sortField} dir={sortDir} onSort={toggleSort} className="text-right">
Fakturor
</SortableHead>
</TableRow>
</TableHeader>
<TableBody>
{sortedLines.map(line => (
<TableRow key={line.customerVatNumber}>
<TableCell>
<Badge variant="outline" className="font-mono text-xs">
{line.customerCountry}
</Badge>
</TableCell>
<TableCell className="font-mono text-sm">
{line.customerVatNumber}
</TableCell>
<TableCell className="text-sm">{line.customerName}</TableCell>
<TableCell className="text-right font-mono text-sm tabular-nums">
{line.goodsAmount !== 0 ? formatSEK(line.goodsAmount) : '—'}
</TableCell>
<TableCell className="text-right font-mono text-sm tabular-nums">
{line.servicesAmount !== 0 ? formatSEK(line.servicesAmount) : '—'}
</TableCell>
<TableCell className="text-right text-sm tabular-nums">
{line.invoiceCount}
</TableCell>
</TableRow>
))}
{/* Totals row */}
<TableRow className="border-t-2 font-medium">
<TableCell colSpan={3} className="text-sm">
Summa ({sortedLines.length} kunder)
</TableCell>
<TableCell className="text-right font-mono text-sm tabular-nums">
{formatSEK(report.totals.goods)}
</TableCell>
<TableCell className="text-right font-mono text-sm tabular-nums">
{formatSEK(report.totals.services)}
</TableCell>
<TableCell className="text-right text-sm tabular-nums">
{report.invoiceCount}
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
{/* ── Filing Info Footer ────────────────────────────── */}
<div className="flex items-center justify-between text-xs text-muted-foreground px-1">
<span>
Uppgiftslämnare: {report.reporterName} ({report.reporterVatNumber})
</span>
<span>
Redovisningsperiod: {report.period.year}
{report.period.month !== undefined && `, ${MONTHS[report.period.month - 1]}`}
{report.period.quarter !== undefined && `, ${QUARTERS[report.period.quarter - 1]}`}
</span>
</div>
</>
)}
</div>
)
}
// ── Sub-components ────────────────────────────────────────────
function CrossCheckRow({
label,
reportTotal,
glTotal,
match,
}: {
label: string
reportTotal: number
glTotal: number
match: boolean
}) {
const diff = Math.round(reportTotal * 100) / 100 - Math.round(glTotal * 100) / 100
return (
<div className="flex items-center gap-2 text-sm">
{match ? (
<CheckCircle2 className="h-4 w-4 text-green-600 shrink-0" />
) : (
<AlertTriangle className="h-4 w-4 text-destructive shrink-0" />
)}
<span className="flex-1">{label}</span>
<span className="font-mono tabular-nums text-muted-foreground">
{formatSEK(reportTotal)} SEK
</span>
{!match && (
<span className="font-mono tabular-nums text-destructive text-xs">
(diff: {diff > 0 ? '+' : ''}{formatSEK(diff)})
</span>
)}
</div>
)
}
function SortableHead({
field,
current,
dir,
onSort,
className,
children,
}: {
field: SortField
current: SortField
dir: SortDir
onSort: (field: SortField) => void
className?: string
children: React.ReactNode
}) {
const isActive = current === field
return (
<TableHead className={cn('cursor-pointer select-none', className)} onClick={() => onSort(field)}>
<span className="inline-flex items-center gap-1">
{children}
{isActive && (
dir === 'asc'
? <ChevronUp className="h-3 w-3" />
: <ChevronDown className="h-3 w-3" />
)}
</span>
</TableHead>
)
}