'use client' import { useState, useEffect, useCallback } from 'react' import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry' import { useMockData } from '@/lib/extensions/use-mock-data' import ExtensionLoadingSkeleton from '@/components/extensions/shared/ExtensionLoadingSkeleton' import MockDataBanner from '@/components/extensions/shared/MockDataBanner' import MockDataImportDialog from '@/components/extensions/shared/MockDataImportDialog' import type { CsvFieldDef } from '@/components/extensions/shared/MockDataImportDialog' 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, FlaskConical, } 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' // ── Mock Data Config ────────────────────────────────────────── const MOCK_CSV_FIELDS: CsvFieldDef[] = [ { key: 'invoiceNumber', label: 'Fakturanummer', required: true }, { key: 'customerName', label: 'Kund', required: true }, { key: 'currency', label: 'Valuta', required: true }, { key: 'foreignAmount', label: 'Belopp (utl. valuta)', required: true }, { key: 'bookedSekAmount', label: 'Bokfört (SEK)' }, { key: 'bookedRate', label: 'Bokförd kurs' }, { key: 'currentSekAmount', label: 'Aktuellt (SEK)' }, { key: 'currentRate', label: 'Aktuell kurs' }, { key: 'invoiceDate', label: 'Fakturadatum' }, { key: 'dueDate', label: 'Förfallodatum' }, ] const MOCK_CSV_TEMPLATE = `invoiceNumber;customerName;currency;foreignAmount;bookedSekAmount;bookedRate;currentSekAmount;currentRate;invoiceDate;dueDate 1001;Beispiel GmbH;EUR;10000;112500;11.25;114200;11.42;2025-01-15;2025-02-15 1002;Example Corp;USD;25000;262500;10.50;260000;10.40;2025-01-20;2025-02-20 1003;London Ltd;GBP;8000;106400;13.30;108000;13.50;2025-02-01;2025-03-01` function parseMockCsvRows(rows: Record[]): ReportData { const today = new Date().toISOString().slice(0, 10) const receivables: ForeignReceivable[] = rows.map(r => { const foreignAmount = parseFloat(r.foreignAmount || '0') || 0 const bookedRate = parseFloat(r.bookedRate || '0') || 0 const currentRate = parseFloat(r.currentRate || '0') || bookedRate const bookedSek = parseFloat(r.bookedSekAmount || '0') || Math.round(foreignAmount * bookedRate * 100) / 100 const currentSek = parseFloat(r.currentSekAmount || '0') || Math.round(foreignAmount * currentRate * 100) / 100 const invoiceDate = r.invoiceDate || today const dueDate = r.dueDate || today const daysOutstanding = Math.max(0, Math.floor((Date.now() - new Date(invoiceDate).getTime()) / 86400000)) return { invoiceId: r.invoiceNumber || '', invoiceNumber: r.invoiceNumber || '', customerName: r.customerName || '', customerCountry: '', currency: r.currency || 'EUR', foreignAmount, bookedSekAmount: bookedSek, bookedRate, currentSekAmount: currentSek, currentRate, unrealizedGainLoss: Math.round((currentSek - bookedSek) * 100) / 100, invoiceDate, dueDate, daysOutstanding, } }) // Group by currency for exposure const currencyMap = new Map() for (const r of receivables) { const existing = currencyMap.get(r.currency) if (existing) { existing.totalForeignAmount += r.foreignAmount existing.bookedSekValue += r.bookedSekAmount existing.currentSekValue += r.currentSekAmount existing.unrealizedGainLoss += r.unrealizedGainLoss existing.invoiceCount++ } else { currencyMap.set(r.currency, { currency: r.currency, totalForeignAmount: r.foreignAmount, bookedSekValue: r.bookedSekAmount, currentSekValue: r.currentSekAmount, unrealizedGainLoss: r.unrealizedGainLoss, invoiceCount: 1, averageBookedRate: r.bookedRate, currentRate: r.currentRate, }) } } const exposureByCurrency = Array.from(currencyMap.values()) const totalBookedSek = receivables.reduce((s, r) => s + r.bookedSekAmount, 0) const totalCurrentSek = receivables.reduce((s, r) => s + r.currentSekAmount, 0) const totalUnrealized = Math.round((totalCurrentSek - totalBookedSek) * 100) / 100 return { referenceDate: today, exchangeRates: exposureByCurrency.map(e => ({ currency: e.currency, rate: e.currentRate, date: today })), exposureByCurrency, receivables, realizedGainLoss: { year: new Date().getFullYear(), gains: 0, losses: 0, net: 0 }, monthlyTrend: [], revalPreview: { totalUnrealizedGainLoss: totalUnrealized, gains: Math.max(0, totalUnrealized), losses: Math.abs(Math.min(0, totalUnrealized)), }, totals: { bookedSekValue: totalBookedSek, currentSekValue: totalCurrentSek, totalUnrealizedGainLoss: totalUnrealized, receivableCount: receivables.length, currencyCount: exposureByCurrency.length, }, } } function validateMockReport(data: unknown): { valid: boolean; error?: string } { if (!data || typeof data !== 'object') return { valid: false, error: 'Data måste vara ett objekt' } const obj = data as Record if (!Array.isArray(obj.receivables) && !Array.isArray(obj.exposureByCurrency)) { return { valid: false, error: 'Fältet "receivables" eller "exposureByCurrency" saknas' } } if (!obj.totals || typeof obj.totals !== 'object') return { valid: false, error: 'Fältet "totals" saknas' } return { valid: true } } // ── Component ───────────────────────────────────────────────── export default function CurrencyReceivablesWorkspace({ userId }: WorkspaceComponentProps) { void userId // Mock data const { mockReport, isMockActive, isLoading: mockLoading, importedAt, saveMockData, clearMockData } = useMockData('export', 'currency-receivables') const [importDialogOpen, setImportDialogOpen] = useState(false) 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 () => { if (isMockActive && mockReport) { setReport(mockReport) setIsLoading(false) setRefreshing(false) return } 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, isMockActive, mockReport]) useEffect(() => { fetchReport() }, [fetchReport]) const handleMockImport = useCallback(async (data: ReportData, meta: { source: 'csv' | 'json'; fileName: string; rowCount: number }) => { await saveMockData(data, meta) setReport(data) }, [saveMockData]) const handleMockClear = useCallback(async () => { await clearMockData() setReport(null) setIsLoading(true) 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() setReport(json.data) } } catch { /* ignore */ } setIsLoading(false) }, [clearMockData, year]) 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 || mockLoading) && !report) { return } return (
{/* ── Header ─────────────────────────────────────── */}
{/* ── Mock Data Banner ──────────────────────────────── */} {isMockActive && ( setImportDialogOpen(true)} /> )} {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.

)} )} {/* ── Mock Data Import Dialog ───────────────────────── */} open={importDialogOpen} onOpenChange={setImportDialogOpen} csvFields={MOCK_CSV_FIELDS} parseCsvRows={parseMockCsvRows} validateReport={validateMockReport} templateCsvContent={MOCK_CSV_TEMPLATE} templateFileName="currency-receivables-template.csv" onImport={handleMockImport} />
) } // ── 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 ( ) }