'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 = { 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(null) const [isLoading, setIsLoading] = useState(true) const [error, setError] = useState(null) const [refreshing, setRefreshing] = useState(false) const [sortField, setSortField] = useState('unrealizedGainLoss') const [sortDir, setSortDir] = useState('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 } return (
{/* ── Header ─────────────────────────────────────── */}
{error && (

{error}

)} {report && ( <> {/* ── Exchange Rates ──────────────────────────── */}

Växelkurser

{report.referenceDate}
{report.exchangeRates.map(r => (
{r.currency}: {formatAmount(r.rate, 4)}
))}
{/* ── Exposure Cards ─────────────────────────── */} {report.exposureByCurrency.length > 0 ? (
{report.exposureByCurrency.map(exp => ( ))} {/* Total card */}
Totalt {report.totals.receivableCount} fakturor

{formatSEK(report.totals.currentSekValue)}

SEK (aktuell kurs)

) : (

Inga öppna fordringar i utländsk valuta.

)} {/* ── Receivables Table ──────────────────────── */} {sortedReceivables.length > 0 && ( Öppna fordringar
Faktura Valuta Bokfört (SEK) Aktuellt (SEK) {sortedReceivables.map(r => ( {r.invoiceNumber}
{r.customerName} {r.customerCountry && ( {r.customerCountry} )}
{r.currency} {currencySymbol(r.currency)}{formatAmount(r.foreignAmount)} {formatSEK(r.bookedSekAmount)} {formatSEK(r.currentSekAmount)} 30 ? 'text-destructive font-medium' : r.daysOutstanding > 14 ? 'text-warning-foreground' : '' )}> {r.daysOutstanding}
))}
)} {/* ── Realized FX Trend ──────────────────────── */} Realiserade kursdifferenser {year} {activeTrend.length === 0 ? (

Inga realiserade kursdifferenser för {year}.

) : (
Månad Vinst (3960) Förlust (7960) Netto {activeTrend.map(t => ( {monthLabel(t.month)} {t.realizedGains > 0 ? `+${formatSEK(t.realizedGains)}` : '—'} {t.realizedLosses > 0 ? `-${formatSEK(t.realizedLosses)}` : '—'} ))} {/* Totals row */} Totalt {year} +{formatSEK(report.realizedGainLoss.gains)} -{formatSEK(report.realizedGainLoss.losses)}
)}
{/* ── Revaluation Preview ────────────────────── */} {report.receivables.length > 0 && (

Omvärdering vid periodbokslut

Om bokslut görs idag: netto orealiserad{' '} = 0 ? 'text-green-600' : 'text-red-600' )}> {report.revalPreview.totalUnrealizedGainLoss >= 0 ? 'vinst' : 'förlust'}{' '} {report.revalPreview.totalUnrealizedGainLoss >= 0 ? '+' : ''} {formatSEK(report.revalPreview.totalUnrealizedGainLoss)} SEK

{report.revalPreview.gains > 0 && (

Konto 3969 (orealiserad kursvinst): {formatSEK(report.revalPreview.gains)} kr

)} {report.revalPreview.losses > 0 && (

Konto 7969 (orealiserad kursförlust): {formatSEK(report.revalPreview.losses)} kr

)}

Bokföringsposterna skapas inte av detta tillägg. Använd värdena ovan som underlag vid periodbokslut.

)} )}
) } // ── Sub-components ──────────────────────────────────────────── function ExposureCard({ exposure }: { exposure: CurrencyExposure }) { const sym = currencySymbol(exposure.currency) return (
{exposure.currency} {exposure.invoiceCount} fakturor

{sym}{formatAmount(exposure.totalForeignAmount)}

{formatSEK(exposure.currentSekValue)} SEK

Bokförd kurs: {formatAmount(exposure.averageBookedRate, 4)} Aktuell: {formatAmount(exposure.currentRate, 4)}
) } function FXIndicator({ label, amount }: { label: string; amount: number }) { const isGain = amount >= 0 return (
{label}
{isGain ? : } {isGain ? '+' : ''}{formatSEK(amount)} kr
) } function FXBadge({ amount }: { amount: number }) { if (amount === 0) return — const isGain = amount > 0 return ( {isGain ? '+' : ''}{formatSEK(amount)} ) } 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 ( ) }