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:
@@ -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 “{deleteConfirm}”? 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user