Files
accounted/components/extensions/export/EuSalesListWorkspace.tsx
T
2026-02-24 16:06:36 +01:00

778 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import { useState, useEffect, useMemo, useCallback } from 'react'
import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
import { 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 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,
FlaskConical,
} 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)
}
// ── Mock Data Config ──────────────────────────────────────────
const MOCK_CSV_FIELDS: CsvFieldDef[] = [
{ key: 'customerVatNumber', label: 'VAT-nummer', required: true },
{ key: 'customerName', label: 'Kundnamn', required: true },
{ key: 'customerCountry', label: 'Land', required: true },
{ key: 'goodsAmount', label: 'Varor (SEK)' },
{ key: 'servicesAmount', label: 'Tjänster (SEK)' },
{ key: 'triangulationAmount', label: 'Trepartshandel (SEK)' },
{ key: 'invoiceCount', label: 'Antal fakturor' },
]
const MOCK_CSV_TEMPLATE = `customerVatNumber;customerName;customerCountry;goodsAmount;servicesAmount;triangulationAmount;invoiceCount
DE123456789;Beispiel GmbH;DE;150000;25000;0;3
FR987654321;Exemple SARL;FR;0;80000;0;2
NL456789012;Voorbeeld BV;NL;45000;0;12000;1`
function parseMockCsvRows(rows: Record<string, string>[]): ReportData {
const lines: ECSalesListLine[] = rows.map(r => ({
customerVatNumber: r.customerVatNumber || '',
customerName: r.customerName || '',
customerCountry: r.customerCountry || '',
customerId: r.customerVatNumber || '',
goodsAmount: parseFloat(r.goodsAmount || '0') || 0,
servicesAmount: parseFloat(r.servicesAmount || '0') || 0,
triangulationAmount: parseFloat(r.triangulationAmount || '0') || 0,
invoiceCount: parseInt(r.invoiceCount || '1', 10) || 1,
}))
const goods = lines.reduce((s, l) => s + l.goodsAmount, 0)
const services = lines.reduce((s, l) => s + l.servicesAmount, 0)
const triangulation = lines.reduce((s, l) => s + l.triangulationAmount, 0)
const invoiceCount = lines.reduce((s, l) => s + l.invoiceCount, 0)
return {
period: { year: new Date().getFullYear(), quarter: Math.ceil((new Date().getMonth() + 1) / 3) },
filingType: 'quarterly',
reporterVatNumber: 'SE000000000001',
reporterName: 'Testdata',
lines,
totals: { goods, services, triangulation, total: goods + services + triangulation },
warnings: [],
crossCheck: null,
invoiceCount,
customerCount: lines.length,
deadline: new Date(Date.now() + 30 * 86400000).toISOString().slice(0, 10),
daysUntilDeadline: 30,
}
}
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<string, unknown>
if (!Array.isArray(obj.lines)) return { valid: false, error: 'Fältet "lines" saknas eller är inte en array' }
if (!obj.totals || typeof obj.totals !== 'object') return { valid: false, error: 'Fältet "totals" saknas' }
return { valid: true }
}
// ── Component ─────────────────────────────────────────────────
export default function EuSalesListWorkspace({ userId }: WorkspaceComponentProps) {
void userId
// Mock data
const { mockReport, isMockActive, isLoading: mockLoading, importedAt, saveMockData, clearMockData } = useMockData<ReportData>('export', 'eu-sales-list')
const [importDialogOpen, setImportDialogOpen] = useState(false)
// 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 () => {
if (isMockActive && mockReport) {
setReport(mockReport)
setIsLoading(false)
return
}
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, 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)
// Re-fetch from API
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()
setReport(json.data)
}
} catch { /* ignore */ }
setIsLoading(false)
}, [clearMockData, year, month, quarter, periodType])
// 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 || mockLoading) && !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 + Import buttons */}
<div className="flex gap-2 ml-auto">
<Button
variant="outline"
size="sm"
onClick={() => setImportDialogOpen(true)}
>
<FlaskConical className="h-4 w-4 mr-1.5" />
Importera testdata
</Button>
<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>
{/* ── Mock Data Banner ──────────────────────────────── */}
{isMockActive && (
<MockDataBanner
importedAt={importedAt}
onClear={handleMockClear}
onReplace={() => setImportDialogOpen(true)}
/>
)}
{/* ── 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>
</>
)}
{/* ── Mock Data Import Dialog ───────────────────────── */}
<MockDataImportDialog<ReportData>
open={importDialogOpen}
onOpenChange={setImportDialogOpen}
csvFields={MOCK_CSV_FIELDS}
parseCsvRows={parseMockCsvRows}
validateReport={validateMockReport}
templateCsvContent={MOCK_CSV_TEMPLATE}
templateFileName="eu-sales-list-template.csv"
onImport={handleMockImport}
/>
</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>
)
}