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,542 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
import ExtensionLoadingSkeleton from '@/components/extensions/shared/ExtensionLoadingSkeleton'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select'
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from '@/components/ui/table'
import {
TrendingUp, TrendingDown, RefreshCw, Info, ArrowUpDown,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
// ── Types ─────────────────────────────────────────────────────
interface CurrencyExposure {
currency: string
totalForeignAmount: number
bookedSekValue: number
currentSekValue: number
unrealizedGainLoss: number
invoiceCount: number
averageBookedRate: number
currentRate: number
}
interface ForeignReceivable {
invoiceId: string
invoiceNumber: string
customerName: string
customerCountry: string
currency: string
foreignAmount: number
bookedSekAmount: number
bookedRate: number
currentSekAmount: number
currentRate: number
unrealizedGainLoss: number
invoiceDate: string
dueDate: string
daysOutstanding: number
}
interface MonthlyFXTrend {
month: string
realizedGains: number
realizedLosses: number
netRealized: number
}
interface ExchangeRateInfo {
currency: string
rate: number
date: string
}
interface RevalPreview {
totalUnrealizedGainLoss: number
gains: number
losses: number
}
interface ReportData {
referenceDate: string
exchangeRates: ExchangeRateInfo[]
exposureByCurrency: CurrencyExposure[]
receivables: ForeignReceivable[]
realizedGainLoss: {
year: number
gains: number
losses: number
net: number
}
monthlyTrend: MonthlyFXTrend[]
revalPreview: RevalPreview
totals: {
bookedSekValue: number
currentSekValue: number
totalUnrealizedGainLoss: number
receivableCount: number
currencyCount: number
}
}
// ── Helpers ───────────────────────────────────────────────────
function formatSEK(amount: number): string {
return Math.round(amount).toLocaleString('sv-SE')
}
function formatAmount(amount: number, decimals = 2): string {
return amount.toLocaleString('sv-SE', {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
})
}
const CURRENCY_SYMBOLS: Record<string, string> = {
EUR: '€', USD: '$', GBP: '£', NOK: 'kr', DKK: 'kr', SEK: 'kr',
}
function currencySymbol(code: string): string {
return CURRENCY_SYMBOLS[code] || code
}
const MONTH_NAMES = [
'Jan', 'Feb', 'Mar', 'Apr', 'Maj', 'Jun',
'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dec',
]
function monthLabel(monthKey: string): string {
const m = parseInt(monthKey.split('-')[1], 10)
return MONTH_NAMES[m - 1] || monthKey
}
function currentYear(): number { return new Date().getFullYear() }
type SortField = 'unrealizedGainLoss' | 'foreignAmount' | 'daysOutstanding' | 'customerName'
type SortDir = 'asc' | 'desc'
// ── Component ─────────────────────────────────────────────────
export default function CurrencyReceivablesWorkspace({ userId }: WorkspaceComponentProps) {
void userId
const [year, setYear] = useState(currentYear())
const [report, setReport] = useState<ReportData | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [refreshing, setRefreshing] = useState(false)
const [sortField, setSortField] = useState<SortField>('unrealizedGainLoss')
const [sortDir, setSortDir] = useState<SortDir>('desc')
const years = [currentYear(), currentYear() - 1, currentYear() - 2]
const fetchReport = useCallback(async () => {
setIsLoading(true)
setError(null)
try {
const params = new URLSearchParams({ year: String(year) })
const res = await fetch(`/api/extensions/export/currency-receivables/report?${params}`)
if (!res.ok) {
const json = await res.json()
setError(json.error || 'Kunde inte hämta rapporten')
setReport(null)
return
}
const json = await res.json()
setReport(json.data)
} catch {
setError('Nätverksfel')
setReport(null)
} finally {
setIsLoading(false)
setRefreshing(false)
}
}, [year])
useEffect(() => {
fetchReport()
}, [fetchReport])
const handleRefresh = () => {
setRefreshing(true)
fetchReport()
}
const toggleSort = (field: SortField) => {
if (sortField === field) {
setSortDir(d => d === 'asc' ? 'desc' : 'asc')
} else {
setSortField(field)
setSortDir('desc')
}
}
const sortedReceivables = report?.receivables.slice().sort((a, b) => {
const mul = sortDir === 'asc' ? 1 : -1
switch (sortField) {
case 'unrealizedGainLoss': return mul * (Math.abs(a.unrealizedGainLoss) - Math.abs(b.unrealizedGainLoss))
case 'foreignAmount': return mul * (a.foreignAmount - b.foreignAmount)
case 'daysOutstanding': return mul * (a.daysOutstanding - b.daysOutstanding)
case 'customerName': return mul * a.customerName.localeCompare(b.customerName)
default: return 0
}
}) || []
// Only show trend months that have data or are <= current month
const activeTrend = report?.monthlyTrend.filter(t => {
const m = parseInt(t.month.split('-')[1], 10)
const trendYear = parseInt(t.month.split('-')[0], 10)
if (trendYear < currentYear()) return true
return m <= new Date().getMonth() + 1
}) || []
if (isLoading && !report) {
return <ExtensionLoadingSkeleton />
}
return (
<div className="space-y-6">
{/* ── Header ─────────────────────────────────────── */}
<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 (realiserade)</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>
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={refreshing} className="ml-auto">
<RefreshCw className={cn('h-4 w-4 mr-1.5', refreshing && 'animate-spin')} />
{refreshing ? 'Uppdaterar...' : 'Uppdatera kurser'}
</Button>
</div>
{error && (
<Card className="border-destructive/50 bg-destructive/5">
<CardContent className="pt-6">
<p className="text-sm text-destructive">{error}</p>
</CardContent>
</Card>
)}
{report && (
<>
{/* ── Exchange Rates ──────────────────────────── */}
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-2 mb-3">
<p className="text-sm font-medium">Växelkurser</p>
<Badge variant="outline" className="text-xs">
{report.referenceDate}
</Badge>
</div>
<div className="flex flex-wrap gap-4">
{report.exchangeRates.map(r => (
<div key={r.currency} className="flex items-baseline gap-1.5">
<span className="text-xs font-medium text-muted-foreground">{r.currency}:</span>
<span className="text-sm font-mono tabular-nums">{formatAmount(r.rate, 4)}</span>
</div>
))}
</div>
</CardContent>
</Card>
{/* ── Exposure Cards ─────────────────────────── */}
{report.exposureByCurrency.length > 0 ? (
<div className={cn(
'grid gap-4',
report.exposureByCurrency.length === 1 ? 'grid-cols-1 sm:grid-cols-2' :
report.exposureByCurrency.length === 2 ? 'grid-cols-1 sm:grid-cols-2' :
'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3'
)}>
{report.exposureByCurrency.map(exp => (
<ExposureCard key={exp.currency} exposure={exp} />
))}
{/* Total card */}
<Card className="border-2">
<CardContent className="pt-6">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-muted-foreground">Totalt</span>
<Badge variant="outline">{report.totals.receivableCount} fakturor</Badge>
</div>
<p className="text-2xl font-semibold tabular-nums">
{formatSEK(report.totals.currentSekValue)}
</p>
<p className="text-xs text-muted-foreground mt-0.5">SEK (aktuell kurs)</p>
<div className="mt-3 pt-3 border-t">
<FXIndicator label="Orealiserat" amount={report.totals.totalUnrealizedGainLoss} />
</div>
</CardContent>
</Card>
</div>
) : (
<Card>
<CardContent className="pt-6">
<p className="text-sm text-muted-foreground text-center py-6">
Inga öppna fordringar i utländsk valuta.
</p>
</CardContent>
</Card>
)}
{/* ── Receivables Table ──────────────────────── */}
{sortedReceivables.length > 0 && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Öppna fordringar</CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Faktura</TableHead>
<SortableHead field="customerName" label="Kund" current={sortField} dir={sortDir} onSort={toggleSort} />
<TableHead>Valuta</TableHead>
<SortableHead field="foreignAmount" label="Belopp" current={sortField} dir={sortDir} onSort={toggleSort} className="text-right" />
<TableHead className="text-right">Bokfört (SEK)</TableHead>
<TableHead className="text-right">Aktuellt (SEK)</TableHead>
<SortableHead field="unrealizedGainLoss" label="Orealiserat" current={sortField} dir={sortDir} onSort={toggleSort} className="text-right" />
<SortableHead field="daysOutstanding" label="Dagar" current={sortField} dir={sortDir} onSort={toggleSort} className="text-right" />
</TableRow>
</TableHeader>
<TableBody>
{sortedReceivables.map(r => (
<TableRow key={r.invoiceId}>
<TableCell className="font-mono text-sm">{r.invoiceNumber}</TableCell>
<TableCell className="text-sm">
<div>
<span>{r.customerName}</span>
{r.customerCountry && (
<Badge variant="outline" className="ml-1.5 text-xs">{r.customerCountry}</Badge>
)}
</div>
</TableCell>
<TableCell>
<Badge variant="outline" className="text-xs">{r.currency}</Badge>
</TableCell>
<TableCell className="text-right font-mono text-sm tabular-nums">
{currencySymbol(r.currency)}{formatAmount(r.foreignAmount)}
</TableCell>
<TableCell className="text-right font-mono text-sm tabular-nums">
{formatSEK(r.bookedSekAmount)}
</TableCell>
<TableCell className="text-right font-mono text-sm tabular-nums">
{formatSEK(r.currentSekAmount)}
</TableCell>
<TableCell className="text-right">
<FXBadge amount={r.unrealizedGainLoss} />
</TableCell>
<TableCell className="text-right text-sm tabular-nums">
<span className={cn(
r.daysOutstanding > 30 ? 'text-destructive font-medium' :
r.daysOutstanding > 14 ? 'text-warning-foreground' : ''
)}>
{r.daysOutstanding}
</span>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
)}
{/* ── Realized FX Trend ──────────────────────── */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">
Realiserade kursdifferenser {year}
</CardTitle>
</CardHeader>
<CardContent>
{activeTrend.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-6">
Inga realiserade kursdifferenser för {year}.
</p>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Månad</TableHead>
<TableHead className="text-right">Vinst (3960)</TableHead>
<TableHead className="text-right">Förlust (7960)</TableHead>
<TableHead className="text-right">Netto</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{activeTrend.map(t => (
<TableRow key={t.month}>
<TableCell className="text-sm">{monthLabel(t.month)}</TableCell>
<TableCell className="text-right font-mono text-sm tabular-nums text-green-600">
{t.realizedGains > 0 ? `+${formatSEK(t.realizedGains)}` : '—'}
</TableCell>
<TableCell className="text-right font-mono text-sm tabular-nums text-red-600">
{t.realizedLosses > 0 ? `-${formatSEK(t.realizedLosses)}` : '—'}
</TableCell>
<TableCell className="text-right font-mono text-sm tabular-nums">
<FXBadge amount={t.netRealized} />
</TableCell>
</TableRow>
))}
{/* Totals row */}
<TableRow className="border-t-2 font-medium">
<TableCell className="text-sm">Totalt {year}</TableCell>
<TableCell className="text-right font-mono text-sm tabular-nums text-green-600">
+{formatSEK(report.realizedGainLoss.gains)}
</TableCell>
<TableCell className="text-right font-mono text-sm tabular-nums text-red-600">
-{formatSEK(report.realizedGainLoss.losses)}
</TableCell>
<TableCell className="text-right">
<FXBadge amount={report.realizedGainLoss.net} />
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
{/* ── Revaluation Preview ────────────────────── */}
{report.receivables.length > 0 && (
<Card className="border-l-4 border-l-blue-500/50">
<CardContent className="pt-6">
<div className="flex items-start gap-3">
<Info className="h-5 w-5 text-blue-500 mt-0.5 shrink-0" />
<div className="space-y-2">
<p className="text-sm font-medium">Omvärdering vid periodbokslut</p>
<p className="text-sm text-muted-foreground">
Om bokslut görs idag: netto orealiserad{' '}
<span className={cn(
'font-medium',
report.revalPreview.totalUnrealizedGainLoss >= 0 ? 'text-green-600' : 'text-red-600'
)}>
{report.revalPreview.totalUnrealizedGainLoss >= 0 ? 'vinst' : 'förlust'}{' '}
{report.revalPreview.totalUnrealizedGainLoss >= 0 ? '+' : ''}
{formatSEK(report.revalPreview.totalUnrealizedGainLoss)} SEK
</span>
</p>
{report.revalPreview.gains > 0 && (
<p className="text-xs text-muted-foreground">
Konto 3969 (orealiserad kursvinst): {formatSEK(report.revalPreview.gains)} kr
</p>
)}
{report.revalPreview.losses > 0 && (
<p className="text-xs text-muted-foreground">
Konto 7969 (orealiserad kursförlust): {formatSEK(report.revalPreview.losses)} kr
</p>
)}
<p className="text-xs text-muted-foreground italic">
Bokföringsposterna skapas inte av detta tillägg. Använd värdena ovan som underlag vid periodbokslut.
</p>
</div>
</div>
</CardContent>
</Card>
)}
</>
)}
</div>
)
}
// ── Sub-components ────────────────────────────────────────────
function ExposureCard({ exposure }: { exposure: CurrencyExposure }) {
const sym = currencySymbol(exposure.currency)
return (
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between mb-2">
<Badge variant="outline" className="text-sm font-medium">{exposure.currency}</Badge>
<span className="text-xs text-muted-foreground">{exposure.invoiceCount} fakturor</span>
</div>
<p className="text-lg font-mono tabular-nums">
{sym}{formatAmount(exposure.totalForeignAmount)}
</p>
<p className="text-sm text-muted-foreground tabular-nums">
{formatSEK(exposure.currentSekValue)} SEK
</p>
<div className="mt-3 pt-3 border-t space-y-1">
<FXIndicator label="Orealiserat" amount={exposure.unrealizedGainLoss} />
<div className="flex justify-between text-xs text-muted-foreground">
<span>Bokförd kurs: {formatAmount(exposure.averageBookedRate, 4)}</span>
<span>Aktuell: {formatAmount(exposure.currentRate, 4)}</span>
</div>
</div>
</CardContent>
</Card>
)
}
function FXIndicator({ label, amount }: { label: string; amount: number }) {
const isGain = amount >= 0
return (
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">{label}</span>
<div className={cn(
'flex items-center gap-1 text-sm font-medium tabular-nums',
isGain ? 'text-green-600' : 'text-red-600'
)}>
{isGain ? <TrendingUp className="h-3.5 w-3.5" /> : <TrendingDown className="h-3.5 w-3.5" />}
<span>{isGain ? '+' : ''}{formatSEK(amount)} kr</span>
</div>
</div>
)
}
function FXBadge({ amount }: { amount: number }) {
if (amount === 0) return <span className="text-sm text-muted-foreground">—</span>
const isGain = amount > 0
return (
<span className={cn(
'text-sm font-mono tabular-nums font-medium',
isGain ? 'text-green-600' : 'text-red-600'
)}>
{isGain ? '+' : ''}{formatSEK(amount)}
</span>
)
}
function SortableHead({
field, label, current, dir, onSort, className,
}: {
field: SortField
label: string
current: SortField
dir: SortDir
onSort: (f: SortField) => void
className?: string
}) {
const isActive = current === field
return (
<TableHead className={className}>
<button
className="flex items-center gap-1 hover:text-foreground transition-colors"
onClick={() => onSort(field)}
>
{label}
<ArrowUpDown className={cn('h-3 w-3', isActive ? 'text-foreground' : 'text-muted-foreground/50')} />
{isActive && <span className="text-xs">{dir === 'asc' ? '↑' : '↓'}</span>}
</button>
</TableHead>
)
}
@@ -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>
)
}
@@ -0,0 +1,625 @@
'use client'
import { useState, useEffect, useMemo, useCallback } from 'react'
import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
import { useExtensionData } from '@/lib/extensions/use-extension-data'
import ExtensionLoadingSkeleton from '@/components/extensions/shared/ExtensionLoadingSkeleton'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Progress } from '@/components/ui/progress'
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select'
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from '@/components/ui/table'
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
} from '@/components/ui/dialog'
import {
AlertTriangle, Plus, Pencil, Trash2, FileSpreadsheet, Clock,
ChevronDown, ChevronUp, Package,
} from 'lucide-react'
import { cn } from '@/lib/utils'
// ── Types ─────────────────────────────────────────────────────
interface IntrastatLine {
cnCode: string
partnerCountry: string
countryOfOrigin: string
transactionNature: string
deliveryTerms: string
invoicedValue: number
netMass: number
supplementaryUnit: number | null
supplementaryUnitType: string | null
partnerVatId: string
}
interface ThresholdStatus {
cumulativeValue: number
threshold: number
isObligated: boolean
percentageUsed: number
}
interface IntrastatWarning {
type: string
severity: 'error' | 'warning'
invoiceId?: string
invoiceNumber?: string
productId?: string
message: string
}
interface ReportData {
period: { year: number; month: number }
reporterVatNumber: string
reporterName: string
lines: IntrastatLine[]
totals: { invoicedValue: number; netMass: number; lineCount: number }
thresholdStatus: ThresholdStatus
warnings: IntrastatWarning[]
invoiceCount: number
}
interface ProductRecord {
key: string
productId: string
description: string
cn_code: string | null
net_weight_kg: number | null
country_of_origin: string
}
interface ProductForm {
productId: string
description: string
cnCode: string
netWeightKg: string
countryOfOrigin: string
}
// ── Helpers ───────────────────────────────────────────────────
function formatSEK(amount: number): string {
return Math.round(amount).toLocaleString('sv-SE')
}
const MONTHS = [
'Januari', 'Februari', 'Mars', 'April', 'Maj', 'Juni',
'Juli', 'Augusti', 'September', 'Oktober', 'November', 'December',
]
function currentYear(): number { return new Date().getFullYear() }
function currentMonth(): number { return new Date().getMonth() + 1 }
const EMPTY_PRODUCT: ProductForm = {
productId: '', description: '', cnCode: '', netWeightKg: '', countryOfOrigin: 'SE',
}
// ── Component ─────────────────────────────────────────────────
export default function IntrastatWorkspace({ userId }: WorkspaceComponentProps) {
void userId
const [year, setYear] = useState(currentYear())
const [month, setMonth] = useState(currentMonth())
const [report, setReport] = useState<ReportData | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [downloading, setDownloading] = useState(false)
const [warningsExpanded, setWarningsExpanded] = useState(false)
// Product CRUD
const { data: extData, save, remove, isLoading: productsLoading } = useExtensionData('export', 'intrastat')
const [productDialogOpen, setProductDialogOpen] = useState(false)
const [editingProduct, setEditingProduct] = useState<string | null>(null)
const [productForm, setProductForm] = useState<ProductForm>(EMPTY_PRODUCT)
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null)
const years = useMemo(() => {
const cy = currentYear()
return [cy, cy - 1, cy - 2]
}, [])
// Parse products from extension data
const products: ProductRecord[] = useMemo(() => {
return extData
.filter(d => d.key.startsWith('product:'))
.map(d => ({
key: d.key,
productId: d.key.replace('product:', ''),
description: String(d.value.description || ''),
cn_code: d.value.cn_code ? String(d.value.cn_code) : null,
net_weight_kg: d.value.net_weight_kg !== undefined ? Number(d.value.net_weight_kg) : null,
country_of_origin: String(d.value.country_of_origin || 'SE'),
}))
.sort((a, b) => a.description.localeCompare(b.description))
}, [extData])
// Fetch report
const fetchReport = useCallback(async () => {
setIsLoading(true)
setError(null)
try {
const params = new URLSearchParams({ year: String(year), month: String(month) })
const res = await fetch(`/api/extensions/export/intrastat/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')
setReport(null)
} finally {
setIsLoading(false)
}
}, [year, month])
useEffect(() => {
fetchReport()
}, [fetchReport])
// Product CRUD handlers
const openNewProduct = () => {
setEditingProduct(null)
setProductForm(EMPTY_PRODUCT)
setProductDialogOpen(true)
}
const openEditProduct = (productId: string) => {
const product = products.find(p => p.productId === productId)
if (!product) return
setEditingProduct(productId)
setProductForm({
productId,
description: product.description,
cnCode: product.cn_code || '',
netWeightKg: product.net_weight_kg !== null ? String(product.net_weight_kg) : '',
countryOfOrigin: product.country_of_origin,
})
setProductDialogOpen(true)
}
const saveProduct = async () => {
const id = editingProduct || productForm.productId.trim()
if (!id) return
await save(`product:${id}`, {
description: productForm.description.trim(),
cn_code: productForm.cnCode.trim() || null,
net_weight_kg: productForm.netWeightKg ? parseFloat(productForm.netWeightKg) : null,
country_of_origin: productForm.countryOfOrigin || 'SE',
})
setProductDialogOpen(false)
// Refresh report to pick up new product metadata
fetchReport()
}
const deleteProduct = async (productId: string) => {
await remove(`product:${productId}`)
setDeleteConfirm(null)
fetchReport()
}
// Download handler
const handleDownload = async () => {
setDownloading(true)
try {
const params = new URLSearchParams({ year: String(year), month: String(month) })
const res = await fetch(`/api/extensions/export/intrastat/download?${params}`)
if (!res.ok) {
setError('Kunde inte ladda ner filen')
return
}
const blob = await res.blob()
const disposition = res.headers.get('Content-Disposition') || ''
const match = disposition.match(/filename="(.+)"/)
const filename = match ? match[1] : `INTRASTAT_${year}-${String(month).padStart(2, '0')}.csv`
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(false)
}
}
const errorCount = report?.warnings.filter(w => w.severity === 'error').length ?? 0
const warningCount = report?.warnings.filter(w => w.severity === 'warning').length ?? 0
if ((isLoading || productsLoading) && !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">Månad</label>
<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>
</div>
<div className="flex gap-2 ml-auto">
<Button
variant="outline" size="sm"
onClick={handleDownload}
disabled={downloading || !report || report.lines.length === 0}
>
<FileSpreadsheet className="h-4 w-4 mr-1.5" />
{downloading ? 'Laddar...' : 'IDEP.web CSV'}
</Button>
</div>
</div>
{error && (
<Card className="border-destructive/50 bg-destructive/5">
<CardContent className="pt-6">
<p className="text-sm text-destructive">{error}</p>
</CardContent>
</Card>
)}
{report && (
<>
{/* ── Threshold Progress ───────────────────────────── */}
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between mb-2">
<p className="text-sm font-medium">Tröskelvärde Intrastat (utförsel)</p>
<Badge variant={report.thresholdStatus.isObligated ? 'destructive' : 'outline'}>
{report.thresholdStatus.isObligated ? 'Obligatorisk rapportering' : 'Frivillig rapportering'}
</Badge>
</div>
<Progress
value={Math.min(report.thresholdStatus.percentageUsed, 100)}
className="h-3"
/>
<div className="flex items-center justify-between mt-2 text-xs text-muted-foreground">
<span>
Ackumulerat (12 mån): {formatSEK(report.thresholdStatus.cumulativeValue)} SEK
</span>
<span>
{report.thresholdStatus.percentageUsed}% av {formatSEK(report.thresholdStatus.threshold)} SEK
</span>
</div>
</CardContent>
</Card>
{/* ── KPI Row ──────────────────────────────────────── */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<Card>
<CardContent className="pt-6">
<p className="text-sm text-muted-foreground">Fakturerat värde</p>
<p className="text-2xl font-semibold tabular-nums mt-1">{formatSEK(report.totals.invoicedValue)}</p>
<p className="text-xs text-muted-foreground mt-0.5">SEK</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<p className="text-sm text-muted-foreground">Nettovikt</p>
<p className="text-2xl font-semibold tabular-nums mt-1">{report.totals.netMass.toLocaleString('sv-SE')}</p>
<p className="text-xs text-muted-foreground mt-0.5">kg</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<p className="text-sm text-muted-foreground">Deklarationsrader</p>
<p className="text-2xl font-semibold tabular-nums mt-1">{report.totals.lineCount}</p>
<p className="text-xs text-muted-foreground mt-0.5">{report.invoiceCount} fakturor</p>
</CardContent>
</Card>
</div>
{/* ── Product Registry ─────────────────────────────── */}
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-base flex items-center gap-2">
<Package className="h-4 w-4" />
Produktregister
</CardTitle>
<Button variant="outline" size="sm" onClick={openNewProduct}>
<Plus className="h-4 w-4 mr-1" />
Lägg till
</Button>
</div>
</CardHeader>
<CardContent>
{products.length === 0 ? (
<p className="text-sm text-muted-foreground py-6 text-center">
Inga produkter registrerade. Lägg till produkter med CN-kod och vikt för att generera Intrastat-deklarationer.
</p>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Produkt</TableHead>
<TableHead>CN-kod</TableHead>
<TableHead className="text-right">Vikt (kg)</TableHead>
<TableHead>Ursprung</TableHead>
<TableHead className="w-20" />
</TableRow>
</TableHeader>
<TableBody>
{products.map(p => (
<TableRow key={p.productId}>
<TableCell className="text-sm">
<div>
<span className="font-medium">{p.description || p.productId}</span>
{p.productId !== p.description && (
<span className="text-xs text-muted-foreground ml-1">({p.productId})</span>
)}
</div>
</TableCell>
<TableCell>
{p.cn_code ? (
<Badge variant="outline" className="font-mono text-xs">{p.cn_code}</Badge>
) : (
<span className="text-destructive text-xs flex items-center gap-1">
<AlertTriangle className="h-3 w-3" /> Saknas
</span>
)}
</TableCell>
<TableCell className="text-right font-mono text-sm tabular-nums">
{p.net_weight_kg !== null
? String(p.net_weight_kg)
: <span className="text-muted-foreground">—</span>
}
</TableCell>
<TableCell>
<Badge variant="outline" className="text-xs">
{p.country_of_origin}
</Badge>
</TableCell>
<TableCell>
<div className="flex gap-1 justify-end">
<Button variant="ghost" size="sm" onClick={() => openEditProduct(p.productId)}>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="sm" onClick={() => setDeleteConfirm(p.productId)}>
<Trash2 className="h-3.5 w-3.5 text-destructive" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
{/* ── Declaration Table ─────────────────────────────── */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">
Deklaration {MONTHS[month - 1]} {year}
</CardTitle>
</CardHeader>
<CardContent>
{report.lines.length === 0 ? (
<p className="text-sm text-muted-foreground py-6 text-center">
Inga EU-varuförsäljningar hittades för perioden.
</p>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>CN-kod</TableHead>
<TableHead>Land</TableHead>
<TableHead>Urspr.</TableHead>
<TableHead className="text-right">Värde (SEK)</TableHead>
<TableHead className="text-right">Vikt (kg)</TableHead>
<TableHead>Partner-VAT</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{report.lines.map((line, i) => (
<TableRow key={i}>
<TableCell>
<Badge
variant={line.cnCode === '00000000' ? 'destructive' : 'outline'}
className="font-mono text-xs"
>
{line.cnCode}
</Badge>
</TableCell>
<TableCell>
<Badge variant="outline" className="text-xs">{line.partnerCountry}</Badge>
</TableCell>
<TableCell className="text-xs">{line.countryOfOrigin}</TableCell>
<TableCell className="text-right font-mono text-sm tabular-nums">
{formatSEK(line.invoicedValue)}
</TableCell>
<TableCell className="text-right font-mono text-sm tabular-nums">
{line.netMass > 0 ? line.netMass.toLocaleString('sv-SE') : '—'}
</TableCell>
<TableCell className="font-mono text-xs">{line.partnerVatId || '—'}</TableCell>
</TableRow>
))}
<TableRow className="border-t-2 font-medium">
<TableCell colSpan={3} className="text-sm">Summa</TableCell>
<TableCell className="text-right font-mono text-sm tabular-nums">
{formatSEK(report.totals.invoicedValue)}
</TableCell>
<TableCell className="text-right font-mono text-sm tabular-nums">
{report.totals.netMass.toLocaleString('sv-SE')}
</TableCell>
<TableCell />
</TableRow>
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
{/* ── 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>
)}
{/* ── Deadline Footer ───────────────────────────────── */}
<div className="flex items-center gap-2 text-xs text-muted-foreground px-1">
<Clock className="h-3.5 w-3.5" />
<span>
Deadline: 10:e arbetsdagen efter redovisningsperiodens slut
</span>
</div>
</>
)}
{/* ── Product Dialog ────────────────────────────────────── */}
<Dialog open={productDialogOpen} onOpenChange={setProductDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{editingProduct ? 'Redigera produkt' : 'Lägg till produkt'}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{!editingProduct && (
<div className="space-y-1.5">
<Label htmlFor="productId">Produkt-ID (SKU)</Label>
<Input
id="productId"
value={productForm.productId}
onChange={e => setProductForm(f => ({ ...f, productId: e.target.value }))}
placeholder="T.ex. STALBALK-M8"
/>
</div>
)}
<div className="space-y-1.5">
<Label htmlFor="description">Beskrivning</Label>
<Input
id="description"
value={productForm.description}
onChange={e => setProductForm(f => ({ ...f, description: e.target.value }))}
placeholder="T.ex. Stålbalk M8 200mm"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="cnCode">CN-kod (8 siffror)</Label>
<Input
id="cnCode"
value={productForm.cnCode}
onChange={e => setProductForm(f => ({ ...f, cnCode: e.target.value.replace(/\D/g, '').slice(0, 8) }))}
placeholder="T.ex. 72163100"
maxLength={8}
className="font-mono"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="weight">Nettovikt per enhet (kg)</Label>
<Input
id="weight"
type="number"
step="0.001"
value={productForm.netWeightKg}
onChange={e => setProductForm(f => ({ ...f, netWeightKg: e.target.value }))}
placeholder="45.5"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="origin">Ursprungsland</Label>
<Input
id="origin"
value={productForm.countryOfOrigin}
onChange={e => setProductForm(f => ({ ...f, countryOfOrigin: e.target.value.toUpperCase().slice(0, 2) }))}
placeholder="SE"
maxLength={2}
/>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setProductDialogOpen(false)}>Avbryt</Button>
<Button
onClick={saveProduct}
disabled={!editingProduct && !productForm.productId.trim()}
>
{editingProduct ? 'Spara' : 'Lägg till'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* ── Delete Confirmation ───────────────────────────────── */}
<Dialog open={deleteConfirm !== null} onOpenChange={() => setDeleteConfirm(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Ta bort produkt?</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
Är du säker på att du vill ta bort produkten &ldquo;{deleteConfirm}&rdquo;? Denna åtgärd kan inte ångras.
</p>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteConfirm(null)}>Avbryt</Button>
<Button variant="destructive" onClick={() => deleteConfirm && deleteProduct(deleteConfirm)}>
Ta bort
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
@@ -0,0 +1,497 @@
'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, ChevronDown, ChevronUp,
ArrowUp, ArrowDown, Minus, BarChart3,
} from 'lucide-react'
import { cn } from '@/lib/utils'
// ── Types ─────────────────────────────────────────────────────
interface VatBoxData {
boxNumber: string
label: string
amount: number
accounts: string[]
}
interface RevenueBreakdown {
domestic: { amount: number; percentage: number }
euGoods: { amount: number; percentage: number }
euServices: { amount: number; percentage: number }
exportGoods: { amount: number; percentage: number }
exportServices: { amount: number; percentage: number }
triangular: { amount: number; percentage: number }
totalRevenue: number
}
interface PeriodDelta {
current: number
previous: number
change: number
changePercent: number | null
}
interface PeriodComparison {
domestic: PeriodDelta
euGoods: PeriodDelta
euServices: PeriodDelta
exportGoods: PeriodDelta
exportServices: PeriodDelta
triangular: PeriodDelta
totalRevenue: PeriodDelta
netVat: PeriodDelta
}
interface VatMonitorWarning {
type: string
severity: 'error' | 'warning'
invoiceId?: string
invoiceNumber?: string
customerName?: string
message: string
}
interface ReportData {
period: { year: number; month?: number; quarter?: number }
boxes: VatBoxData[]
revenueBreakdown: RevenueBreakdown
vatSummary: {
outputVat25: number
outputVat12: number
outputVat6: number
totalOutputVat: number
inputVat: number
netVat: number
isRefund: boolean
}
warnings: VatMonitorWarning[]
comparison: PeriodComparison | null
}
// ── Helpers ───────────────────────────────────────────────────
function formatSEK(amount: number): string {
return Math.round(amount).toLocaleString('sv-SE')
}
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)
}
// Revenue breakdown cards config
const REVENUE_CARDS: { key: keyof Omit<RevenueBreakdown, 'totalRevenue'>; label: string; compKey: keyof PeriodComparison }[] = [
{ key: 'domestic', label: 'Inrikes', compKey: 'domestic' },
{ key: 'euGoods', label: 'EU varor', compKey: 'euGoods' },
{ key: 'euServices', label: 'EU tjänster', compKey: 'euServices' },
{ key: 'exportGoods', label: 'Export varor', compKey: 'exportGoods' },
{ key: 'exportServices', label: 'Export tjänster', compKey: 'exportServices' },
{ key: 'triangular', label: 'Trepartshandel', compKey: 'triangular' },
]
// Box display order (only show relevant ones)
const DISPLAY_BOX_ORDER = ['05', '10', '11', '12', '35', '36', '38', '39', '40', '48', '49']
// ── Component ─────────────────────────────────────────────────
export default function VatMonitorWorkspace({ userId }: WorkspaceComponentProps) {
void userId
const [year, setYear] = useState(currentYear())
const [periodType, setPeriodType] = useState<'monthly' | 'quarterly'>('monthly')
const [month, setMonth] = useState(currentMonth())
const [quarter, setQuarter] = useState(currentQuarter())
const [compareEnabled, setCompareEnabled] = useState(true)
const [report, setReport] = useState<ReportData | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [warningsExpanded, setWarningsExpanded] = useState(false)
const years = useMemo(() => {
const cy = currentYear()
return [cy, cy - 1, cy - 2]
}, [])
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))
}
if (compareEnabled) {
params.set('compare', 'previous')
}
try {
const res = await fetch(`/api/extensions/export/vat-monitor/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, compareEnabled])
useEffect(() => {
fetchReport()
}, [fetchReport])
const errorCount = report?.warnings.filter(w => w.severity === 'error').length ?? 0
const warningCount = report?.warnings.filter(w => w.severity === 'warning').length ?? 0
// Filter boxes to only show ones in display order that have data or are always shown
const displayBoxes = useMemo(() => {
if (!report) return []
const boxMap = new Map(report.boxes.map(b => [b.boxNumber, b]))
return DISPLAY_BOX_ORDER
.map(num => boxMap.get(num))
.filter((b): b is VatBoxData => b !== undefined)
}, [report])
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>
<div className="ml-auto">
<Button
variant={compareEnabled ? 'default' : 'outline'}
size="sm"
onClick={() => setCompareEnabled(!compareEnabled)}
>
<BarChart3 className="h-4 w-4 mr-1.5" />
{compareEnabled ? 'Jämförelse på' : 'Jämför perioder'}
</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 && (
<>
{/* ── Revenue Breakdown Cards ──────────────────────── */}
<div>
<h3 className="text-sm font-medium text-muted-foreground mb-3">Intäktsfördelning</h3>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
{REVENUE_CARDS.map(({ key, label, compKey }) => {
const data = report.revenueBreakdown[key]
const delta = report.comparison?.[compKey]
return (
<Card key={key} className={cn(data.amount === 0 && 'opacity-50')}>
<CardContent className="pt-4 pb-3 px-4">
<p className="text-xs text-muted-foreground truncate">{label}</p>
<p className="text-lg font-semibold tabular-nums mt-0.5">
{formatSEK(data.amount)}
</p>
<div className="flex items-center justify-between mt-1">
<span className="text-xs text-muted-foreground">
{data.percentage}%
</span>
{delta && <DeltaIndicator delta={delta} />}
</div>
</CardContent>
</Card>
)
})}
</div>
</div>
{/* ── VAT Summary + Moms Box Table ─────────────────── */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
{/* VAT Summary cards */}
<div className="space-y-3">
<h3 className="text-sm font-medium text-muted-foreground">Moms</h3>
<KPICard
label="Utgående moms"
value={formatSEK(report.vatSummary.totalOutputVat)}
suffix="SEK"
/>
<KPICard
label="Ingående moms"
value={formatSEK(report.vatSummary.inputVat)}
suffix="SEK"
/>
<Card className={cn(
report.vatSummary.isRefund ? 'border-green-200 bg-green-50/50 dark:border-green-900 dark:bg-green-950/30' : ''
)}>
<CardContent className="pt-6">
<p className="text-sm text-muted-foreground">
{report.vatSummary.isRefund ? 'Moms att få tillbaka' : 'Moms att betala'}
</p>
<div className="flex items-baseline gap-1 mt-1">
<span className={cn(
'text-2xl font-semibold tracking-tight',
report.vatSummary.isRefund && 'text-green-700 dark:text-green-400'
)}>
{formatSEK(Math.abs(report.vatSummary.netVat))}
</span>
<span className="text-sm text-muted-foreground">SEK</span>
</div>
{report.comparison && (
<div className="mt-1">
<DeltaIndicator delta={report.comparison.netVat} invert />
</div>
)}
</CardContent>
</Card>
</div>
{/* Momsdeklaration preview table */}
<div className="lg:col-span-2">
<h3 className="text-sm font-medium text-muted-foreground mb-3">
Momsdeklaration (förhandsvisning)
</h3>
<Card>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-16">Ruta</TableHead>
<TableHead>Beskrivning</TableHead>
<TableHead className="text-right w-36">Belopp (SEK)</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{displayBoxes.map(box => {
const isNetVat = box.boxNumber === '49'
const isInputVat = box.boxNumber === '48'
return (
<TableRow
key={box.boxNumber}
className={cn(isNetVat && 'font-medium border-t-2')}
>
<TableCell>
<Badge
variant={isNetVat ? 'default' : isInputVat ? 'secondary' : 'outline'}
className="font-mono text-xs"
>
{box.boxNumber}
</Badge>
</TableCell>
<TableCell className="text-sm">{box.label}</TableCell>
<TableCell className="text-right font-mono text-sm tabular-nums">
{formatSEK(box.amount)}
</TableCell>
</TableRow>
)
})}
{displayBoxes.length === 0 && (
<TableRow>
<TableCell colSpan={3} className="text-center text-muted-foreground py-8">
Ingen bokföringsdata för perioden.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
</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>
)}
{/* ── Total Revenue Footer ─────────────────────────── */}
<div className="flex items-center justify-between text-xs text-muted-foreground px-1">
<span>
Total omsättning: {formatSEK(report.revenueBreakdown.totalRevenue)} SEK
</span>
<span>
{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 DeltaIndicator({ delta, invert = false }: { delta: PeriodDelta; invert?: boolean }) {
if (delta.changePercent === null || delta.change === 0) {
return (
<span className="inline-flex items-center gap-0.5 text-xs text-muted-foreground">
<Minus className="h-3 w-3" />
</span>
)
}
// For most metrics, positive = green (revenue growing)
// For netVat (invert=true), positive = red (paying more VAT)
const isPositive = delta.change > 0
const isGood = invert ? !isPositive : isPositive
return (
<span className={cn(
'inline-flex items-center gap-0.5 text-xs',
isGood ? 'text-green-600' : 'text-red-600'
)}>
{isPositive
? <ArrowUp className="h-3 w-3" />
: <ArrowDown className="h-3 w-3" />
}
<span>{delta.changePercent > 0 ? '+' : ''}{delta.changePercent}%</span>
</span>
)
}