'use client' import { useEffect, useState } from 'react' import Link from 'next/link' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { Label } from '@/components/ui/label' import { EmptyState } from '@/components/ui/empty-state' import { Skeleton } from '@/components/ui/skeleton' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select' import { Table, TableHeader, TableHead, TableRow, TableCell, TableBody, } from '@/components/ui/table' import { Download, AlertCircle, AlertTriangle, CheckCircle2, FileText, ExternalLink, RefreshCw, } from 'lucide-react' import { formatCurrency } from '@/lib/utils' import type { PeriodiskSammanstallningReport, PsPeriodType, PsWarning, } from '@/lib/reports/periodisk-sammanstallning' function formatAmount(amount: number): string { // Hela kronor: SKV 5740 har inga öre. return amount.toLocaleString('sv-SE', { maximumFractionDigits: 0 }) } /** API errors may be a plain string or the canonical { code, message } envelope. */ function parseApiError(error: unknown, fallback: string): string { if (typeof error === 'string') return error if (error && typeof error === 'object') { const message = (error as { message?: unknown }).message if (typeof message === 'string') return message } return fallback } function typeBadge(row: { services: number; goods: number; triangulation: number }) { const types: string[] = [] if (row.services !== 0) types.push('Tjänster') if (row.goods !== 0) types.push('Varor') if (row.triangulation !== 0) types.push('Trepart') return types.join(' + ') || '-' } function deadlineLabel(end: string): string { // Inlämnas digitalt senast den 25:e månaden efter perioden. const endDate = new Date(end) const deadline = new Date(endDate.getFullYear(), endDate.getMonth() + 1, 25) const months = [ 'januari', 'februari', 'mars', 'april', 'maj', 'juni', 'juli', 'augusti', 'september', 'oktober', 'november', 'december', ] return `${deadline.getDate()} ${months[deadline.getMonth()]} ${deadline.getFullYear()}` } export function PeriodiskSammanstallningView() { const currentYear = new Date().getFullYear() const currentMonth = new Date().getMonth() + 1 const currentQuarter = Math.ceil(currentMonth / 3) const [periodType, setPeriodType] = useState('quarterly') const [year, setYear] = useState(currentYear) const [period, setPeriod] = useState(currentQuarter) // Fetch outcome tagged with the key it was requested under: the report // follows the picker automatically and stale responses are discarded // (same pattern as the momsdeklaration view). const [result, setResult] = useState<{ key: string data?: PeriodiskSammanstallningReport error?: string } | null>(null) const [retryKey, setRetryKey] = useState(0) const yearOptions = Array.from({ length: 5 }, (_, i) => currentYear - i) const periodOptions = periodType === 'monthly' ? [ { value: 1, label: 'Januari' }, { value: 2, label: 'Februari' }, { value: 3, label: 'Mars' }, { value: 4, label: 'April' }, { value: 5, label: 'Maj' }, { value: 6, label: 'Juni' }, { value: 7, label: 'Juli' }, { value: 8, label: 'Augusti' }, { value: 9, label: 'September' }, { value: 10, label: 'Oktober' }, { value: 11, label: 'November' }, { value: 12, label: 'December' }, ] : [ { value: 1, label: 'Kvartal 1 (jan-mar)' }, { value: 2, label: 'Kvartal 2 (apr-jun)' }, { value: 3, label: 'Kvartal 3 (jul-sep)' }, { value: 4, label: 'Kvartal 4 (okt-dec)' }, ] // Switching periodicity resets the period to "now" in the new unit, in the // change handler so the auto-fetch never sees an inconsistent pair. const handlePeriodTypeChange = (value: PsPeriodType) => { setPeriodType(value) setPeriod(value === 'monthly' ? currentMonth : currentQuarter) } const fetchKey = `${periodType}:${year}:${period}:${retryKey}` useEffect(() => { let cancelled = false fetch( `/api/reports/periodisk-sammanstallning?periodType=${periodType}&year=${year}&period=${period}`, ) .then(async (res) => { const json = await res.json().catch(() => null) if (cancelled) return if (!res.ok || json?.error) { setResult({ key: fetchKey, error: parseApiError(json?.error, 'Kunde inte hämta periodisk sammanställning.'), }) } else { setResult({ key: fetchKey, data: json.data }) } }) .catch(() => { if (!cancelled) { setResult({ key: fetchKey, error: 'Kunde inte hämta periodisk sammanställning.' }) } }) return () => { cancelled = true } }, [fetchKey, periodType, year, period]) const upToDate = result !== null && result.key === fetchKey const data = result?.data ?? null const error = upToDate ? (result.error ?? null) : null const loading = !upToDate const downloadCsv = () => { window.open( `/api/reports/periodisk-sammanstallning/csv?periodType=${periodType}&year=${year}&period=${period}`, '_blank', ) } const errors = data?.warnings.filter(w => w.level === 'error') ?? [] const cautions = data?.warnings.filter(w => w.level === 'warning') ?? [] const hasBlockingErrors = errors.length > 0 return (
{/* Period selection: the report below follows it automatically */}
{/* Re-runs the report for the SAME period: after fixing VAT numbers or bookings elsewhere, the blocking checks must be re-checkable without toggling the period away and back. */}
{error && ( {error} )} {!error && loading && !data && ( )} {data && !error && (
{/* Summary */}
Periodisk sammanställning: {data.period.label} {data.totals.rowCount > 0 && ( {data.totals.rowCount} {data.totals.rowCount === 1 ? 'rad' : 'rader'} )}
Tjänster (typ 3)
{formatAmount(data.totals.services)} kr
Varor (typ 1)
{formatAmount(data.totals.goods)} kr
Trepart (typ 2)
{formatAmount(data.totals.triangulation)} kr
{/* Reconciliation */} {data.reconciliation.matches !== null && data.totals.rowCount > 0 && (
{data.reconciliation.matches ? ( Stämmer mot momsdeklarationen {' '}(Ruta 39: {formatAmount(data.reconciliation.ruta39 ?? 0)} kr, {' '}Ruta 35: {formatAmount(data.reconciliation.ruta35 ?? 0)} kr, {' '}Ruta 38: {formatAmount(data.reconciliation.ruta38 ?? 0)} kr) ) : ( Avviker från momsdeklarationen: kontrollera bokföringen. {' '}Ruta 39: {formatAmount(data.reconciliation.ruta39 ?? 0)} kr, {' '}Ruta 35: {formatAmount(data.reconciliation.ruta35 ?? 0)} kr, {' '}Ruta 38: {formatAmount(data.reconciliation.ruta38 ?? 0)} kr. )}
)} {data.reconciliation.matches === null && data.totals.rowCount > 0 && (
Avstämning mot momsdeklarationen tillgänglig endast när PS-perioden sammanfaller med momsperioden.
)} {/* Deadline banner */} {data.totals.rowCount > 0 && (
Inlämnas digitalt senast den {deadlineLabel(data.period.end)} {' '}via e-tjänsten hos Skatteverket. Förseningsavgift: 1 250 kr.
)}
{/* Errors */} {errors.length > 0 && ( {errors.length} fel måste åtgärdas {errors.map((w, i) => )}
CSV-nedladdning är blockerad tills alla fel åtgärdats.
)} {/* Warnings */} {cautions.length > 0 && ( Varningar ({cautions.length}) {cautions.map((w, i) => )} )} {/* Table */} {data.rows.length > 0 ? ( Rader
{hasBlockingErrors && (

Åtgärda felen ovan innan CSV kan laddas ner.

)}
Land VAT-nummer Tjänster Varor Trepart Typ Kund {data.rows.map((row, i) => ( {row.country} {row.vatNumber} {row.services !== 0 ? formatAmount(row.services) : '-'} {row.goods !== 0 ? formatAmount(row.goods) : '-'} {row.triangulation !== 0 ? formatAmount(row.triangulation) : '-'} {typeBadge(row)} {row.customerId ? {row.customerName ?? '-'} : - } ))} Totalt {formatAmount(data.totals.services)} {formatAmount(data.totals.goods)} {formatAmount(data.totals.triangulation)}
) : ( Läs mer hos Skatteverket )}
)}
) } function WarningItem({ warning }: { warning: PsWarning }) { return (
{warning.message}
{(warning.customerId || warning.invoiceId) && (
{warning.customerId && ( Öppna kund )} {warning.invoiceId && ( Öppna faktura )} {warning.amount !== undefined && ( {formatCurrency(warning.amount)} )}
)}
) }