Files
accounted/components/extensions/export/EuSalesListWorkspace.tsx
T
EmilandClaude Opus 4.6 0764bc11a6 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>
2026-02-24 14:10:16 +01:00

649 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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>
)
}