refactor(reports): restructure declaration pages around the filing pipeline (#992)
* refactor(reports): restructure declaration pages around the filing pipeline Momsdeklaration (/reports/vat-declaration) becomes the four-step flow the user actually runs: kontrollera, granska, bokfoer, laemna in. - NEW VatChecksCard, mounted first and ungated: the local pre-flight checks and the RC-basis-gap worklist used to render inside SkatteverketPanel BELOW the filing CTAs, and vanished entirely for free-tier or not-connected users, exactly the manual filers who must not file a declaration the checks would have blocked. - The gap worklist scales: compact DataList rows (first 8 + visa alla), visible shared classification selects, per-row overrides, bulk "Korrigera alla" behind a confirm dialog with serial progress, and an in-page declaration refetch replacing "Ladda om sidan". The list outlives the aggregate RC_BASIS_MISSING check so remaining rows never vanish after the first fix. - Summary card: status Badge + font-display headline amount instead of a Badge carrying the number; sanctioned h3 section heads; Table primitive; NEW import block (rutor 50/60-62) and the utgaende sum now includes 60-62 so it matches ruta 49 arithmetic; drill-downs preserved. - SkatteverketPanel stops being an API console: three forward buttons (Validera, Spara utkast, Laas och signera), six lookup/recovery actions in an overflow menu with two-line descriptions, destructive confirms on radera/koppla bort, one truthful notice slot (not-found is info, never green), visible disabled-reasons instead of title attrs, signing link as a real anchor plus auto re-check of inlaemning on tab refocus, and per-period state reset so a Q1 signing link can never show Q4's kvittens. - VatCompositionChart deleted (decorative donut mixing in/out VAT); export menu keeps xlsx only, XML/PDF live in the Laemna in card. - Sibling declaration pages adopt the same grammar: PS gets shadcn selects, auto-fetch with stale-discard, refresh button, envelope-parse fix (message_sv never existed); NE/INK2 auto-fetch on fiscal-year change (kills stale-year data), shared keyboard-accessible DeclarationRutaRow (fixes the expense sign bug), whole-krona amounts matching filed SRU values, neutral info notes instead of bg-primary/10, Skeleton loading, accessible download errors. UI-only: no API, schema, or dependency changes. All strings hardcoded Swedish (statutory surface). Adversarially reviewed (19-agent pass); all 10 confirmed findings fixed in this commit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(reports): address CodeRabbit review on #992 - formatWholeKronor truncates instead of rounds: NE/INK2 SRU generators drop oere with Math.trunc, and the UI must show the filed figures. - SkatteverketPanel disconnect surfaces non-ok responses instead of silently stopping the spinner. - VatChecksCard distinguishes a failed rc-basis-gaps fetch from a real zero-gap result: destructive note + retry instead of the benign 'Inga verifikationer hittades'. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
650c7be5e1
commit
0ef5593388
@@ -66,3 +66,6 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-07-11] Counterparty template learning repair (#865): fixed the dead write path with ALTER COLUMN user_id DROP NOT NULL (kept the column and its data; a column drop is a separate cleanup) instead of re-plumbing user_id through the insert, because scoping is company_id-only since the multi-tenant refactor and RLS never reads user_id. Sign-mismatched matches (refund against an expense-learned template) are MIRRORED + requires_review rather than skipped: the swapped entry (debit bank / credit expense, VAT leg reversed) is the bookkeeping-correct refund shape, and skipping would just fall through to the dumb default; direction_mismatch results and opposite-direction "corrections" never write back into the template so a refund cannot flip the learned accounts. SIE extraction infers the 2641 VAT rate from voucher amounts (snap to 25/12/6% within 1.5pp, else drop the VAT leg) only when the voucher has exactly one deductible-VAT line: with several, each line's base is unknowable and the old 25% hardcode stays.
|
||||
[2026-07-11] Counterparty template follow-ups from the compliance-bot review of PR #989: RC exclusion set extended with import output-VAT accounts (2615/2625/2635); RC credit notes now mirror both fiktiv legs (credit 2645 / debit 2614, income line-builder nets VAT credits against debits) so Ruta 30/48 net to zero instead of leaving the prior RC output unreversed. The 1.5pp snap tolerance is derived from the smallest gap between legal rates (6pp between 6% and 12%): 1.5pp accepts ore-rounding drift on small vouchers while an ambiguous observed rate (e.g. 9%) snaps to nothing and drops the VAT leg. Livsmedel 12->6% transition (April 2026): templates store the LEARNED rate, so one applied to a backdated pre-transition purchase books the new rate; accepted, the booking is review-visible and re-deriving rate-by-date is out of scope here.
|
||||
[2026-07-11] Compliance-review round 2 on PR #989: (1) stale reduced_12 templates are review-gated across the livsmedel 12->6% transition (verified via swedish-vat skill: food dropped 2026-04-01, restaurang/hotell stay 12%) only when last_seen_date predates the transition, so actively-confirmed 12% counterparties keep flowing while pre-transition grocery templates get a human look; chose this over the bot's blanket flag-all-reduced_12 (too much friction) and over rate-by-date re-derivation (needs a rates table keyed on statute dates, out of scope). (2) Import-RC credit-note mirroring books the reversal on 2614 rather than 2615 (ruta 30 vs 60 attribution): accepted as-is because the entry balances, is review-gated, and the FORWARD legacy path has the same limitation (legacy fields cannot carry which output account history used); proper fix is persisting the learned RC output account, filed as future work.
|
||||
[2026-07-11] Momsdeklaration UI overhaul: deleted VatCompositionChart (donut mixed utgående/omvänd/ingående moms as slices of one pie, answering no filing question) and reduced the VAT ReportExportMenu to xlsx-only (XML/PDF are filing artifacts, now owned solely by the "Lämna in" card): both are one-commit reverts if vetoed.
|
||||
[2026-07-11] Hoisted local VAT checks + RC-gap worklist out of SkatteverketPanel into ungated VatChecksCard: the panel's paywall/not-connected early-returns hid compliance errors from exactly the users who file manually.
|
||||
[2026-07-11] NE/INK2 amounts display in whole kronor (matches filed SRU values per SFL); momsdeklaration keeps öre (reconciles against ledger and settlement verifikat). Numbered h2 section headers instead of a stepper component on the VAT page: same sequencing legibility, a tenth of the diff.
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
'use client'
|
||||
|
||||
import { useId, useState } from 'react'
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react'
|
||||
import { AccountNumber } from '@/components/ui/account-number'
|
||||
|
||||
export interface DeclarationAccountLine {
|
||||
accountNumber: string
|
||||
accountName: string
|
||||
amount: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Default formatter: whole kronor, matching the filed SRU values. Truncation,
|
||||
* not rounding: SFL "öretal faller bort" and the NE/INK2 SRU generators drop
|
||||
* öre with Math.trunc, so the UI must agree with the filed figures. Callers
|
||||
* that need öre pass their own formatter.
|
||||
*/
|
||||
export function formatWholeKronor(n: number): string {
|
||||
// + 0 normalizes -0 (Math.trunc(-0.3) is -0, which sv-SE renders "−0").
|
||||
return `${(Math.trunc(n) + 0).toLocaleString('sv-SE')} kr`
|
||||
}
|
||||
|
||||
/**
|
||||
* One expandable declaration row (NE-bilaga, INK2): ruta code chip, label,
|
||||
* signed amount, and a per-account breakdown behind a keyboard-accessible
|
||||
* toggle. Replaces the copy-pasted <tr onClick> rows that had no keyboard
|
||||
* path, no aria-expanded, and double-encoded signs.
|
||||
*
|
||||
* `amount` is the SIGNED display value: expense callers pass the negated
|
||||
* value instead of an isExpense flag, so a credit-balance expense renders
|
||||
* with its true sign. Composes inside the Table primitive's TableBody.
|
||||
*/
|
||||
export function DeclarationRutaRow({
|
||||
code,
|
||||
label,
|
||||
amount,
|
||||
accounts = [],
|
||||
hideWhenZero = true,
|
||||
formatAmount = formatWholeKronor,
|
||||
}: {
|
||||
code: string
|
||||
label: string
|
||||
amount: number
|
||||
accounts?: DeclarationAccountLine[]
|
||||
hideWhenZero?: boolean
|
||||
formatAmount?: (n: number) => string
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const panelId = useId()
|
||||
|
||||
if (amount === 0 && accounts.length === 0 && hideWhenZero) return null
|
||||
|
||||
const hasAccounts = accounts.length > 0
|
||||
|
||||
return (
|
||||
<>
|
||||
<tr
|
||||
className={`border-b ${hasAccounts ? 'cursor-pointer hover:bg-muted/50 transition-colors' : ''}`}
|
||||
onClick={() => hasAccounts && setExpanded((v) => !v)}
|
||||
>
|
||||
<td className="py-2">
|
||||
{hasAccounts && (
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={expanded}
|
||||
aria-controls={panelId}
|
||||
aria-label={expanded ? `Dölj konton för ${code}` : `Visa konton för ${code}`}
|
||||
className="mr-1 inline-flex h-6 w-6 items-center justify-center rounded align-middle hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setExpanded((v) => !v)
|
||||
}}
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<span className="font-mono text-xs bg-muted px-1 rounded mr-2">{code}</span>
|
||||
{label}
|
||||
{hasAccounts && (
|
||||
<span className="text-xs text-muted-foreground ml-2">
|
||||
({accounts.length} konton)
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 text-right tabular-nums">{formatAmount(amount)}</td>
|
||||
</tr>
|
||||
{expanded && hasAccounts && (
|
||||
<tr id={panelId}>
|
||||
<td colSpan={2} className="py-2 pl-8 bg-muted/20">
|
||||
<table className="w-full text-xs">
|
||||
<tbody>
|
||||
{accounts.map((acc) => (
|
||||
<tr key={acc.accountNumber}>
|
||||
<td className="py-1">
|
||||
<AccountNumber
|
||||
number={acc.accountNumber}
|
||||
name={acc.accountName}
|
||||
size="sm"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-1">{acc.accountName}</td>
|
||||
<td className="py-1 text-right tabular-nums">
|
||||
{formatAmount(acc.amount)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,12 +1,16 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Download, AlertCircle, Info } from 'lucide-react'
|
||||
import { AccountNumber } from '@/components/ui/account-number'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
import type { INK2Declaration, INK2RSRUCode } from '@/lib/reports/ink2/types'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Table, TableBody, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { Download, AlertCircle, AlertTriangle, Info } from 'lucide-react'
|
||||
import {
|
||||
DeclarationRutaRow,
|
||||
formatWholeKronor,
|
||||
} from '@/components/reports/DeclarationRutaRow'
|
||||
import type { INK2Declaration } from '@/lib/reports/ink2/types'
|
||||
import {
|
||||
INK2R_RUTA_LABELS,
|
||||
INK2R_ASSET_CODES,
|
||||
@@ -14,32 +18,65 @@ import {
|
||||
INK2R_INCOME_CODES,
|
||||
} from '@/lib/reports/ink2/types'
|
||||
|
||||
export function INK2DeclarationView({ periodId }: { periodId: string }) {
|
||||
const [data, setData] = useState<INK2Declaration | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [downloading, setDownloading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const fetchDeclaration = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(`/api/reports/ink2?period_id=${periodId}`)
|
||||
const result = await res.json()
|
||||
if (result.error) {
|
||||
setError(result.error)
|
||||
} else {
|
||||
setData(result.data)
|
||||
}
|
||||
} catch {
|
||||
setError('Kunde inte hämta INK2-deklaration')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
/** 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
|
||||
}
|
||||
|
||||
export function INK2DeclarationView({ periodId }: { periodId: string }) {
|
||||
// Fetch outcome tagged with the key it was requested under: switching
|
||||
// fiscal year discards stale responses instead of leaving last year's
|
||||
// declaration on screen (same pattern as the momsdeklaration view).
|
||||
const [result, setResult] = useState<{
|
||||
key: string
|
||||
data?: INK2Declaration
|
||||
error?: string
|
||||
} | null>(null)
|
||||
const [retryKey, setRetryKey] = useState(0)
|
||||
const [downloading, setDownloading] = useState(false)
|
||||
const [downloadError, setDownloadError] = useState<string | null>(null)
|
||||
|
||||
const fetchKey = periodId ? `${periodId}:${retryKey}` : null
|
||||
|
||||
useEffect(() => {
|
||||
if (!fetchKey || !periodId) return
|
||||
let cancelled = false
|
||||
fetch(`/api/reports/ink2?period_id=${periodId}`)
|
||||
.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 INK2-deklaration'),
|
||||
})
|
||||
} else {
|
||||
setResult({ key: fetchKey, data: json.data })
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setResult({ key: fetchKey, error: 'Kunde inte hämta INK2-deklaration' })
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [fetchKey, periodId])
|
||||
|
||||
const upToDate = result !== null && result.key === fetchKey
|
||||
const data = result?.data ?? null
|
||||
const error = upToDate ? (result.error ?? null) : null
|
||||
const loading = fetchKey !== null && !upToDate
|
||||
|
||||
const downloadSRU = async () => {
|
||||
setDownloading(true)
|
||||
setDownloadError(null)
|
||||
try {
|
||||
const res = await fetch(`/api/reports/ink2?period_id=${periodId}&format=sru`)
|
||||
if (!res.ok) throw new Error('Download failed')
|
||||
@@ -52,311 +89,293 @@ export function INK2DeclarationView({ periodId }: { periodId: string }) {
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch {
|
||||
setError('Kunde inte ladda ner SRU-filer')
|
||||
setDownloadError('Kunde inte ladda ner SRU-filer')
|
||||
} finally {
|
||||
setDownloading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-6 flex flex-wrap items-center gap-3 text-destructive">
|
||||
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||
<span className="text-sm">{error}</span>
|
||||
<Button variant="outline" onClick={() => setRetryKey((k) => k + 1)}>
|
||||
Försök igen
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
if (loading && !data) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-6 space-y-4">
|
||||
<Skeleton className="h-5 w-48" />
|
||||
<Skeleton className="h-64" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data) return null
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Info card */}
|
||||
<div
|
||||
className={`space-y-8 transition-opacity duration-150 ${loading ? 'opacity-60' : ''}`}
|
||||
>
|
||||
{/* Header: company, year, filing artifact, and how to file it */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">INK2 (Aktiebolag)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-start gap-2 mb-4 p-3 bg-primary/10 rounded-md">
|
||||
<Info className="h-4 w-4 text-primary mt-0.5 shrink-0" />
|
||||
<div className="text-sm text-primary space-y-1">
|
||||
<p>
|
||||
INK2 visar det bokföringsmässiga resultatet baserat på din bokföring.
|
||||
Skattemässiga justeringar (ej avdragsgilla kostnader, periodiseringsfonder m.m.)
|
||||
hanteras av din revisor/redovisningskonsult.
|
||||
</p>
|
||||
<p>
|
||||
SRU-filen laddas ner som en ZIP med INFO.SRU och BLANKETTER.SRU.
|
||||
Ladda upp båda filerna till{' '}
|
||||
<a
|
||||
href="https://www1.skatteverket.se/fv/fv_web/start.do"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
Skatteverkets filöverföringstjänst
|
||||
</a>.
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle className="text-base">INK2 (Aktiebolag)</CardTitle>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{data.companyInfo.companyName} · {data.fiscalYear.name}
|
||||
{data.companyInfo.orgNumber && ` · Org.nr: ${data.companyInfo.orgNumber}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={fetchDeclaration} disabled={loading}>
|
||||
{loading ? 'Laddar...' : 'Hämta INK2'}
|
||||
<Button variant="outline" onClick={downloadSRU} disabled={downloading}>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
{downloading ? 'Laddar ner...' : 'Ladda ner SRU-filer'}
|
||||
</Button>
|
||||
{data && (
|
||||
<Button variant="outline" onClick={downloadSRU} disabled={downloading}>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
{downloading ? 'Laddar ner...' : 'Ladda ner SRU-filer'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{downloadError && (
|
||||
<div role="alert" className="flex items-start gap-2 text-sm text-destructive">
|
||||
<AlertCircle className="h-4 w-4 mt-1 shrink-0" />
|
||||
{downloadError}
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-start gap-2 rounded-lg border border-border bg-muted/30 p-3">
|
||||
<Info className="h-4 w-4 mt-1 shrink-0 text-muted-foreground" />
|
||||
<div className="text-sm text-muted-foreground space-y-2">
|
||||
<p>
|
||||
INK2 visar det bokföringsmässiga resultatet baserat på din bokföring.
|
||||
</p>
|
||||
<ol className="list-decimal pl-6 space-y-1">
|
||||
<li>Ladda ner SRU-filerna (en ZIP med INFO.SRU och BLANKETTER.SRU).</li>
|
||||
<li>
|
||||
Ladda upp båda filerna till{' '}
|
||||
<a
|
||||
href="https://www1.skatteverket.se/fv/fv_web/start.do"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline underline-offset-2 text-foreground"
|
||||
>
|
||||
Skatteverkets filöverföringstjänst
|
||||
</a>.
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{error && (
|
||||
{/* Warnings */}
|
||||
{data.warnings.length > 0 && (
|
||||
<Card>
|
||||
<CardContent className="p-8 text-center text-destructive">
|
||||
<AlertCircle className="h-6 w-6 mx-auto mb-2" />
|
||||
{error}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{data && (
|
||||
<>
|
||||
{/* Warnings */}
|
||||
{data.warnings.length > 0 && (
|
||||
<Card className="border-border">
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="h-5 w-5 text-warning mt-0.5" />
|
||||
<div>
|
||||
{data.warnings.map((warning, i) => (
|
||||
<p key={i} className="text-sm text-foreground">{warning}</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Company info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{data.companyInfo.companyName}
|
||||
</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{data.fiscalYear.name}
|
||||
{data.companyInfo.orgNumber && ` · Org.nr: ${data.companyInfo.orgNumber}`}
|
||||
</p>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
{/* Assets section */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Tillgångar</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<table className="w-full text-sm">
|
||||
<tbody>
|
||||
{INK2R_ASSET_CODES.map((code) => (
|
||||
<INK2DeclarationRow
|
||||
key={code}
|
||||
code={code}
|
||||
label={INK2R_RUTA_LABELS[code]}
|
||||
amount={data.ink2r[code]}
|
||||
accounts={data.breakdown[code]?.accounts || []}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="border-t-2 font-semibold">
|
||||
<td className="py-2">Summa tillgångar</td>
|
||||
<td className="py-2 text-right tabular-nums">
|
||||
{formatCurrency(data.totals.totalAssets)}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Equity & Liabilities section */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Eget kapital och skulder</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<table className="w-full text-sm">
|
||||
<tbody>
|
||||
{INK2R_EQUITY_LIABILITY_CODES.map((code) => (
|
||||
<INK2DeclarationRow
|
||||
key={code}
|
||||
code={code}
|
||||
label={INK2R_RUTA_LABELS[code]}
|
||||
amount={data.ink2r[code]}
|
||||
accounts={data.breakdown[code]?.accounts || []}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="border-t-2 font-semibold">
|
||||
<td className="py-2">Summa eget kapital och skulder</td>
|
||||
<td className="py-2 text-right tabular-nums">
|
||||
{formatCurrency(data.totals.totalEquityLiabilities)}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Income Statement section */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Resultaträkning</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<table className="w-full text-sm">
|
||||
<tbody>
|
||||
{INK2R_INCOME_CODES.map((code) => (
|
||||
<INK2DeclarationRow
|
||||
key={code}
|
||||
code={code}
|
||||
label={INK2R_RUTA_LABELS[code]}
|
||||
amount={data.ink2r[code]}
|
||||
accounts={data.breakdown[code]?.accounts || []}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="border-t font-medium">
|
||||
<td className="py-2">Rörelseresultat</td>
|
||||
<td className={`py-2 text-right ${data.totals.operatingResult >= 0 ? 'text-success' : 'text-destructive'}`}>
|
||||
{formatCurrency(data.totals.operatingResult)}
|
||||
</td>
|
||||
</tr>
|
||||
<tr className="border-t-2 font-semibold">
|
||||
<td className="py-2">Årets resultat</td>
|
||||
<td className={`py-2 text-right ${data.totals.resultAfterFinancial >= 0 ? 'text-success' : 'text-destructive'}`}>
|
||||
{formatCurrency(data.totals.resultAfterFinancial)}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* INK2S summary */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">INK2S: Skattemässiga justeringar</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-start gap-2 mb-4 p-3 bg-primary/10 rounded-md">
|
||||
<Info className="h-4 w-4 text-primary mt-0.5 shrink-0" />
|
||||
<p className="text-sm text-primary">
|
||||
Grundläggande justeringar beräknas automatiskt. Manuella justeringar
|
||||
(periodiseringsfonder, koncernbidrag m.m.) hanteras av din redovisningskonsult.
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="h-4 w-4 mt-1 shrink-0 text-warning" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">
|
||||
{data.warnings.length}{' '}
|
||||
{data.warnings.length === 1 ? 'varning' : 'varningar'}
|
||||
</p>
|
||||
{data.warnings.map((warning, i) => (
|
||||
<p key={i} className="text-sm text-muted-foreground">{warning}</p>
|
||||
))}
|
||||
</div>
|
||||
<table className="w-full text-sm">
|
||||
<tbody>
|
||||
<tr className="border-b">
|
||||
<td className="py-2">
|
||||
<span className="font-mono text-xs bg-muted px-1 rounded mr-2">4.1</span>
|
||||
Årets resultat (vinst)
|
||||
</td>
|
||||
<td className="py-2 text-right">{formatCurrency(data.ink2s['7650'])}</td>
|
||||
</tr>
|
||||
<tr className="border-b">
|
||||
<td className="py-2">
|
||||
<span className="font-mono text-xs bg-muted px-1 rounded mr-2">4.2</span>
|
||||
Årets resultat (förlust)
|
||||
</td>
|
||||
<td className="py-2 text-right">{formatCurrency(data.ink2s['7750'])}</td>
|
||||
</tr>
|
||||
<tr className="border-b">
|
||||
<td className="py-2">
|
||||
<span className="font-mono text-xs bg-muted px-1 rounded mr-2">4.3a</span>
|
||||
Skatt på årets resultat (ej avdragsgill)
|
||||
</td>
|
||||
<td className="py-2 text-right">{formatCurrency(data.ink2s['7651'])}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="border-t-2 font-semibold">
|
||||
<td className="py-2">
|
||||
{data.ink2s['8020'] > 0 ? 'Överskott (punkt 1.1)' : 'Underskott (punkt 1.2)'}
|
||||
</td>
|
||||
<td className={`py-2 text-right ${data.ink2s['8020'] > 0 ? 'text-success' : 'text-destructive'}`}>
|
||||
{formatCurrency(data.ink2s['8020'] > 0 ? data.ink2s['8020'] : data.ink2s['8021'])}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!data && !loading && !error && (
|
||||
<Card>
|
||||
<CardContent className="p-8 text-center text-muted-foreground">
|
||||
Klicka "Hämta INK2" för att generera deklarationsunderlaget.
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Assets section */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Tillgångar</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="px-0">Post</TableHead>
|
||||
<TableHead className="px-0 text-right">Belopp</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{INK2R_ASSET_CODES.map((code) => (
|
||||
<DeclarationRutaRow
|
||||
key={code}
|
||||
code={code}
|
||||
label={INK2R_RUTA_LABELS[code]}
|
||||
amount={data.ink2r[code]}
|
||||
accounts={data.breakdown[code]?.accounts || []}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
<tfoot>
|
||||
<tr className="border-t font-medium">
|
||||
<td className="py-2">Summa tillgångar</td>
|
||||
<td className="py-2 text-right tabular-nums">
|
||||
{formatWholeKronor(data.totals.totalAssets)}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Equity & Liabilities section */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Eget kapital och skulder</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="px-0">Post</TableHead>
|
||||
<TableHead className="px-0 text-right">Belopp</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{INK2R_EQUITY_LIABILITY_CODES.map((code) => (
|
||||
<DeclarationRutaRow
|
||||
key={code}
|
||||
code={code}
|
||||
label={INK2R_RUTA_LABELS[code]}
|
||||
amount={data.ink2r[code]}
|
||||
accounts={data.breakdown[code]?.accounts || []}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
<tfoot>
|
||||
<tr className="border-t font-medium">
|
||||
<td className="py-2">Summa eget kapital och skulder</td>
|
||||
<td className="py-2 text-right tabular-nums">
|
||||
{formatWholeKronor(data.totals.totalEquityLiabilities)}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Income Statement section */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Resultaträkning</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="px-0">Post</TableHead>
|
||||
<TableHead className="px-0 text-right">Belopp</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{INK2R_INCOME_CODES.map((code) => (
|
||||
<DeclarationRutaRow
|
||||
key={code}
|
||||
code={code}
|
||||
label={INK2R_RUTA_LABELS[code]}
|
||||
amount={data.ink2r[code]}
|
||||
accounts={data.breakdown[code]?.accounts || []}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
<tfoot>
|
||||
<tr className="border-t font-medium">
|
||||
<td className="py-2">Rörelseresultat</td>
|
||||
<td
|
||||
className={`py-2 text-right tabular-nums ${
|
||||
data.totals.operatingResult >= 0 ? 'text-success' : 'text-destructive'
|
||||
}`}
|
||||
>
|
||||
{formatWholeKronor(data.totals.operatingResult)}
|
||||
</td>
|
||||
</tr>
|
||||
<tr className="border-t font-medium">
|
||||
<td className="py-2">Årets resultat</td>
|
||||
<td
|
||||
className={`py-2 text-right tabular-nums ${
|
||||
data.totals.resultAfterFinancial >= 0 ? 'text-success' : 'text-destructive'
|
||||
}`}
|
||||
>
|
||||
{formatWholeKronor(data.totals.resultAfterFinancial)}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* INK2S summary */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">INK2S: Skattemässiga justeringar</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Grundläggande justeringar beräknas automatiskt. Manuella justeringar
|
||||
(periodiseringsfonder, koncernbidrag m.m.) hanteras av din
|
||||
redovisningskonsult.
|
||||
</p>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="px-0">Post</TableHead>
|
||||
<TableHead className="px-0 text-right">Belopp</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{/* At most one of 4.1/4.2 is nonzero: render only that one. A
|
||||
zero-result year has both at 0, so both render then, or the
|
||||
table would be nothing but its total row. */}
|
||||
<DeclarationRutaRow
|
||||
code="4.1"
|
||||
label="Årets resultat (vinst)"
|
||||
amount={data.ink2s['7650']}
|
||||
hideWhenZero={data.ink2s['7750'] !== 0}
|
||||
/>
|
||||
<DeclarationRutaRow
|
||||
code="4.2"
|
||||
label="Årets resultat (förlust)"
|
||||
amount={data.ink2s['7750']}
|
||||
hideWhenZero={data.ink2s['7650'] !== 0}
|
||||
/>
|
||||
<DeclarationRutaRow
|
||||
code="4.3a"
|
||||
label="Skatt på årets resultat (ej avdragsgill)"
|
||||
amount={data.ink2s['7651']}
|
||||
/>
|
||||
</TableBody>
|
||||
<tfoot>
|
||||
<tr className="border-t font-medium">
|
||||
<td className="py-2">
|
||||
{data.ink2s['8020'] > 0 ? 'Överskott (punkt 1.1)' : 'Underskott (punkt 1.2)'}
|
||||
</td>
|
||||
<td
|
||||
className={`py-2 text-right tabular-nums ${
|
||||
data.ink2s['8020'] > 0 ? 'text-success' : 'text-destructive'
|
||||
}`}
|
||||
>
|
||||
{formatWholeKronor(
|
||||
data.ink2s['8020'] > 0 ? data.ink2s['8020'] : data.ink2s['8021']
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function INK2DeclarationRow({
|
||||
code,
|
||||
label,
|
||||
amount,
|
||||
accounts,
|
||||
}: {
|
||||
code: INK2RSRUCode
|
||||
label: string
|
||||
amount: number
|
||||
accounts: Array<{ accountNumber: string; accountName: string; amount: number }>
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
if (amount === 0 && accounts.length === 0) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<tr
|
||||
className="border-b cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => accounts.length > 0 && setExpanded(!expanded)}
|
||||
>
|
||||
<td className="py-2">
|
||||
<span className="font-mono text-xs bg-muted px-1 rounded mr-2">{code}</span>
|
||||
{label}
|
||||
{accounts.length > 0 && (
|
||||
<span className="text-xs text-muted-foreground ml-2">
|
||||
({accounts.length} konton)
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 text-right tabular-nums">
|
||||
{formatCurrency(amount)}
|
||||
</td>
|
||||
</tr>
|
||||
{expanded && accounts.length > 0 && (
|
||||
<tr>
|
||||
<td colSpan={2} className="py-2 pl-8 bg-muted/30">
|
||||
<table className="w-full text-xs">
|
||||
<tbody>
|
||||
{accounts.map((acc) => (
|
||||
<tr key={acc.accountNumber}>
|
||||
<td className="py-1">
|
||||
<AccountNumber number={acc.accountNumber} name={acc.accountName} size="sm" />
|
||||
</td>
|
||||
<td className="py-1">{acc.accountName}</td>
|
||||
<td className="py-1 text-right tabular-nums">
|
||||
{formatCurrency(acc.amount)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,39 +1,74 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Download, AlertCircle } from 'lucide-react'
|
||||
import { AccountNumber } from '@/components/ui/account-number'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Table, TableBody, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { Download, AlertCircle, AlertTriangle } from 'lucide-react'
|
||||
import {
|
||||
DeclarationRutaRow,
|
||||
formatWholeKronor,
|
||||
} from '@/components/reports/DeclarationRutaRow'
|
||||
import type { NEDeclaration } from '@/lib/reports/ne-bilaga/types'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
|
||||
/** 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
|
||||
}
|
||||
|
||||
export function NEDeclarationView({ periodId }: { periodId: string }) {
|
||||
const [data, setData] = useState<NEDeclaration | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
// Fetch outcome tagged with the key it was requested under: switching
|
||||
// fiscal year discards stale responses instead of leaving last year's
|
||||
// declaration on screen (same pattern as the momsdeklaration view).
|
||||
const [result, setResult] = useState<{
|
||||
key: string
|
||||
data?: NEDeclaration
|
||||
error?: string
|
||||
} | null>(null)
|
||||
const [retryKey, setRetryKey] = useState(0)
|
||||
const [downloading, setDownloading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [downloadError, setDownloadError] = useState<string | null>(null)
|
||||
|
||||
const fetchDeclaration = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(`/api/reports/ne-bilaga?period_id=${periodId}`)
|
||||
const result = await res.json()
|
||||
if (result.error) {
|
||||
setError(result.error)
|
||||
} else {
|
||||
setData(result.data)
|
||||
}
|
||||
} catch {
|
||||
setError('Kunde inte hämta NE-bilaga')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
const fetchKey = periodId ? `${periodId}:${retryKey}` : null
|
||||
|
||||
useEffect(() => {
|
||||
if (!fetchKey || !periodId) return
|
||||
let cancelled = false
|
||||
fetch(`/api/reports/ne-bilaga?period_id=${periodId}`)
|
||||
.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 NE-bilaga'),
|
||||
})
|
||||
} else {
|
||||
setResult({ key: fetchKey, data: json.data })
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setResult({ key: fetchKey, error: 'Kunde inte hämta NE-bilaga' })
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}
|
||||
}, [fetchKey, periodId])
|
||||
|
||||
const upToDate = result !== null && result.key === fetchKey
|
||||
const data = result?.data ?? null
|
||||
const error = upToDate ? (result.error ?? null) : null
|
||||
const loading = fetchKey !== null && !upToDate
|
||||
|
||||
const downloadSRU = async () => {
|
||||
setDownloading(true)
|
||||
setDownloadError(null)
|
||||
try {
|
||||
const res = await fetch(`/api/reports/ne-bilaga?period_id=${periodId}&format=sru`)
|
||||
if (!res.ok) throw new Error('Download failed')
|
||||
@@ -46,7 +81,7 @@ export function NEDeclarationView({ periodId }: { periodId: string }) {
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch {
|
||||
setError('Kunde inte ladda ner SRU-filer')
|
||||
setDownloadError('Kunde inte ladda ner SRU-filer')
|
||||
} finally {
|
||||
setDownloading(false)
|
||||
}
|
||||
@@ -71,238 +106,182 @@ export function NEDeclarationView({ periodId }: { periodId: string }) {
|
||||
const revenueRutor = ['R1', 'R2', 'R3', 'R4'] as const
|
||||
const expenseRutor = ['R5', 'R6', 'R7', 'R8', 'R9', 'R10'] as const
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-6 flex flex-wrap items-center gap-3 text-destructive">
|
||||
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||
<span className="text-sm">{error}</span>
|
||||
<Button variant="outline" onClick={() => setRetryKey((k) => k + 1)}>
|
||||
Försök igen
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
if (loading && !data) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-6 space-y-4">
|
||||
<Skeleton className="h-5 w-48" />
|
||||
<Skeleton className="h-64" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data) return null
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Info card */}
|
||||
<div
|
||||
className={`space-y-8 transition-opacity duration-150 ${loading ? 'opacity-60' : ''}`}
|
||||
>
|
||||
{/* Header: company, year, and the filing artifact */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">NE-bilaga (Enskild firma)</CardTitle>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle className="text-base">NE-bilaga (Enskild firma)</CardTitle>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{data.companyInfo.companyName} · {data.fiscalYear.name}
|
||||
{data.companyInfo.orgNumber && ` · Org.nr: ${data.companyInfo.orgNumber}`}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={downloadSRU} disabled={downloading}>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
{downloading ? 'Laddar ner...' : 'Ladda ner SRU-fil'}
|
||||
</Button>
|
||||
</div>
|
||||
{downloadError && (
|
||||
<div role="alert" className="flex items-start gap-2 text-sm text-destructive">
|
||||
<AlertCircle className="h-4 w-4 mt-1 shrink-0" />
|
||||
{downloadError}
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
{/* Warnings */}
|
||||
{data.warnings.length > 0 && (
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="h-4 w-4 mt-1 shrink-0 text-warning" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">
|
||||
{data.warnings.length}{' '}
|
||||
{data.warnings.length === 1 ? 'varning' : 'varningar'}
|
||||
</p>
|
||||
{data.warnings.map((warning, i) => (
|
||||
<p key={i} className="text-sm text-muted-foreground">{warning}</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Revenue section */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Intäkter</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
NE-bilagan används för att deklarera resultat från enskild näringsverksamhet.
|
||||
Uppgifterna hämtas från bokföringen för valt räkenskapsår.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={fetchDeclaration} disabled={loading}>
|
||||
{loading ? 'Laddar...' : 'Hämta NE-bilaga'}
|
||||
</Button>
|
||||
{data && (
|
||||
<Button variant="outline" onClick={downloadSRU} disabled={downloading}>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
{downloading ? 'Laddar ner...' : 'Ladda ner SRU-fil'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="px-0">Post</TableHead>
|
||||
<TableHead className="px-0 text-right">Belopp</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{revenueRutor.map((ruta) => (
|
||||
<DeclarationRutaRow
|
||||
key={ruta}
|
||||
code={ruta}
|
||||
label={rutaLabels[ruta]}
|
||||
amount={data.rutor[ruta]}
|
||||
accounts={data.breakdown[ruta]?.accounts || []}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
<tfoot>
|
||||
<tr className="border-t font-medium">
|
||||
<td className="py-2">Summa intäkter</td>
|
||||
<td className="py-2 text-right tabular-nums">
|
||||
{formatWholeKronor(
|
||||
data.rutor.R1 + data.rutor.R2 + data.rutor.R3 + data.rutor.R4
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{error && (
|
||||
<Card>
|
||||
<CardContent className="p-8 text-center text-destructive">
|
||||
<AlertCircle className="h-6 w-6 mx-auto mb-2" />
|
||||
{error}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{/* Expenses section */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Kostnader</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="px-0">Post</TableHead>
|
||||
<TableHead className="px-0 text-right">Belopp</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{expenseRutor.map((ruta) => (
|
||||
// Costs display negated: the signed value keeps its true sign
|
||||
// when an expense ruta carries a credit balance.
|
||||
<DeclarationRutaRow
|
||||
key={ruta}
|
||||
code={ruta}
|
||||
label={rutaLabels[ruta]}
|
||||
amount={-data.rutor[ruta]}
|
||||
accounts={(data.breakdown[ruta]?.accounts || []).map((acc) => ({
|
||||
...acc,
|
||||
amount: -acc.amount,
|
||||
}))}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
<tfoot>
|
||||
<tr className="border-t font-medium">
|
||||
<td className="py-2">Summa kostnader</td>
|
||||
<td className="py-2 text-right tabular-nums">
|
||||
{formatWholeKronor(
|
||||
-(data.rutor.R5 + data.rutor.R6 + data.rutor.R7 +
|
||||
data.rutor.R8 + data.rutor.R9 + data.rutor.R10)
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{data && (
|
||||
<>
|
||||
{/* Warnings */}
|
||||
{data.warnings.length > 0 && (
|
||||
<Card className="border-border">
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="h-5 w-5 text-warning mt-0.5" />
|
||||
<div>
|
||||
{data.warnings.map((warning, i) => (
|
||||
<p key={i} className="text-sm text-foreground">{warning}</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Company info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{data.companyInfo.companyName}
|
||||
</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{data.fiscalYear.name}
|
||||
</p>
|
||||
{data.companyInfo.orgNumber && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Org.nr: {data.companyInfo.orgNumber}
|
||||
</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
{/* Revenue section */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Intäkter</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<table className="w-full text-sm">
|
||||
<tbody>
|
||||
{revenueRutor.map((ruta) => {
|
||||
const value = data.rutor[ruta]
|
||||
const breakdown = data.breakdown[ruta]
|
||||
return (
|
||||
<NEDeclarationRow
|
||||
key={ruta}
|
||||
ruta={ruta}
|
||||
label={rutaLabels[ruta]}
|
||||
amount={value}
|
||||
accounts={breakdown?.accounts || []}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="border-t-2 font-semibold">
|
||||
<td className="py-2">Summa intäkter</td>
|
||||
<td className="py-2 text-right tabular-nums">
|
||||
{formatCurrency(
|
||||
data.rutor.R1 + data.rutor.R2 + data.rutor.R3 + data.rutor.R4
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Expenses section */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Kostnader</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<table className="w-full text-sm">
|
||||
<tbody>
|
||||
{expenseRutor.map((ruta) => {
|
||||
const value = data.rutor[ruta]
|
||||
const breakdown = data.breakdown[ruta]
|
||||
return (
|
||||
<NEDeclarationRow
|
||||
key={ruta}
|
||||
ruta={ruta}
|
||||
label={rutaLabels[ruta]}
|
||||
amount={value}
|
||||
accounts={breakdown?.accounts || []}
|
||||
isExpense
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="border-t-2 font-semibold">
|
||||
<td className="py-2">Summa kostnader</td>
|
||||
<td className="py-2 text-right tabular-nums">
|
||||
-{formatCurrency(
|
||||
data.rutor.R5 + data.rutor.R6 + data.rutor.R7 +
|
||||
data.rutor.R8 + data.rutor.R9 + data.rutor.R10
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Result */}
|
||||
<Card className="border-2">
|
||||
<CardContent className="py-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<span className="font-mono text-xs bg-muted px-1 rounded mr-2">R11</span>
|
||||
<span className="font-display text-xl">Årets resultat</span>
|
||||
</div>
|
||||
<span
|
||||
className={`font-display text-2xl tabular-nums ${
|
||||
data.rutor.R11 >= 0 ? 'text-success' : 'text-destructive'
|
||||
}`}
|
||||
>
|
||||
{formatCurrency(data.rutor.R11)}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!data && !loading && !error && (
|
||||
<Card>
|
||||
<CardContent className="p-8 text-center text-muted-foreground">
|
||||
Klicka "Hämta NE-bilaga" för att generera deklarationsunderlaget.
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{/* Result */}
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<span className="font-mono text-xs bg-muted px-1 rounded mr-2">R11</span>
|
||||
<span className="font-display text-xl">Årets resultat</span>
|
||||
</div>
|
||||
<span
|
||||
className={`font-display text-2xl tabular-nums ${
|
||||
data.rutor.R11 >= 0 ? 'text-success' : 'text-destructive'
|
||||
}`}
|
||||
>
|
||||
{formatWholeKronor(data.rutor.R11)}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NEDeclarationRow({
|
||||
ruta,
|
||||
label,
|
||||
amount,
|
||||
accounts,
|
||||
isExpense,
|
||||
}: {
|
||||
ruta: string
|
||||
label: string
|
||||
amount: number
|
||||
accounts: Array<{ accountNumber: string; accountName: string; amount: number }>
|
||||
isExpense?: boolean
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
// Don't show rows with zero values
|
||||
if (amount === 0 && accounts.length === 0) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<tr
|
||||
className="border-b cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => accounts.length > 0 && setExpanded(!expanded)}
|
||||
>
|
||||
<td className="py-2">
|
||||
<span className="font-mono text-xs bg-muted px-1 rounded mr-2">{ruta}</span>
|
||||
{label}
|
||||
{accounts.length > 0 && (
|
||||
<span className="text-xs text-muted-foreground ml-2">
|
||||
({accounts.length} konton)
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 text-right tabular-nums">
|
||||
{isExpense && amount > 0 ? '-' : ''}{formatCurrency(Math.abs(amount))}
|
||||
</td>
|
||||
</tr>
|
||||
{expanded && accounts.length > 0 && (
|
||||
<tr>
|
||||
<td colSpan={2} className="py-2 pl-8 bg-muted/30">
|
||||
<table className="w-full text-xs">
|
||||
<tbody>
|
||||
{accounts.map((acc) => (
|
||||
<tr key={acc.accountNumber}>
|
||||
<td className="py-1"><AccountNumber number={acc.accountNumber} name={acc.accountName} size="sm" /></td>
|
||||
<td className="py-1">{acc.accountName}</td>
|
||||
<td className="py-1 text-right">
|
||||
{isExpense && acc.amount > 0 ? '-' : ''}{formatCurrency(Math.abs(acc.amount))}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
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'
|
||||
@@ -8,6 +8,13 @@ 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,
|
||||
@@ -16,7 +23,15 @@ import {
|
||||
TableCell,
|
||||
TableBody,
|
||||
} from '@/components/ui/table'
|
||||
import { Download, AlertCircle, AlertTriangle, FileText, ExternalLink } from 'lucide-react'
|
||||
import {
|
||||
Download,
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
FileText,
|
||||
ExternalLink,
|
||||
RefreshCw,
|
||||
} from 'lucide-react'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
import type {
|
||||
PeriodiskSammanstallningReport,
|
||||
@@ -29,6 +44,16 @@ function formatAmount(amount: number): string {
|
||||
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')
|
||||
@@ -56,9 +81,15 @@ export function PeriodiskSammanstallningView() {
|
||||
const [periodType, setPeriodType] = useState<PsPeriodType>('quarterly')
|
||||
const [year, setYear] = useState(currentYear)
|
||||
const [period, setPeriod] = useState(currentQuarter)
|
||||
const [data, setData] = useState<PeriodiskSammanstallningReport | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
// 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)
|
||||
|
||||
@@ -78,30 +109,47 @@ export function PeriodiskSammanstallningView() {
|
||||
{ value: 4, label: 'Kvartal 4 (okt-dec)' },
|
||||
]
|
||||
|
||||
useEffect(() => {
|
||||
setPeriod(periodType === 'monthly' ? currentMonth : currentQuarter)
|
||||
}, [periodType, currentMonth, currentQuarter])
|
||||
|
||||
const fetchReport = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/reports/periodisk-sammanstallning?periodType=${periodType}&year=${year}&period=${period}`,
|
||||
)
|
||||
const result = await res.json()
|
||||
if (result.error) {
|
||||
setError(typeof result.error === 'string' ? result.error : result.error.message_sv ?? 'Något gick fel.')
|
||||
} else {
|
||||
setData(result.data)
|
||||
}
|
||||
} catch {
|
||||
setError('Kunde inte hämta periodisk sammanställning.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
// 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}`,
|
||||
@@ -114,49 +162,70 @@ export function PeriodiskSammanstallningView() {
|
||||
const hasBlockingErrors = errors.length > 0
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Period selection */}
|
||||
<div className="space-y-8">
|
||||
{/* Period selection: the report below follows it automatically */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Välj period</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
<div>
|
||||
<Label>Periodicitet</Label>
|
||||
<select
|
||||
<Label htmlFor="ps-period-type">Periodicitet</Label>
|
||||
<Select
|
||||
value={periodType}
|
||||
onChange={e => setPeriodType(e.target.value as PsPeriodType)}
|
||||
className="w-full mt-1 rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
onValueChange={(value) => handlePeriodTypeChange(value as PsPeriodType)}
|
||||
>
|
||||
<option value="monthly">Månadsvis</option>
|
||||
<option value="quarterly">Kvartalsvis</option>
|
||||
</select>
|
||||
<SelectTrigger id="ps-period-type" className="mt-1 w-40">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="monthly">Månadsvis</SelectItem>
|
||||
<SelectItem value="quarterly">Kvartalsvis</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>År</Label>
|
||||
<select
|
||||
value={year}
|
||||
onChange={e => setYear(parseInt(e.target.value))}
|
||||
className="w-full mt-1 rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
>
|
||||
{yearOptions.map(y => <option key={y} value={y}>{y}</option>)}
|
||||
</select>
|
||||
<Label htmlFor="ps-year">År</Label>
|
||||
<Select value={String(year)} onValueChange={(value) => setYear(parseInt(value))}>
|
||||
<SelectTrigger id="ps-year" className="mt-1 w-28 tabular-nums">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{yearOptions.map((y) => (
|
||||
<SelectItem key={y} value={String(y)} className="tabular-nums">
|
||||
{y}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Period</Label>
|
||||
<select
|
||||
value={period}
|
||||
onChange={e => setPeriod(parseInt(e.target.value))}
|
||||
className="w-full mt-1 rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
<Label htmlFor="ps-period">Period</Label>
|
||||
<Select
|
||||
value={String(period)}
|
||||
onValueChange={(value) => setPeriod(parseInt(value))}
|
||||
>
|
||||
{periodOptions.map(opt => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<SelectTrigger id="ps-period" className="mt-1 w-44">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{periodOptions.map((opt) => (
|
||||
<SelectItem key={opt.value} value={String(opt.value)}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button onClick={fetchReport} disabled={loading}>
|
||||
{loading ? 'Laddar…' : 'Hämta'}
|
||||
{/* 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. */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Uppdatera rapporten"
|
||||
onClick={() => setRetryKey((k) => k + 1)}
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -164,14 +233,17 @@ export function PeriodiskSammanstallningView() {
|
||||
|
||||
{error && (
|
||||
<Card>
|
||||
<CardContent className="p-6 flex items-start gap-3 text-destructive">
|
||||
<AlertCircle className="h-5 w-5 mt-0.5 shrink-0" />
|
||||
<div>{error}</div>
|
||||
<CardContent className="p-6 flex flex-wrap items-center gap-3 text-destructive">
|
||||
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||
<span className="text-sm">{error}</span>
|
||||
<Button variant="outline" onClick={() => setRetryKey((k) => k + 1)}>
|
||||
Försök igen
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
{!error && loading && !data && (
|
||||
<Card>
|
||||
<CardContent className="p-6 space-y-3">
|
||||
<Skeleton className="h-5 w-40" />
|
||||
@@ -180,8 +252,10 @@ export function PeriodiskSammanstallningView() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{data && !loading && (
|
||||
<>
|
||||
{data && !error && (
|
||||
<div
|
||||
className={`space-y-8 transition-opacity duration-150 ${loading ? 'opacity-60' : ''}`}
|
||||
>
|
||||
{/* Summary */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -222,18 +296,24 @@ export function PeriodiskSammanstallningView() {
|
||||
{data.reconciliation.matches !== null && data.totals.rowCount > 0 && (
|
||||
<div className="text-sm text-muted-foreground border-t pt-3">
|
||||
{data.reconciliation.matches ? (
|
||||
<span className="text-foreground">
|
||||
✓ 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)
|
||||
<span className="text-foreground inline-flex items-start gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 mt-1 shrink-0 text-success" />
|
||||
<span>
|
||||
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)
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-destructive">
|
||||
⚠ 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.
|
||||
<span className="text-destructive inline-flex items-start gap-2">
|
||||
<AlertTriangle className="h-4 w-4 mt-1 shrink-0" />
|
||||
<span>
|
||||
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.
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -256,7 +336,7 @@ export function PeriodiskSammanstallningView() {
|
||||
|
||||
{/* Errors */}
|
||||
{errors.length > 0 && (
|
||||
<Card className="border-destructive/50">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base text-destructive flex items-center gap-2">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
@@ -277,7 +357,7 @@ export function PeriodiskSammanstallningView() {
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2 text-foreground">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTriangle className="h-4 w-4 text-warning" />
|
||||
Varningar ({cautions.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
@@ -292,16 +372,21 @@ export function PeriodiskSammanstallningView() {
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-base">Rader</CardTitle>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={downloadCsv}
|
||||
disabled={hasBlockingErrors}
|
||||
title={hasBlockingErrors ? 'Åtgärda blockerande fel innan CSV kan laddas ner' : undefined}
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Ladda ner CSV (SKV 5740)
|
||||
</Button>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{hasBlockingErrors && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Åtgärda felen ovan innan CSV kan laddas ner.
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={downloadCsv}
|
||||
disabled={hasBlockingErrors}
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Ladda ner CSV (SKV 5740)
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
@@ -367,7 +452,7 @@ export function PeriodiskSammanstallningView() {
|
||||
</a>
|
||||
</EmptyState>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
@@ -380,7 +465,7 @@ function WarningItem({ warning }: { warning: PsWarning }) {
|
||||
<div className="flex-1">
|
||||
<div>{warning.message}</div>
|
||||
{(warning.customerId || warning.invoiceId) && (
|
||||
<div className="text-xs text-muted-foreground mt-0.5 flex gap-3">
|
||||
<div className="text-xs text-muted-foreground mt-1 flex gap-3">
|
||||
{warning.customerId && (
|
||||
<Link href={`/customers/${warning.customerId}`} className="underline-offset-4 hover:underline">
|
||||
Öppna kund
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,613 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
DataList,
|
||||
DataListMeta,
|
||||
DataListMetaSeparator,
|
||||
DataListPrimary,
|
||||
DataListRow,
|
||||
} from '@/components/ui/data-list'
|
||||
import {
|
||||
DestructiveConfirmDialog,
|
||||
useDestructiveConfirm,
|
||||
} from '@/components/ui/destructive-confirm-dialog'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { ToastAction } from '@/components/ui/toast'
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Loader2,
|
||||
ShieldAlert,
|
||||
} from 'lucide-react'
|
||||
import type { VatDeclarationCheck } from '@/lib/reports/vat-declaration-checks'
|
||||
import type { RcBasisGap } from '@/lib/reports/rc-basis-gaps'
|
||||
import type { VatPeriodType } from '@/types'
|
||||
import { formatDate } from '@/lib/utils'
|
||||
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||
|
||||
function formatAmount(amount: number): string {
|
||||
return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
}
|
||||
|
||||
/** API responses use the canonical envelope; error may be a string or an object. */
|
||||
function apiErrorMessage(json: unknown, fallback: string): string {
|
||||
const err = (json as { error?: unknown } | null)?.error
|
||||
if (typeof err === 'string') return err
|
||||
if (err && typeof err === 'object') {
|
||||
const message = (err as { message?: unknown }).message
|
||||
if (typeof message === 'string') return message
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
type SupplierType = 'eu_business' | 'non_eu_business' | 'swedish_business'
|
||||
type SupplyType = 'service' | 'goods'
|
||||
interface GapClassification {
|
||||
supplierType: SupplierType
|
||||
supplyType: SupplyType
|
||||
}
|
||||
|
||||
const SUPPLIER_LABELS: Record<SupplierType, string> = {
|
||||
eu_business: 'EU-leverantör',
|
||||
non_eu_business: 'Utanför EU',
|
||||
swedish_business: 'Svensk omvänd skattskyldighet',
|
||||
}
|
||||
const SUPPLY_LABELS: Record<SupplyType, string> = {
|
||||
service: 'tjänst',
|
||||
goods: 'vara',
|
||||
}
|
||||
|
||||
/** How many gap rows render before the "Visa alla" toggle. */
|
||||
const GAP_PREVIEW_COUNT = 8
|
||||
|
||||
/**
|
||||
* "Kontroll av underlaget": the local pre-flight checks for the
|
||||
* momsdeklaration plus the per-voucher RC-basis-gap worklist with single and
|
||||
* bulk Korrigera. Hoisted out of SkatteverketPanel so EVERY user sees it,
|
||||
* paying or not, connected or not: manual filers are exactly the users who
|
||||
* must not file a declaration these checks would have blocked.
|
||||
*/
|
||||
export function VatChecksCard({
|
||||
checks,
|
||||
periodType,
|
||||
year,
|
||||
period,
|
||||
fiscalPeriodId,
|
||||
onCorrected,
|
||||
}: {
|
||||
checks: VatDeclarationCheck[]
|
||||
periodType: VatPeriodType
|
||||
year: number
|
||||
period: number
|
||||
fiscalPeriodId?: string
|
||||
onCorrected: () => void
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const { toast } = useToast()
|
||||
const { canWrite } = useCanWrite()
|
||||
const { dialogProps, confirm } = useDestructiveConfirm()
|
||||
|
||||
const errorCount = checks.filter((c) => c.status === 'ERROR').length
|
||||
const warningCount = checks.length - errorCount
|
||||
const hasRcBasisGaps = checks.some((c) => c.code === 'RC_BASIS_MISSING')
|
||||
|
||||
// Gap fetch tagged with the key it was requested under: loading is derived
|
||||
// by comparing tags, so the effect never sets state synchronously. Fixed
|
||||
// rows are removed via removedIds (the fetch itself is not re-run after a
|
||||
// korrigering: the period key is unchanged). The key is period-only, NOT
|
||||
// gated on RC_BASIS_MISSING: the aggregate check clears as soon as ONE
|
||||
// voucher is corrected, and the remaining unfixed rows must survive the
|
||||
// declaration refetch that follows each korrigering.
|
||||
const gapsKey = `${periodType}:${year}:${period}:${fiscalPeriodId ?? ''}`
|
||||
const [gapsResult, setGapsResult] = useState<{
|
||||
key: string
|
||||
gaps: RcBasisGap[]
|
||||
failed?: boolean
|
||||
} | null>(null)
|
||||
const [removedIds, setRemovedIds] = useState<Set<string>>(new Set())
|
||||
const [showAll, setShowAll] = useState(false)
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||
// Shared classification applied by "Korrigera alla" and any row without an
|
||||
// override. Visible in the toolbar so a bulk fix never runs on a hidden guess.
|
||||
const [sharedSel, setSharedSel] = useState<GapClassification>({
|
||||
supplierType: 'eu_business',
|
||||
supplyType: 'service',
|
||||
})
|
||||
const [overrides, setOverrides] = useState<Record<string, GapClassification>>({})
|
||||
const [rowErrors, setRowErrors] = useState<Record<string, string>>({})
|
||||
const [fixingId, setFixingId] = useState<string | null>(null)
|
||||
const [bulkProgress, setBulkProgress] = useState<{ done: number; total: number } | null>(null)
|
||||
|
||||
// Render-phase adjustment: a new period resets the per-row working state.
|
||||
const [appliedGapsKey, setAppliedGapsKey] = useState<string | null>(null)
|
||||
if (gapsKey !== appliedGapsKey) {
|
||||
setAppliedGapsKey(gapsKey)
|
||||
setRemovedIds(new Set())
|
||||
setOverrides({})
|
||||
setRowErrors({})
|
||||
setExpandedId(null)
|
||||
setShowAll(false)
|
||||
}
|
||||
|
||||
const gapsFetched = gapsResult !== null && gapsResult.key === gapsKey
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch once per period, and only while the aggregate check flags a gap:
|
||||
// the fetched list then outlives the check, which clears after the first
|
||||
// correction even though other vouchers may remain broken.
|
||||
if (!hasRcBasisGaps || gapsFetched) return
|
||||
let cancelled = false
|
||||
const params = new URLSearchParams({
|
||||
periodType,
|
||||
year: String(year),
|
||||
period: String(period),
|
||||
})
|
||||
// Forward-compatible: the route reads calendar params today, but yearly
|
||||
// (helårsmoms) declarations resolve against the räkenskapsår.
|
||||
if (fiscalPeriodId) params.set('fiscal_period_id', fiscalPeriodId)
|
||||
fetch(`/api/reports/vat-declaration/rc-basis-gaps?${params.toString()}`)
|
||||
.then(async (r) => {
|
||||
const j = await r.json().catch(() => null)
|
||||
if (cancelled) return
|
||||
// A failed fetch must not masquerade as "no gaps found": the check
|
||||
// above says there ARE gaps, so an empty list here would mislead.
|
||||
if (!r.ok || j?.error) setGapsResult({ key: gapsKey, gaps: [], failed: true })
|
||||
else setGapsResult({ key: gapsKey, gaps: j?.data?.gaps || [] })
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setGapsResult({ key: gapsKey, gaps: [], failed: true })
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [hasRcBasisGaps, gapsFetched, gapsKey, periodType, year, period, fiscalPeriodId])
|
||||
|
||||
const gaps = gapsFetched
|
||||
? gapsResult.gaps.filter((g) => !removedIds.has(g.entryId))
|
||||
: []
|
||||
const gapsLoading = hasRcBasisGaps && !gapsFetched
|
||||
const gapsFailed = gapsFetched && !!gapsResult.failed
|
||||
// The worklist stays mounted while unfixed rows remain, even after the
|
||||
// aggregate check has cleared: hiding them mid-session would strand the
|
||||
// user with silently understated rutor 20-24.
|
||||
const showGapWorklist = hasRcBasisGaps || gaps.length > 0
|
||||
|
||||
const busy = fixingId !== null || bulkProgress !== null
|
||||
|
||||
const classificationFor = (gap: RcBasisGap): GapClassification =>
|
||||
overrides[gap.entryId] ?? sharedSel
|
||||
|
||||
const postFix = async (
|
||||
gap: RcBasisGap,
|
||||
sel: GapClassification,
|
||||
): Promise<{ ok: true; correctedId?: string } | { ok: false; message: string }> => {
|
||||
try {
|
||||
const res = await fetch('/api/reports/vat-declaration/rc-basis-gaps/fix', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
entryId: gap.entryId,
|
||||
supplierType: sel.supplierType,
|
||||
supplyType: sel.supplyType,
|
||||
}),
|
||||
})
|
||||
const json = await res.json().catch(() => null)
|
||||
if (!res.ok || json?.error) {
|
||||
return { ok: false, message: apiErrorMessage(json, 'Kunde inte korrigera verifikationen') }
|
||||
}
|
||||
return { ok: true, correctedId: json?.data?.correctedId }
|
||||
} catch {
|
||||
return { ok: false, message: 'Kunde inte korrigera verifikationen' }
|
||||
}
|
||||
}
|
||||
|
||||
const handleFixOne = async (gap: RcBasisGap) => {
|
||||
setFixingId(gap.entryId)
|
||||
setRowErrors((prev) => {
|
||||
const next = { ...prev }
|
||||
delete next[gap.entryId]
|
||||
return next
|
||||
})
|
||||
const result = await postFix(gap, classificationFor(gap))
|
||||
setFixingId(null)
|
||||
if (!result.ok) {
|
||||
setRowErrors((prev) => ({ ...prev, [gap.entryId]: result.message }))
|
||||
return
|
||||
}
|
||||
setRemovedIds((prev) => new Set(prev).add(gap.entryId))
|
||||
const correctedId = result.correctedId
|
||||
toast({
|
||||
title: `Verifikation ${gap.voucherSeries}-${gap.voucherNumber} korrigerad`,
|
||||
description: 'Storno + ny verifikation skapad.',
|
||||
action: correctedId ? (
|
||||
<ToastAction
|
||||
altText="Visa verifikat"
|
||||
onClick={() => router.push(`/bookkeeping/${correctedId}`)}
|
||||
>
|
||||
Visa verifikat
|
||||
</ToastAction>
|
||||
) : undefined,
|
||||
})
|
||||
onCorrected()
|
||||
}
|
||||
|
||||
const handleFixAll = async () => {
|
||||
const targets = [...gaps]
|
||||
if (targets.length === 0) return
|
||||
const overriddenCount = targets.filter((g) => overrides[g.entryId]).length
|
||||
const ok = await confirm({
|
||||
title: `Korrigera ${targets.length} verifikationer?`,
|
||||
description:
|
||||
`Detta skapar ${targets.length} stornon och ${targets.length} nya verifikationer ` +
|
||||
`klassificerade som ${SUPPLIER_LABELS[sharedSel.supplierType].toLowerCase()}, ` +
|
||||
`${SUPPLY_LABELS[sharedSel.supplyType]}.` +
|
||||
(overriddenCount > 0
|
||||
? ` ${overriddenCount} rader använder sina egna val i stället.`
|
||||
: ' Rader du inte ändrat använder valen ovan.'),
|
||||
variant: 'warning',
|
||||
confirmLabel: 'Korrigera alla',
|
||||
})
|
||||
if (!ok) return
|
||||
|
||||
setBulkProgress({ done: 0, total: targets.length })
|
||||
const failures: Record<string, string> = {}
|
||||
for (const [index, gap] of targets.entries()) {
|
||||
const result = await postFix(gap, classificationFor(gap))
|
||||
if (!result.ok) failures[gap.entryId] = result.message
|
||||
setBulkProgress({ done: index + 1, total: targets.length })
|
||||
}
|
||||
// Fixed rows leave the list; failures stay with their inline error note.
|
||||
setRemovedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
for (const gap of targets) {
|
||||
if (!failures[gap.entryId]) next.add(gap.entryId)
|
||||
}
|
||||
return next
|
||||
})
|
||||
setRowErrors(failures)
|
||||
setBulkProgress(null)
|
||||
const failureCount = Object.keys(failures).length
|
||||
const fixedCount = targets.length - failureCount
|
||||
toast({
|
||||
title: `${fixedCount} av ${targets.length} verifikationer korrigerade`,
|
||||
description:
|
||||
failureCount > 0
|
||||
? `${failureCount} kunde inte korrigeras och ligger kvar i listan.`
|
||||
: 'Storno + nya verifikationer skapade.',
|
||||
})
|
||||
// One refetch at the end: per-row refetches would remount the page once
|
||||
// per verifikat.
|
||||
onCorrected()
|
||||
}
|
||||
|
||||
const visibleGaps = showAll ? gaps : gaps.slice(0, GAP_PREVIEW_COUNT)
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<CardTitle className="text-base">Kontroll av underlaget</CardTitle>
|
||||
{errorCount > 0 ? (
|
||||
<Badge variant="destructive">
|
||||
{errorCount} {errorCount === 1 ? 'fel' : 'fel'}
|
||||
</Badge>
|
||||
) : warningCount > 0 ? (
|
||||
<Badge variant="warning">
|
||||
{warningCount} {warningCount === 1 ? 'varning' : 'varningar'}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="success">Inga anmärkningar</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{checks.length === 0 && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<CheckCircle2 className="h-4 w-4 text-success" />
|
||||
Inga fel hittades i underlaget för perioden.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{checks.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{checks.map((c, i) => (
|
||||
<div
|
||||
key={`${c.code}-${i}`}
|
||||
className={`flex items-start gap-2 rounded-lg p-3 text-sm ${
|
||||
c.status === 'ERROR'
|
||||
? 'bg-destructive/5 text-destructive'
|
||||
: 'border border-border bg-muted/30'
|
||||
}`}
|
||||
>
|
||||
{c.status === 'ERROR' ? (
|
||||
<ShieldAlert className="h-4 w-4 mt-1 shrink-0" />
|
||||
) : (
|
||||
<AlertTriangle className="h-4 w-4 mt-1 shrink-0 text-warning" />
|
||||
)}
|
||||
<div>
|
||||
<span className="font-mono text-xs mr-2">{c.code}</span>
|
||||
{c.message}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showGapWorklist && (
|
||||
<div className="space-y-3">
|
||||
<h3
|
||||
className="text-sm font-medium uppercase tracking-wider text-muted-foreground"
|
||||
aria-live="polite"
|
||||
>
|
||||
Verifikationer som saknar basbelopp ({gaps.length})
|
||||
</h3>
|
||||
|
||||
{gapsLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Söker berörda verifikationer...
|
||||
</div>
|
||||
) : gapsFailed ? (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
Kunde inte hämta verifikationslistan.
|
||||
</p>
|
||||
<Button variant="outline" onClick={() => setGapsResult(null)}>
|
||||
Försök igen
|
||||
</Button>
|
||||
</div>
|
||||
) : gaps.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Inga verifikationer hittades. Bristen kan ligga utanför perioden eller i
|
||||
bokföring som inte är bokförd.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
<div>
|
||||
<Label htmlFor="rc-supplier-type">Leverantörstyp</Label>
|
||||
<Select
|
||||
value={sharedSel.supplierType}
|
||||
onValueChange={(value) => {
|
||||
const supplierType = value as SupplierType
|
||||
setSharedSel((prev) => ({
|
||||
supplierType,
|
||||
// Non-EU + goods is import VAT, not reverse charge:
|
||||
// coerce back to service so an invalid combo can't
|
||||
// be mass-applied.
|
||||
supplyType:
|
||||
supplierType === 'non_eu_business' ? 'service' : prev.supplyType,
|
||||
}))
|
||||
}}
|
||||
disabled={busy}
|
||||
>
|
||||
<SelectTrigger id="rc-supplier-type" className="mt-1 w-56">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="eu_business">EU-leverantör</SelectItem>
|
||||
<SelectItem value="non_eu_business">Utanför EU</SelectItem>
|
||||
<SelectItem value="swedish_business">Svensk omvänd skattskyldighet</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="rc-supply-type">Typ av inköp</Label>
|
||||
<Select
|
||||
value={sharedSel.supplyType}
|
||||
onValueChange={(value) =>
|
||||
setSharedSel((prev) => ({ ...prev, supplyType: value as SupplyType }))
|
||||
}
|
||||
disabled={busy}
|
||||
>
|
||||
<SelectTrigger id="rc-supply-type" className="mt-1 w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="service">Tjänst</SelectItem>
|
||||
{sharedSel.supplierType !== 'non_eu_business' && (
|
||||
<SelectItem value="goods">Vara</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button onClick={handleFixAll} disabled={!canWrite || busy}>
|
||||
{bulkProgress ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<CheckCircle2 className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Korrigera alla ({gaps.length})
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{bulkProgress && (
|
||||
<p role="status" className="text-sm text-muted-foreground">
|
||||
Korrigerar {Math.min(bulkProgress.done + 1, bulkProgress.total)} av{' '}
|
||||
{bulkProgress.total}...
|
||||
</p>
|
||||
)}
|
||||
{!canWrite && (
|
||||
<p className="text-xs text-muted-foreground">Kräver skrivbehörighet.</p>
|
||||
)}
|
||||
|
||||
<DataList>
|
||||
{visibleGaps.map((gap) => {
|
||||
const sel = classificationFor(gap)
|
||||
const expanded = expandedId === gap.entryId
|
||||
const rowError = rowErrors[gap.entryId]
|
||||
return (
|
||||
<DataListRow
|
||||
key={gap.entryId}
|
||||
expanded={expanded}
|
||||
onClick={() => setExpandedId(expanded ? null : gap.entryId)}
|
||||
trailing={
|
||||
<>
|
||||
<span className="text-sm tabular-nums text-muted-foreground">
|
||||
{formatAmount(gap.expectedBasisAmount)} kr
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleFixOne(gap)
|
||||
}}
|
||||
disabled={!canWrite || busy}
|
||||
>
|
||||
{fixingId === gap.entryId ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<CheckCircle2 className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Korrigera
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-expanded={expanded}
|
||||
aria-label={
|
||||
expanded
|
||||
? `Dölj detaljer för verifikation ${gap.voucherSeries}-${gap.voucherNumber}`
|
||||
: `Visa detaljer för verifikation ${gap.voucherSeries}-${gap.voucherNumber}`
|
||||
}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setExpandedId(expanded ? null : gap.entryId)
|
||||
}}
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
expandedContent={
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm tabular-nums">
|
||||
{gap.rcOutputAccount} har {formatAmount(gap.rcOutputAmount)} kr
|
||||
fiktiv moms: saknar basbelopp{' '}
|
||||
{formatAmount(gap.expectedBasisAmount)} kr
|
||||
</p>
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
<div>
|
||||
<Label htmlFor={`rc-supplier-${gap.entryId}`}>
|
||||
Leverantörstyp
|
||||
</Label>
|
||||
<Select
|
||||
value={sel.supplierType}
|
||||
onValueChange={(value) => {
|
||||
const supplierType = value as SupplierType
|
||||
setOverrides((prev) => ({
|
||||
...prev,
|
||||
[gap.entryId]: {
|
||||
supplierType,
|
||||
supplyType:
|
||||
supplierType === 'non_eu_business'
|
||||
? 'service'
|
||||
: sel.supplyType,
|
||||
},
|
||||
}))
|
||||
}}
|
||||
disabled={busy}
|
||||
>
|
||||
<SelectTrigger
|
||||
id={`rc-supplier-${gap.entryId}`}
|
||||
className="mt-1 w-56"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="eu_business">EU-leverantör</SelectItem>
|
||||
<SelectItem value="non_eu_business">Utanför EU</SelectItem>
|
||||
<SelectItem value="swedish_business">Svensk omvänd skattskyldighet</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor={`rc-supply-${gap.entryId}`}>Typ av inköp</Label>
|
||||
<Select
|
||||
value={sel.supplyType}
|
||||
onValueChange={(value) =>
|
||||
setOverrides((prev) => ({
|
||||
...prev,
|
||||
[gap.entryId]: {
|
||||
...sel,
|
||||
supplyType: value as SupplyType,
|
||||
},
|
||||
}))
|
||||
}
|
||||
disabled={busy}
|
||||
>
|
||||
<SelectTrigger
|
||||
id={`rc-supply-${gap.entryId}`}
|
||||
className="mt-1 w-32"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="service">Tjänst</SelectItem>
|
||||
{sel.supplierType !== 'non_eu_business' && (
|
||||
<SelectItem value="goods">Vara</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<DataListPrimary>
|
||||
Verifikation {gap.voucherSeries}-{gap.voucherNumber}
|
||||
</DataListPrimary>
|
||||
<DataListMeta>
|
||||
<span className="tabular-nums">{formatDate(gap.entryDate)}</span>
|
||||
{gap.description && (
|
||||
<>
|
||||
<DataListMetaSeparator />
|
||||
<span className="truncate">{gap.description}</span>
|
||||
</>
|
||||
)}
|
||||
</DataListMeta>
|
||||
{/* Always visible: a failed korrigering must not hide
|
||||
its reason behind the collapsed expansion. */}
|
||||
{rowError && (
|
||||
<p role="alert" className="mt-1 text-xs text-destructive">
|
||||
{rowError}
|
||||
</p>
|
||||
)}
|
||||
</DataListRow>
|
||||
)
|
||||
})}
|
||||
</DataList>
|
||||
|
||||
{gaps.length > GAP_PREVIEW_COUNT && (
|
||||
<Button variant="ghost" onClick={() => setShowAll((v) => !v)}>
|
||||
{showAll ? 'Visa färre' : `Visa alla ${gaps.length} verifikationer`}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
<DestructiveConfirmDialog {...dialogProps} />
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer, Legend } from 'recharts'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
import type { VatDeclarationRutor } from '@/types'
|
||||
|
||||
interface VatCompositionChartProps {
|
||||
rutor: VatDeclarationRutor
|
||||
}
|
||||
|
||||
const COLORS = [
|
||||
'hsl(var(--chart-1))',
|
||||
'hsl(var(--chart-2))',
|
||||
'hsl(var(--chart-3))',
|
||||
'hsl(var(--chart-4))',
|
||||
'hsl(var(--chart-1) / 0.6)',
|
||||
'hsl(var(--chart-2) / 0.6)',
|
||||
'hsl(var(--chart-3) / 0.6)',
|
||||
]
|
||||
|
||||
export function VatCompositionChart({ rutor }: VatCompositionChartProps) {
|
||||
const chartData = useMemo(() => {
|
||||
const segments = [
|
||||
{ name: 'Utgående 25%', value: rutor.ruta10 },
|
||||
{ name: 'Utgående 12%', value: rutor.ruta11 },
|
||||
{ name: 'Utgående 6%', value: rutor.ruta12 },
|
||||
{ name: 'Omvänd 25%', value: rutor.ruta30 },
|
||||
{ name: 'Omvänd 12%', value: rutor.ruta31 },
|
||||
{ name: 'Omvänd 6%', value: rutor.ruta32 },
|
||||
{ name: 'Ingående moms', value: rutor.ruta48 },
|
||||
]
|
||||
return segments.filter((s) => s.value > 0)
|
||||
}, [rutor])
|
||||
|
||||
if (chartData.length === 0) return null
|
||||
|
||||
return (
|
||||
<Card className="mb-4">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">Momsfördelning</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={chartData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={60}
|
||||
outerRadius={90}
|
||||
paddingAngle={2}
|
||||
dataKey="value"
|
||||
>
|
||||
{chartData.map((_, index) => (
|
||||
<Cell key={index} fill={COLORS[index % COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
formatter={(value) => [
|
||||
formatCurrency(Number(value)),
|
||||
]}
|
||||
/>
|
||||
<Legend />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -27,6 +27,9 @@ import { roundOre } from '@/lib/money'
|
||||
import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
|
||||
import { AccountNumber } from '@/components/ui/account-number'
|
||||
import { ReportExportMenu } from '@/components/reports/ReportExportMenu'
|
||||
import { VatChecksCard } from '@/components/reports/VatChecksCard'
|
||||
import { runVatDeclarationChecks } from '@/lib/reports/vat-declaration-checks'
|
||||
import { Table, TableBody } from '@/components/ui/table'
|
||||
import { useCompanySettings } from '@/components/settings/useSettings'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { SkatteverketPanel } from '@/components/reports/SkatteverketPanel'
|
||||
@@ -42,10 +45,6 @@ const TrialBalanceChart = dynamic(
|
||||
() => import('@/components/reports/TrialBalanceChart').then((m) => m.TrialBalanceChart),
|
||||
{ ssr: false, loading: chartFallback },
|
||||
)
|
||||
const VatCompositionChart = dynamic(
|
||||
() => import('@/components/reports/VatCompositionChart').then((m) => m.VatCompositionChart),
|
||||
{ ssr: false, loading: chartFallback },
|
||||
)
|
||||
const IncomeExpenseChart = dynamic(
|
||||
() => import('@/components/reports/IncomeExpenseChart').then((m) => m.IncomeExpenseChart),
|
||||
{ ssr: false, loading: chartFallback },
|
||||
@@ -1077,33 +1076,36 @@ function VatManualFilingCard({ xmlHref, pdfHref }: { xmlHref: string; pdfHref: s
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Lämna in din momsdeklaration</CardTitle>
|
||||
<CardTitle className="text-base">Lämna in själv (utan anslutning)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Deklarationen ovan är klar. Du behöver inte vara ansluten till
|
||||
Skatteverket för att lämna in den.
|
||||
Du behöver inte vara ansluten till Skatteverket för att lämna in.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Enklast: ladda ner filen (XML), logga in på skatteverket.se med BankID,
|
||||
öppna Moms- och arbetsgivardeklarationer och välj Deklarera via fil.
|
||||
Ladda upp filen, granska och signera. Vill du hellre fylla i rutorna
|
||||
för hand laddar du ner PDF:en och skriver av beloppen (i hela kronor).
|
||||
<ol className="list-decimal pl-6 space-y-1 text-sm text-muted-foreground">
|
||||
<li>Ladda ner XML-filen nedan.</li>
|
||||
<li>Logga in på skatteverket.se med BankID.</li>
|
||||
<li>Öppna Moms- och arbetsgivardeklarationer och välj Deklarera via fil.</li>
|
||||
<li>Ladda upp filen, granska och signera.</li>
|
||||
</ol>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Vill du hellre fylla i rutorna för hand laddar du ner PDF:en och skriver av
|
||||
beloppen (i hela kronor).
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" asChild className="gap-2">
|
||||
<Button variant="outline" asChild className="gap-2">
|
||||
<a href={xmlHref} target="_blank" rel="noopener noreferrer">
|
||||
<FileCode className="h-4 w-4" />
|
||||
Ladda ner fil för uppladdning (XML)
|
||||
</a>
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" asChild className="gap-2">
|
||||
<Button variant="outline" asChild className="gap-2">
|
||||
<a href={pdfHref} target="_blank" rel="noopener noreferrer">
|
||||
<FileDown className="h-4 w-4" />
|
||||
Ladda ner momsdeklaration (PDF)
|
||||
</a>
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" asChild className="gap-2">
|
||||
<Button variant="outline" asChild className="gap-2">
|
||||
<a href={SKATTEVERKET_MOMS_URL} target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
Öppna skatteverket.se
|
||||
@@ -1130,11 +1132,18 @@ function VatBookingCard({
|
||||
year,
|
||||
period,
|
||||
fiscalPeriodId,
|
||||
checksBlocked,
|
||||
}: {
|
||||
periodType: VatPeriodType
|
||||
year: number
|
||||
period: number
|
||||
fiscalPeriodId?: string
|
||||
/**
|
||||
* True when the local pre-flight checks found ERRORs. Booking stays
|
||||
* possible (the RC-basis fixes only touch 44xx/45xx pairs, never the 26xx
|
||||
* accounts the settlement clears), but the user should know before filing.
|
||||
*/
|
||||
checksBlocked?: boolean
|
||||
}) {
|
||||
const { canWrite } = useCanWrite()
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
@@ -1231,27 +1240,45 @@ function VatBookingCard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{checksBlocked && (
|
||||
<div className="flex items-start gap-3 rounded-lg border border-border bg-muted/30 p-3 text-sm">
|
||||
<AlertCircle className="h-4 w-4 mt-1 shrink-0 text-muted-foreground" />
|
||||
<p>
|
||||
Det finns fel under 1 · Kontrollera underlaget. Du kan bokföra momsen ändå,
|
||||
men åtgärda felen innan du lämnar in.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{failed ? (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<p className="text-sm text-destructive">Kunde inte hämta verifikatförslaget.</p>
|
||||
<Button variant="outline" size="sm" onClick={() => setRefreshKey((k) => k + 1)}>
|
||||
<Button variant="outline" onClick={() => setRefreshKey((k) => k + 1)}>
|
||||
Försök igen
|
||||
</Button>
|
||||
</div>
|
||||
) : !upToDate ? (
|
||||
<Skeleton className="h-10 w-40" />
|
||||
) : proposal?.is_empty ? (
|
||||
<p className="text-sm text-muted-foreground">Ingen moms att bokföra för perioden.</p>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
// A posted settlement blocks re-booking: the proposal re-clears the
|
||||
// FULL period (it is not delta-aware), so booking twice would
|
||||
// corrupt the 26xx balances. Annulling the verifikat restores them
|
||||
// and re-enables the button.
|
||||
disabled={!proposal || !canWrite || !!booked}
|
||||
onClick={() => setDialogOpen(true)}
|
||||
>
|
||||
Skapa verifikat
|
||||
</Button>
|
||||
<div className="space-y-2">
|
||||
<Button
|
||||
// A posted settlement blocks re-booking: the proposal re-clears the
|
||||
// FULL period (it is not delta-aware), so booking twice would
|
||||
// corrupt the 26xx balances. Annulling the verifikat restores them
|
||||
// and re-enables the button.
|
||||
disabled={!proposal || !canWrite || !!booked}
|
||||
onClick={() => setDialogOpen(true)}
|
||||
>
|
||||
Skapa verifikat
|
||||
</Button>
|
||||
{!canWrite && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Du har läsbehörighet och kan inte bokföra.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
@@ -1449,6 +1476,12 @@ export function VatDeclarationView() {
|
||||
const error = upToDate ? (result.error ?? null) : null
|
||||
const loading = fetchKey !== null && !upToDate
|
||||
|
||||
// Local pre-flight checks on the calculated declaration. Computed here (not
|
||||
// in SkatteverketPanel) so every user sees them: they gate direct submission
|
||||
// but concern manual filers just as much.
|
||||
const checks = data ? runVatDeclarationChecks(data.rutor) : []
|
||||
const checksBlocked = checks.some((c) => c.status === 'ERROR')
|
||||
|
||||
// Settings not settled yet — the picker defaults and the gate both depend
|
||||
// on them, so hold the whole view in a skeleton.
|
||||
if (settingsLoading || periodType === null) {
|
||||
@@ -1491,11 +1524,11 @@ export function VatDeclarationView() {
|
||||
|
||||
return (
|
||||
<VatDrillContext.Provider value={{ fiscalPeriodId: isYearly ? fiscalPeriodId : undefined }}>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-8">
|
||||
{/* XML and PDF live in "Lämna in" below: they are filing artifacts, not
|
||||
report exports, and one home avoids two competing download surfaces. */}
|
||||
<ReportExportMenu
|
||||
items={[
|
||||
{ format: 'xml', href: `/api/reports/vat-declaration/eskd?${vatQueryString()}` },
|
||||
{ format: 'pdf', href: `/api/reports/vat-declaration/pdf?${vatQueryString()}` },
|
||||
{ format: 'xlsx', href: `/api/reports/vat-declaration/xlsx?${vatQueryString()}` },
|
||||
]}
|
||||
>
|
||||
@@ -1599,30 +1632,55 @@ export function VatDeclarationView() {
|
||||
|
||||
{data && !awaitingFiscalPeriod && (
|
||||
<div
|
||||
className={`space-y-4 transition-opacity duration-150 ${loading ? 'opacity-60' : ''}`}
|
||||
className={`space-y-8 transition-opacity duration-150 ${loading ? 'opacity-60' : ''}`}
|
||||
>
|
||||
<VatCompositionChart rutor={data.rutor} />
|
||||
{/* The page follows the filing pipeline: kontrollera, granska,
|
||||
bokför, lämna in. The checks come first because their errors
|
||||
invalidate everything below them. */}
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
1 · Kontrollera underlaget
|
||||
</h2>
|
||||
<VatChecksCard
|
||||
checks={checks}
|
||||
periodType={periodType}
|
||||
year={year}
|
||||
period={period}
|
||||
fiscalPeriodId={isYearly ? fiscalPeriodId : undefined}
|
||||
onCorrected={() => setRetryKey((k) => k + 1)}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Summary */}
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
2 · Granska deklarationen
|
||||
</h2>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<CardTitle>Momsdeklaration - {data.period.start} till {data.period.end}</CardTitle>
|
||||
<Badge
|
||||
variant={
|
||||
data.rutor.ruta49 > 0
|
||||
? 'warning'
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge
|
||||
variant={
|
||||
data.rutor.ruta49 > 0
|
||||
? 'warning'
|
||||
: data.rutor.ruta49 < 0
|
||||
? 'success'
|
||||
: 'secondary'
|
||||
}
|
||||
>
|
||||
{data.rutor.ruta49 > 0
|
||||
? 'Att betala'
|
||||
: data.rutor.ruta49 < 0
|
||||
? 'success'
|
||||
: 'secondary'
|
||||
}
|
||||
>
|
||||
{data.rutor.ruta49 > 0
|
||||
? `Att betala: ${formatAmount(data.rutor.ruta49)} kr`
|
||||
: data.rutor.ruta49 < 0
|
||||
? `Att återfå: ${formatAmount(Math.abs(data.rutor.ruta49))} kr`
|
||||
: 'Ingen moms'}
|
||||
</Badge>
|
||||
? 'Att återfå'
|
||||
: 'Ingen moms'}
|
||||
</Badge>
|
||||
{data.rutor.ruta49 !== 0 && (
|
||||
<span className="font-display text-xl tabular-nums">
|
||||
{formatAmount(Math.abs(data.rutor.ruta49))} kr
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -1633,9 +1691,11 @@ export function VatDeclarationView() {
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
{/* Utgående moms */}
|
||||
<div>
|
||||
<h4 className="font-semibold mb-3">Utgående moms (försäljning)</h4>
|
||||
<div><table className="w-full text-sm">
|
||||
<tbody>
|
||||
<h3 className="text-sm font-medium uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Utgående moms (försäljning)
|
||||
</h3>
|
||||
<Table>
|
||||
<TableBody>
|
||||
{data.rutor.ruta05 > 0 && (
|
||||
<VatRutaRow
|
||||
ruta="05"
|
||||
@@ -1694,27 +1754,30 @@ export function VatDeclarationView() {
|
||||
year={year}
|
||||
period={period}
|
||||
/>
|
||||
</tbody>
|
||||
</TableBody>
|
||||
<tfoot>
|
||||
<tr className="border-t-2 font-semibold">
|
||||
<td className="py-2">Summa utgående</td>
|
||||
<td className="py-2 text-right">
|
||||
<tr className="border-t font-medium">
|
||||
<td className="py-2">Summa utgående moms</td>
|
||||
<td className="py-2 text-right tabular-nums">
|
||||
{formatAmount(
|
||||
data.rutor.ruta10 + data.rutor.ruta11 + data.rutor.ruta12 +
|
||||
data.rutor.ruta30 + data.rutor.ruta31 + data.rutor.ruta32
|
||||
data.rutor.ruta30 + data.rutor.ruta31 + data.rutor.ruta32 +
|
||||
data.rutor.ruta60 + data.rutor.ruta61 + data.rutor.ruta62
|
||||
)} kr
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table></div>
|
||||
</Table>
|
||||
|
||||
{/* Omvänd skattskyldighet (inköp) */}
|
||||
{(data.rutor.ruta20 > 0 || data.rutor.ruta21 > 0 || data.rutor.ruta22 > 0 || data.rutor.ruta23 > 0 || data.rutor.ruta24 > 0 ||
|
||||
data.rutor.ruta30 > 0 || data.rutor.ruta31 > 0 || data.rutor.ruta32 > 0) && (
|
||||
<>
|
||||
<h4 className="font-semibold mb-3 mt-6">Omvänd skattskyldighet (inköp)</h4>
|
||||
<div><table className="w-full text-sm">
|
||||
<tbody>
|
||||
<h3 className="text-sm font-medium uppercase tracking-wider text-muted-foreground mb-3 mt-6">
|
||||
Omvänd skattskyldighet (inköp)
|
||||
</h3>
|
||||
<Table>
|
||||
<TableBody>
|
||||
<VatRutaRow ruta="20" label="Inköp av varor från annat EU-land" amount={0} baseAmount={data.rutor.ruta20} noVat periodType={periodType} year={year} period={period} />
|
||||
<VatRutaRow ruta="21" label="Inköp av tjänster från annat EU-land" amount={0} baseAmount={data.rutor.ruta21} noVat periodType={periodType} year={year} period={period} />
|
||||
<VatRutaRow ruta="22" label="Inköp av tjänster utanför EU" amount={0} baseAmount={data.rutor.ruta22} noVat periodType={periodType} year={year} period={period} />
|
||||
@@ -1723,17 +1786,36 @@ export function VatDeclarationView() {
|
||||
<VatRutaRow ruta="30" label="Utgående moms 25% (omvänd)" amount={data.rutor.ruta30} baseAmount={0} periodType={periodType} year={year} period={period} />
|
||||
<VatRutaRow ruta="31" label="Utgående moms 12% (omvänd)" amount={data.rutor.ruta31} baseAmount={0} periodType={periodType} year={year} period={period} />
|
||||
<VatRutaRow ruta="32" label="Utgående moms 6% (omvänd)" amount={data.rutor.ruta32} baseAmount={0} periodType={periodType} year={year} period={period} />
|
||||
</tbody>
|
||||
</table></div>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Moms vid import */}
|
||||
{(data.rutor.ruta50 > 0 || data.rutor.ruta60 > 0 || data.rutor.ruta61 > 0 || data.rutor.ruta62 > 0) && (
|
||||
<>
|
||||
<h3 className="text-sm font-medium uppercase tracking-wider text-muted-foreground mb-3 mt-6">
|
||||
Moms vid import
|
||||
</h3>
|
||||
<Table>
|
||||
<TableBody>
|
||||
<VatRutaRow ruta="50" label="Beskattningsunderlag vid import" amount={0} baseAmount={data.rutor.ruta50} noVat periodType={periodType} year={year} period={period} />
|
||||
<VatRutaRow ruta="60" label="Utgående moms 25% import" amount={data.rutor.ruta60} baseAmount={0} periodType={periodType} year={year} period={period} />
|
||||
<VatRutaRow ruta="61" label="Utgående moms 12% import" amount={data.rutor.ruta61} baseAmount={0} periodType={periodType} year={year} period={period} />
|
||||
<VatRutaRow ruta="62" label="Utgående moms 6% import" amount={data.rutor.ruta62} baseAmount={0} periodType={periodType} year={year} period={period} />
|
||||
</TableBody>
|
||||
</Table>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Ingående moms */}
|
||||
<div>
|
||||
<h4 className="font-semibold mb-3">Ingående moms (avdragsgill)</h4>
|
||||
<div><table className="w-full text-sm">
|
||||
<tbody>
|
||||
<h3 className="text-sm font-medium uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Ingående moms (avdragsgill)
|
||||
</h3>
|
||||
<Table>
|
||||
<TableBody>
|
||||
<VatRutaRow
|
||||
ruta="48"
|
||||
label="Ingående moms att dra av"
|
||||
@@ -1759,28 +1841,29 @@ export function VatDeclarationView() {
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</TableBody>
|
||||
<tfoot>
|
||||
<tr className="border-t-2 font-semibold">
|
||||
<td className="py-2">Summa ingående</td>
|
||||
<td className="py-2 text-right">{formatAmount(data.rutor.ruta48)} kr</td>
|
||||
<tr className="border-t font-medium">
|
||||
<td className="py-2">Summa ingående moms</td>
|
||||
<td className="py-2 text-right tabular-nums">{formatAmount(data.rutor.ruta48)} kr</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table></div>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Net result */}
|
||||
<div className="mt-6 pt-4 border-t-2">
|
||||
{/* Net result: the tables' sum row. The headline amount lives in
|
||||
the card header next to the status badge. */}
|
||||
<div className="mt-6 pt-4 border-t">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<span className="font-mono text-xs bg-muted px-1 rounded mr-2">49</span>
|
||||
<span className="font-bold text-lg">
|
||||
<span className="text-sm font-medium">
|
||||
{data.rutor.ruta49 >= 0 ? 'Moms att betala' : 'Moms att återfå'}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className={`text-xl font-bold ${
|
||||
className={`font-semibold tabular-nums ${
|
||||
data.rutor.ruta49 > 0
|
||||
? 'text-warning'
|
||||
: data.rutor.ruta49 < 0
|
||||
@@ -1794,18 +1877,30 @@ export function VatDeclarationView() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<VatBookingCard
|
||||
periodType={periodType}
|
||||
year={year}
|
||||
period={period}
|
||||
fiscalPeriodId={isYearly ? fiscalPeriodId : undefined}
|
||||
/>
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
3 · Bokför momsen
|
||||
</h2>
|
||||
<VatBookingCard
|
||||
periodType={periodType}
|
||||
year={year}
|
||||
period={period}
|
||||
fiscalPeriodId={isYearly ? fiscalPeriodId : undefined}
|
||||
checksBlocked={checksBlocked}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<VatManualFilingCard
|
||||
xmlHref={`/api/reports/vat-declaration/eskd?${vatQueryString()}`}
|
||||
pdfHref={`/api/reports/vat-declaration/pdf?${vatQueryString()}`}
|
||||
/>
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
4 · Lämna in
|
||||
</h2>
|
||||
<VatManualFilingCard
|
||||
xmlHref={`/api/reports/vat-declaration/eskd?${vatQueryString()}`}
|
||||
pdfHref={`/api/reports/vat-declaration/pdf?${vatQueryString()}`}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1818,7 +1913,7 @@ export function VatDeclarationView() {
|
||||
year={year}
|
||||
period={period}
|
||||
hasData={data !== null}
|
||||
rutor={data?.rutor ?? null}
|
||||
localBlocked={checksBlocked}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user