* fix(security): gate replace_sie_import behind owner/admin membership The RPC was SECURITY DEFINER with EXECUTE granted to PUBLIC and anon, no company_members lookup, no auth.uid() reference and no unauthorized raise, while setting gnubok.allow_delete to disarm the BFL immutability and retention triggers. Any caller holding a company_id and an import id could hard delete another tenant's verifikationer. Confirmed live in production. Applies the same fail closed owner/admin guard that undo_sie_import already carries (migration 20260624120000), resolving the actor from COALESCE(p_user_id, auth.uid()) so it denies when the role is NULL, then revokes EXECUTE from PUBLIC and anon. search_path and the raised statement_timeout are restated, since CREATE OR REPLACE drops settings that are not repeated. userId is a required parameter on replaceSIEImport: the service client has a NULL auth.uid(), so a caller without an explicit actor now fails to compile rather than hitting the closed gate at runtime. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(security): validate arcim OAuth callback state server side The callback route is skipAuth and decoded the state parameter as plain base64url JSON, trusting consentId and provider from it. A one time code was minted at flow start and never read. An unauthenticated attacker who learned a consent id could run an OAuth flow on their own provider account and post the callback with a forged state, landing their tokens on another tenant's consent, so the victim's next migration imported the attacker's ledger. State is now an opaque randomBytes(32) pointer to a provider_otc row, consumed by a single atomic UPDATE guarded on used_at IS NULL and expires_at, so a replay loses the row lock race and updates nothing. provider is read from provider_consents rather than trusted from the client. provider_otc already existed for exactly this purpose and was never wired up. Also scopes getConsent to an owning company, closing a cross tenant status oracle where the preview and migrate paths echoed a consent's status before the scoped check ran. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(security): scope documents storage to company_id (phase A) The documents bucket policies matched on auth.uid(), and upload keys were documents/{userId}/..., so company membership was never consulted. Removing a member revoked nothing: their session still authenticated and they kept direct Storage read access to every receipt, supplier invoice and bank statement they had uploaded. The same bug was fixed for sie-files in 20260416120000; this bucket was left behind. Phase A is additive. Company scoped policies are added alongside the uploader scoped ones, uploads move to documents/{companyId}/{userId}/..., and reads accept either layout so nothing breaks mid migration. Phase C, which drops the old policies, is gated on the backfill reporting zero remaining legacy prefix objects. The policy compares the company segment as text rather than casting to uuid the way sie-files does: this bucket holds keys whose second segment is not a uuid (MCP audit packages), and Postgres does not guarantee the bucket prefix qual runs before the cast, so a planner reordering would raise 22P02 and fail the whole query instead of filtering the row out. deleteDocument now removes both candidate keys. Removing only the stored pointer would leave a readable orphan copy of a document the user asked to erase. The backfill script is included but has never been run. It defaults to dry run, refuses .env.local by name, and verifies each copy is readable and SHA-256 identical before repointing the row. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(security): enforce events:read scope and membership on /api/events This was the only one of the three validateApiKey call sites with no downstream guard: v1 and the MCP server both check scope and re-verify company membership, this route did neither. An events:read scope existed and was documented as gating the endpoint but was never called, so a legacy key falling back to DEFAULT_SCOPES read the full log. The bound company id went straight from the api_keys row into a service role query, so a key whose user had been removed from the company kept reading. Adds the scope check before any database access, re-verifies company_members with archived_at IS NULL, honours test mode by stamping X-Gnubok-Mode instead of ignoring it, applies minimisePayload so the pull surface can never return a wider payload than the push surface, and replaces the three flat error strings with the canonical envelope. Test key reads are served rather than blocked: TEST_KEY_WRITE_BLOCKED is gated on mutations in with-api-v1, so a read gets the same treatment as every other v1 read endpoint. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * perf(bookkeeping): sweep remaining journal_entries!inner embeds A previous refactor removed this pattern from lib/reports and introduced fetchEntryLines, but the class was never swept. Seventeen sites remained and had become the top application consumer of production database time: measured across the resulting query shapes, 32,694 calls and 25,848 seconds of execution, mean 790ms, with shapes averaging 2.6s and 3.0s and maxing at 7,962ms against the 8s statement_timeout, which surfaced to users as 500s on the booking path. PostgREST compiles an embed with filters on the embedded side into a correlated INNER JOIN LATERAL with a parameterized LIMIT, which stops Postgres reordering the join, so each query walked the whole journal_entry_lines table across all tenants. Driving from the entries side instead turns that into two indexed round trips. Converted sites keep their existing shape: the helper reattaches the parent entry under the same key the embed produced. Several conversions also remove a latent silent truncation where an unpaginated query was capped at PostgREST's 1000 row ceiling. Two deliberate exceptions. The free text ilike legs of the MCP display query stay on the embed, because each is capped at legLimit and that cap drives the truncation contract the tool reports, while the helper is unbounded. The accounts route moves to the existing get_account_usage_counts RPC instead, since its embed was a head count and the helper returns rows. commitEntry's write path is untouched: the change there is confined to the read query of the pre-commit dimension rule check. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(api): anchor v1 list cursors on created_at Page two returned page one, forever, while still advertising a fresh next_cursor. The three routes sorted by and encoded a Postgres date column, which serializes as YYYY-MM-DD, but decodeDefaultCursor validates the cursor timestamp as full ISO-8601 and returned null, so the keyset filter was never applied and has_more never went false. An integrator syncing verifikat looped on the newest rows indefinitely. The transactions route already solved this and its comment names the trap; the fix was never ported. All three now order and encode on created_at with an id tie break, matching the transactions keyset predicate exactly. ISO_TIMESTAMP is deliberately left alone: relaxing it would silently change sort semantics on the route that currently works. Default ordering therefore moves from business date to insert order. Every business date is still on the row, and the invoices list gains date_from and date_to filters so a date range is still reachable; the other two already had them. The tests use an in-memory PostgREST that actually evaluates the filters, because the repo's pass-through mock cannot catch this class of bug: the bug is that the filter is never sent. They walk to exhaustion with a hard iteration cap, so an unterminated walk fails instead of hanging. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(api): separate dry run from commit in the idempotency hash The request hash was built from url.pathname, which excludes the query string, so a dry run and its commit hashed identically. Following the flow documented in dry-run.ts, re-issuing the request with the same Idempotency-Key returned the cached preview with Idempotent-Replayed set and wrote nothing, while reporting 200. An agent or integrator saw success for a write that never happened. dry_run is folded into the hash only when true, not as an unconditional boolean. Including it as false would change the hash of every ordinary write, and with a 24h idempotency TTL any key in flight across the deploy would fail the request_hash comparison and 409 on a legitimate retry. Both hash call sites now go through one shared helper so they cannot drift into a permanent cache miss, and dry run responses are no longer stored at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * ci: install the Bedrock SDK out of tree in the compliance review The Swedish accounting compliance gate had failed ten consecutive runs and so was posting nothing. With --no-package-lock npm discarded the lockfile and re-resolved the whole tree from package.json, floating @hookform/resolvers to 5.4.3, whose valibot ^1 peer conflicts with the pinned valibot 0.39.0. Installing into the parent of the checkout resolves only that one package, so an unrelated peer conflict can never take the gate down again. Node still finds it because ESM bare specifiers walk up parent node_modules; NODE_PATH would not have worked, as it is CommonJS only. --legacy-peer-deps was rejected because it masks future genuine peer conflicts and still reifies the full tree. The same step's SDK version is aligned from 0.31.0 back to the 0.29.1 that package.json and check:guards enforce after the streaming outage. That drift went unnoticed because the pin guard only inspects package.json and the lockfile, never workflow files. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * build(docker): generate crontabs from vercel.json vercel.json defines 16 cron jobs; both Docker crontabs carried 9, and were byte identical to each other. Self hosted deployments therefore never sent recurring invoices, never dispatched webhooks and never cleaned up idempotency keys. tax-deadlines also ran once a year on 2 January instead of daily, and documents/verify weekly instead of daily. Extension crons are included rather than excluded. The Dockerfile copies the whole tree before building, so every extension cron route is compiled into the image regardless of the enabled preset, and each returns 200 when its extension is unconfigured, so curl -sf logs no failure. Two such entries were already present in the crontab for extensions absent from the preset, which settles the intent. documents/verify is treated as drift rather than a self hosted concession: the weekly cadence was present in the hosted crontab too, and the run is capped at 200 documents walking a nulls-first queue, so weekly drains the integrity queue seven times slower on a check that exists for BFL retention. webhooks/dispatch keeps its per minute cadence, adding 1,440 requests a day on self hosted. A gentler tick would silently stretch the first retry, since the retry ladder opens at 60 seconds. SCHEDULE_OVERRIDES is the one line place to change that. A parity test asserts the path sets match minus a documented exclusion list, and ratchets three cron routes that are currently scheduled nowhere so they are named rather than silently rotting. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore(observability): add a provider agnostic error sink There is no error tracking in this codebase: logs go to console and Vercel retention and nowhere else, nothing alerts on the 16 cron jobs, and seven code comments across lib, app, components and extensions asserted that Sentry captures errors when Sentry is not a dependency. The two most recent bug fixes on this repo were both discovered by customer email. This adds the sink, not a vendor. No dependency is taken: the interface has a no-op default and a registration point, so behaviour is unchanged until an adapter is registered. Releases are tagged from the build id already inlined by next.config.ts. Redaction moved out of lib/logger.ts into a leaf module that both the logger and the sink import, so there is one denylist and no path from application data to a third party can skip the personnummer regex, including direct sink calls that bypass the logger. That matters here because these logs carry personnummer and financial data. verifyCronSecret now reports its own 401s, which covers all 16 jobs without touching a route file and catches the case where CRON_SECRET is rotated without updating the scheduler and every job silently 401s forever. The threshold is one failure rather than the backup alert's three: suppressing the first occurrence is precisely how an outage stays invisible. The seven misleading comments are corrected to describe what the code actually does, including the two cases that still are not covered: the client side one, since the sink is server side, and a warn level call that is not forwarded. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: remediate the 2026-07-26 similar-sweep findings across all surfaces Resolves the ~150-finding sweep (dev_docs/similar-sweep-2026-07-26.md) with one agent per finding; every behavioural fix carries a regression test proven to fail at HEAD. Full status, corrections to the sweep, refusals and open decisions in dev_docs/similar-sweep-2026-07-26-remediation-status.md. Structural roots closed: - resolveSekAmountOrNull(): honest SEK resolution refuses instead of booking 1:1; four duplicated toSek closures now refuse via INVOICE_FX_RATE_MISSING - ledger-line-amount.ts: journal_entry_lines.currency labels the document, not the amount; SQL pre-filter decoy proven and fixed - sparse-patch.ts: .partial() does not strip .default() in Zod 4.4.3; the exploitable salary payslip-line PATCH and KPI preferences sinks fixed - tests/schema: migration-replay phantom-column guard (13k+ refs, closed CHECK sets, onConflict targets); found 28 real defects, all fixed, all four baselines now empty - three new ratchet guards: sek-labelled-amount, cross-extension-import, ungated-extension-route Highlights: lawful VAT-rate set on all seven invoice surfaces (ML 6 kap), RC input VAT mismatch wired on web + both MCP callers, missing-underlag resource delegates to the shared RPC predicate, push-notifications consent polarity fail-closed, deadlines undo honours requested state, silent-failure and read-side-fabrication classes fixed across settings/KPI/inbox/Stripe/ Arcim/kassaflodesanalys, error-envelope stringification fixed at 10+ sites with isSwedishUserMessage extended. Also includes the parallel session's MCP invoice tools (update_invoice, recurring schedules, invoice deliveries) which share files with the sweep work and are verified green together. 13 new migrations are NOT applied anywhere; they apply via branch merge. 20260726120000 backfills 1247 supplier-invoice rows. pg tests for new DDL are written but unrun (no local Postgres). Verified: 11088 tests / 881 files green, tsc 0 non-test errors, lint 0 errors, check:guards passing, MCP payload 57475/57500. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(migrations): rename replace_sie_import migration off main's 20260726090000 version origin/main shipped 20260726090000_agent_quota_rpc_caller_guard.sql; keeping our replace_sie_import migration on the same version would abort the Supabase apply with a schema_migrations_pkey duplicate at merge time. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(review): remediate pre-publish deep-review findings across all slices A 13-agent review of the full branch diff surfaced 1 critical, 5 high and ~45 further findings; this commit resolves them in one pass: - replace_sie_import / undo_sie_import: p_user_id honored only for service_role callers; any other caller is pinned to auth.uid() (impersonation gate bypass), authz raise errcode 42501 mapped to a Swedish 403 in the route, new caller-guard migration for undo - bulk_book_transactions refuses homogeneous non-SEK batches instead of writing foreign magnitudes into SEK ledger columns - credit-note cap trigger: company-match on credited_invoice_id, no cross-tenant figures in exception text - link_voucher RPCs resolve NULL invoice currency as SEK end to end - personal-number ciphertext CHECK split into NOT VALID + VALIDATE - same-currency foreign settlements clear 1510 at booking rate and book realized diff to 3960/7960; rate-less foreign write paths refuse - receivables revaluation covers partially_paid and outstanding amounts - period lock guard paginates candidates past the PostgREST 1000 cap - documents: service-client storage removals after authz, dual-layout reads in integrity cron and archive export, backfill delete-source sweep actually deletes with hash verification and shared-key grouping - invoice matching normalizes NULL/lowercase currencies (regression), duplicate candidates stop claiming amount matches they never ran - match-invoice aborts on any booking failure (no paid-without-verifikat) - refresh-exchange-rate reverts on concurrent booking (TOCTOU window) - KPI preferences upsert arbiter aligned to the company-scoped constraint - personnummer_last4 stripped from all salary responses incl. MCP tools - worked-hours batch restores destroyed rows on conflict and error paths - MCP: shared duplicate-claim builder (no more 'null kr'), short-circuit on tag_journal_lines overflow, auto_send schedules stage as high risk - observability sink redacts emails/IBANs/API keys and keeps redacted stacks in prod; assorted small guards (safe-return-to /@, dry_run=True, cursor helper off-by-one, OAuth state TTL 10 min, arcim saveMappings call removed) Full dispositions, deferred items and hand-verified accounting numbers are documented in the PR body and DECISIONS.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(personnummer): implement masking and encryption for personal numbers with tests * fix(review): address CI and compliance-bot findings for PR #1215 pg-real: the CI image's auth shim reads the legacy request.jwt.claim.role GUC, so both service-role simulations (runAsServiceRole and the invoice-delivery test's local helper) never satisfied auth.role() = 'service_role' and every legitimate p_user_id path failed closed; the shared helper now sets both GUC shapes plus SET LOCAL ROLE with a fail-loud sanity check, and the delivery test reuses it. The link-voucher migration had recreated both RPCs from pre-rewrite file text, reintroducing the NULL-unsafe membership pattern the null-safe-tenant-guards ratchet bans; both guards now use public.caller_is_company_member() with all currency changes preserved. Compliance bots: the customers export now emits the standard masked form instead of raw AES-256-GCM ciphertext in the Org-/personnummer column, and maskCustomerRow returns a non-round-trippable placeholder on decrypt failure instead of 500ing the list. MCP parity: gnubok_lock_period's staging pre-check now runs the exact countUnbookedInPeriod the commit path enforces (exported from period-service; local mirror deleted), and gnubok_agi_status resolves AGI state run-scoped so a correction run no longer renders as already filed. Declined with evidence: PR-Agent's opening-balances null-zeroing concern (all mergeable columns are NOT NULL with defaults per 20260713101000). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(review): address codex review findings on PR #1215 - restore 20260726140000 to its preview-recorded content and restate the NULL-safe tenant guard under 20260727130000: a recorded migration version never re-runs, so the in-place edit could not reach the preview branch - replace toFixed() with sv-SE two-decimal formatting in the ROT/RUT cap warning texts and update the pinned test expectations - drop the em dash in the fiscal-periods route comment - strip trailing whitespace in import-existing.test.ts Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(reports): raise timeout on real PDF render tests renderToBuffer does real @react-pdf layout work and exceeds the 5s default when the full suite saturates the CPU; tests pass in isolation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
3550 lines
136 KiB
TypeScript
3550 lines
136 KiB
TypeScript
'use client'
|
||
|
||
// Report view components, extracted verbatim from app/(dashboard)/reports/page.tsx.
|
||
// Rendered by the focused /reports/[slug] route (see components/reports/FocusedReport.tsx).
|
||
// The regulated table/figure rendering is unchanged from the original monolith.
|
||
|
||
import React, { useState, useEffect, useCallback } from 'react'
|
||
import Link from 'next/link'
|
||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||
import { Button } from '@/components/ui/button'
|
||
import { Input } from '@/components/ui/input'
|
||
import { Label } from '@/components/ui/label'
|
||
import { Badge } from '@/components/ui/badge'
|
||
import { AlertCircle, Check, ChevronDown, ChevronRight, ExternalLink, FileCode, FileDown, Percent } from 'lucide-react'
|
||
import { Skeleton } from '@/components/ui/skeleton'
|
||
import { EmptyState } from '@/components/ui/empty-state'
|
||
import { FyPicker } from '@/components/common/FyPicker'
|
||
import { ContextPicker } from '@/components/common/ContextPicker'
|
||
import { cn, formatDate } from '@/lib/utils'
|
||
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 { PageHeader } from '@/components/ui/page-header'
|
||
import { VatChecksCard } from '@/components/reports/VatChecksCard'
|
||
import { runVatDeclarationChecks } from '@/lib/reports/vat-declaration-checks'
|
||
import { rcInputTotalsFromDeclaration } from '@/lib/reports/vat-declaration'
|
||
import {
|
||
isFilingBlocked,
|
||
withRcBasisGapFindings,
|
||
type RcBasisGapScan,
|
||
} from '@/lib/reports/vat-filing-gate'
|
||
import { Table, TableBody } from '@/components/ui/table'
|
||
import { useCompanySettings } from '@/components/settings/useSettings'
|
||
import dynamic from 'next/dynamic'
|
||
import { SkatteverketPanel } from '@/components/reports/SkatteverketPanel'
|
||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||
import type { FormLine } from '@/components/bookkeeping/JournalEntryForm'
|
||
import type { VatSettlementProposal } from '@/lib/reports/vat-settlement'
|
||
|
||
// Recharts is ~180KB: defer the chart components so report tables (the
|
||
// regulated content) render without waiting for the charting bundle.
|
||
const chartFallback = () => <Skeleton className="h-64 w-full" />
|
||
const TrialBalanceChart = dynamic(
|
||
() => import('@/components/reports/TrialBalanceChart').then((m) => m.TrialBalanceChart),
|
||
{ ssr: false, loading: chartFallback },
|
||
)
|
||
const IncomeExpenseChart = dynamic(
|
||
() => import('@/components/reports/IncomeExpenseChart').then((m) => m.IncomeExpenseChart),
|
||
{ ssr: false, loading: chartFallback },
|
||
)
|
||
// The full journal entry editor is heavy (BAS catalogue, comboboxes, review
|
||
// dialogs): defer it until the user opens the momsverifikat dialog.
|
||
const JournalEntryForm = dynamic(() => import('@/components/bookkeeping/JournalEntryForm'), {
|
||
ssr: false,
|
||
loading: () => <Skeleton className="h-64 w-full" />,
|
||
})
|
||
import { useReportRowExpansion } from '@/components/reports/ReportRowExpansion'
|
||
import type {
|
||
ReportSourceLine,
|
||
ReportSourceFetcher,
|
||
} from '@/lib/reports/source-lines'
|
||
import type { MonthlyDataPoint } from '@/components/reports/IncomeExpenseChart'
|
||
import type { DateRangeValue } from '@/components/common/ReportDateRange'
|
||
import type { DimensionFilterValue } from '@/components/reports/DimensionFilter'
|
||
import type {
|
||
TrialBalanceRow,
|
||
IncomeStatementReport,
|
||
BalanceSheetReport,
|
||
ResultatrapportReport,
|
||
BalansrapportReport,
|
||
DimensionPnlReport,
|
||
VatDeclaration,
|
||
VatPeriodType,
|
||
} from '@/types'
|
||
|
||
function formatAmount(amount: number): string {
|
||
return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||
}
|
||
|
||
function reportQuery(
|
||
periodId: string,
|
||
range?: DateRangeValue,
|
||
dimensionFilter?: DimensionFilterValue | null,
|
||
): string {
|
||
const params = new URLSearchParams({ period_id: periodId })
|
||
if (range?.fromDate) params.set('from_date', range.fromDate)
|
||
if (range?.toDate) params.set('to_date', range.toDate)
|
||
if (dimensionFilter) {
|
||
params.set('dim_no', dimensionFilter.dimNo)
|
||
params.set('dim_code', dimensionFilter.code)
|
||
}
|
||
return params.toString()
|
||
}
|
||
|
||
export function TrialBalanceView({ periodId, onNavigateToAccount }: { periodId: string; onNavigateToAccount: (account: string) => void }) {
|
||
const [data, setData] = useState<{
|
||
rows: TrialBalanceRow[]
|
||
totalDebit: number
|
||
totalCredit: number
|
||
isBalanced: boolean
|
||
} | null>(null)
|
||
const [loading, setLoading] = useState(true)
|
||
const [error, setError] = useState<string | null>(null)
|
||
const [viewMode, setViewMode] = useState<'simplified' | 'detailed'>('simplified')
|
||
|
||
useEffect(() => {
|
||
setLoading(true)
|
||
setError(null)
|
||
fetch(`/api/reports/trial-balance?period_id=${periodId}`)
|
||
.then((res) => res.json())
|
||
.then((result) => {
|
||
if (result.error) {
|
||
setError(result.error)
|
||
} else {
|
||
setData(result.data)
|
||
}
|
||
setLoading(false)
|
||
})
|
||
.catch(() => {
|
||
setError('Kunde inte hämta saldobalans')
|
||
setLoading(false)
|
||
})
|
||
}, [periodId])
|
||
|
||
if (loading) {
|
||
return (
|
||
<Card>
|
||
<CardContent className="p-6 space-y-2">
|
||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||
<Skeleton key={i} className="h-4 w-full" />
|
||
))}
|
||
</CardContent>
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
if (error) {
|
||
return (
|
||
<Card>
|
||
<CardContent className="p-8 text-center text-destructive">
|
||
<AlertCircle className="h-6 w-6 mx-auto mb-2" />
|
||
{error}
|
||
</CardContent>
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
if (!data || data.rows.length === 0) {
|
||
return (
|
||
<Card>
|
||
<CardContent className="p-8 text-center text-muted-foreground">
|
||
Inga bokförda verifikationer i denna period.
|
||
</CardContent>
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
function getNetBalance(row: TrialBalanceRow, type: 'opening' | 'period' | 'closing'): number {
|
||
let debit: number, credit: number
|
||
if (type === 'opening') {
|
||
debit = row.opening_debit; credit = row.opening_credit
|
||
} else if (type === 'period') {
|
||
debit = row.period_debit; credit = row.period_credit
|
||
} else {
|
||
debit = row.closing_debit; credit = row.closing_credit
|
||
}
|
||
// Credit-normal accounts (liabilities/equity class 2, revenue class 3): positive when credit > debit
|
||
// Debit-normal accounts (assets class 1, expenses class 4-9): positive when debit > credit
|
||
const creditNormal = row.account_class === 2 || row.account_class === 3
|
||
return roundOre(creditNormal ? credit - debit : debit - credit)
|
||
}
|
||
|
||
function formatSigned(amount: number): string {
|
||
if (amount === 0) return ''
|
||
return amount < 0
|
||
? `−${formatAmount(Math.abs(amount))}`
|
||
: formatAmount(amount)
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<ReportExportMenu items={[{ format: 'xlsx', href: `/api/reports/trial-balance/xlsx?period_id=${periodId}` }]} />
|
||
<TrialBalanceChart rows={data.rows} />
|
||
<Card>
|
||
<CardHeader>
|
||
<div className="flex items-center justify-between">
|
||
<CardTitle>Saldobalans</CardTitle>
|
||
<div className="flex items-center gap-3">
|
||
<div className="flex items-center gap-0.5 rounded-md border border-border bg-card p-0.5">
|
||
<button
|
||
onClick={() => setViewMode('simplified')}
|
||
className={`px-3 py-1 text-xs rounded-sm transition-colors ${
|
||
viewMode === 'simplified'
|
||
? 'bg-secondary text-foreground'
|
||
: 'text-muted-foreground hover:text-foreground'
|
||
}`}
|
||
>
|
||
Förenklad
|
||
</button>
|
||
<button
|
||
onClick={() => setViewMode('detailed')}
|
||
className={`px-3 py-1 text-xs rounded-sm transition-colors ${
|
||
viewMode === 'detailed'
|
||
? 'bg-secondary text-foreground'
|
||
: 'text-muted-foreground hover:text-foreground'
|
||
}`}
|
||
>
|
||
Detaljerad
|
||
</button>
|
||
</div>
|
||
{data.isBalanced ? (
|
||
<Badge variant="success">Balanserad</Badge>
|
||
) : (
|
||
<Badge variant="destructive">Ej balanserad</Badge>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="overflow-x-auto -mx-2 px-2">
|
||
{viewMode === 'simplified' ? (
|
||
<table className="w-full text-sm min-w-[500px]">
|
||
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
|
||
<tr className="border-b text-left">
|
||
<th className="py-2 w-8"></th>
|
||
<th className="py-2 w-20">Konto</th>
|
||
<th className="py-2">Namn</th>
|
||
<th className="py-2 w-32 text-right">Ingående saldo</th>
|
||
<th className="py-2 w-32 text-right">Förändring</th>
|
||
<th className="py-2 w-32 text-right">Utgående saldo</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{data.rows.map((row) => (
|
||
<TrialBalanceSimplifiedRow
|
||
key={row.account_number}
|
||
row={row}
|
||
periodId={periodId}
|
||
onNavigateToAccount={onNavigateToAccount}
|
||
getNetBalance={getNetBalance}
|
||
formatSigned={formatSigned}
|
||
/>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
) : (
|
||
<table className="w-full text-sm min-w-[600px]">
|
||
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
|
||
<tr className="border-b text-left">
|
||
<th className="py-2 w-8"></th>
|
||
<th className="py-2 w-20">Konto</th>
|
||
<th className="py-2">Namn</th>
|
||
<th className="py-2 w-28 text-right">Period debet</th>
|
||
<th className="py-2 w-28 text-right">Period kredit</th>
|
||
<th className="py-2 w-28 text-right">Saldo debet</th>
|
||
<th className="py-2 w-28 text-right">Saldo kredit</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{data.rows.map((row) => (
|
||
<TrialBalanceDetailedRow
|
||
key={row.account_number}
|
||
row={row}
|
||
periodId={periodId}
|
||
onNavigateToAccount={onNavigateToAccount}
|
||
/>
|
||
))}
|
||
</tbody>
|
||
<tfoot>
|
||
<tr className="font-semibold border-t-2">
|
||
<td className="py-2"></td>
|
||
<td colSpan={2} className="py-2">Summa</td>
|
||
<td className="py-2 text-right">
|
||
{formatAmount(data.rows.reduce((s, r) => s + r.period_debit, 0))}
|
||
</td>
|
||
<td className="py-2 text-right">
|
||
{formatAmount(data.rows.reduce((s, r) => s + r.period_credit, 0))}
|
||
</td>
|
||
<td className={`py-2 text-right ${data.isBalanced ? 'text-success' : 'text-destructive'}`}>
|
||
{formatAmount(data.totalDebit)}
|
||
</td>
|
||
<td className={`py-2 text-right ${data.isBalanced ? 'text-success' : 'text-destructive'}`}>
|
||
{formatAmount(data.totalCredit)}
|
||
</td>
|
||
</tr>
|
||
</tfoot>
|
||
</table>
|
||
)}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// Lazy fetcher for a TB account's source lines. Memoised at the row level so
|
||
// repeated toggling never refetches.
|
||
function makeTrialBalanceFetcher(accountNumber: string, periodId: string): ReportSourceFetcher {
|
||
return async () => {
|
||
const res = await fetch(
|
||
`/api/reports/trial-balance/account/${encodeURIComponent(accountNumber)}/sources?fiscal_period_id=${encodeURIComponent(periodId)}`
|
||
)
|
||
const json = await res.json()
|
||
if (!res.ok) throw new Error(json.error || 'Kunde inte hämta verifikat')
|
||
const lines: ReportSourceLine[] = json.data?.lines || []
|
||
return { lines, next_cursor: json.data?.next_cursor ?? null }
|
||
}
|
||
}
|
||
|
||
function TrialBalanceSimplifiedRow({
|
||
row,
|
||
periodId,
|
||
onNavigateToAccount,
|
||
getNetBalance,
|
||
formatSigned,
|
||
}: {
|
||
row: TrialBalanceRow
|
||
periodId: string
|
||
onNavigateToAccount: (account: string) => void
|
||
getNetBalance: (row: TrialBalanceRow, type: 'opening' | 'period' | 'closing') => number
|
||
formatSigned: (amount: number) => string
|
||
}) {
|
||
const fetcher = React.useMemo(
|
||
() => makeTrialBalanceFetcher(row.account_number, periodId),
|
||
[row.account_number, periodId]
|
||
)
|
||
const { Toggle, Panel } = useReportRowExpansion(fetcher, `tb-${row.account_number}`)
|
||
|
||
const ob = getNetBalance(row, 'opening')
|
||
const ch = getNetBalance(row, 'period')
|
||
const cb = getNetBalance(row, 'closing')
|
||
|
||
return (
|
||
<>
|
||
<tr className="border-b last:border-0 hover:bg-muted/50 transition-colors">
|
||
<td className="py-2" onClick={(e) => e.stopPropagation()}>
|
||
<Toggle />
|
||
</td>
|
||
<td
|
||
className="py-2 cursor-pointer"
|
||
onClick={() => onNavigateToAccount(row.account_number)}
|
||
>
|
||
<AccountNumber number={row.account_number} name={row.account_name} />
|
||
</td>
|
||
<td
|
||
className="py-2 cursor-pointer"
|
||
onClick={() => onNavigateToAccount(row.account_number)}
|
||
>
|
||
{row.account_name}
|
||
</td>
|
||
<td className={`py-2 text-right tabular-nums ${ob < 0 ? 'text-destructive' : ''}`}>
|
||
{formatSigned(ob)}
|
||
</td>
|
||
<td className={`py-2 text-right tabular-nums ${ch < 0 ? 'text-destructive' : ''}`}>
|
||
{formatSigned(ch)}
|
||
</td>
|
||
<td className={`py-2 text-right tabular-nums font-medium ${cb < 0 ? 'text-destructive' : ''}`}>
|
||
{formatSigned(cb)}
|
||
</td>
|
||
</tr>
|
||
<Panel colSpan={6} />
|
||
</>
|
||
)
|
||
}
|
||
|
||
function TrialBalanceDetailedRow({
|
||
row,
|
||
periodId,
|
||
onNavigateToAccount,
|
||
}: {
|
||
row: TrialBalanceRow
|
||
periodId: string
|
||
onNavigateToAccount: (account: string) => void
|
||
}) {
|
||
const fetcher = React.useMemo(
|
||
() => makeTrialBalanceFetcher(row.account_number, periodId),
|
||
[row.account_number, periodId]
|
||
)
|
||
const { Toggle, Panel } = useReportRowExpansion(fetcher, `tb-det-${row.account_number}`)
|
||
|
||
return (
|
||
<>
|
||
<tr className="border-b last:border-0 hover:bg-muted/50 transition-colors">
|
||
<td className="py-2" onClick={(e) => e.stopPropagation()}>
|
||
<Toggle />
|
||
</td>
|
||
<td
|
||
className="py-2 cursor-pointer"
|
||
onClick={() => onNavigateToAccount(row.account_number)}
|
||
>
|
||
<AccountNumber number={row.account_number} name={row.account_name} />
|
||
</td>
|
||
<td
|
||
className="py-2 cursor-pointer"
|
||
onClick={() => onNavigateToAccount(row.account_number)}
|
||
>
|
||
{row.account_name}
|
||
</td>
|
||
<td className="py-2 text-right">
|
||
{row.period_debit > 0 ? formatAmount(row.period_debit) : ''}
|
||
</td>
|
||
<td className="py-2 text-right">
|
||
{row.period_credit > 0 ? formatAmount(row.period_credit) : ''}
|
||
</td>
|
||
<td className="py-2 text-right">
|
||
{row.closing_debit > 0 ? formatAmount(row.closing_debit) : ''}
|
||
</td>
|
||
<td className="py-2 text-right">
|
||
{row.closing_credit > 0 ? formatAmount(row.closing_credit) : ''}
|
||
</td>
|
||
</tr>
|
||
<Panel colSpan={7} />
|
||
</>
|
||
)
|
||
}
|
||
export function IncomeStatementView({ periodId, dateRange, dimensionFilter = null, onNavigateToAccount }: { periodId: string; dateRange: DateRangeValue; dimensionFilter?: DimensionFilterValue | null; onNavigateToAccount: (account: string) => void }) {
|
||
const [data, setData] = useState<IncomeStatementReport | null>(null)
|
||
const [monthlyData, setMonthlyData] = useState<MonthlyDataPoint[]>([])
|
||
const [monthlyLoading, setMonthlyLoading] = useState(false)
|
||
const [loading, setLoading] = useState(true)
|
||
const [error, setError] = useState<string | null>(null)
|
||
const reportQs = reportQuery(periodId, dateRange, dimensionFilter)
|
||
|
||
useEffect(() => {
|
||
setLoading(true)
|
||
setError(null)
|
||
setMonthlyLoading(true)
|
||
|
||
fetch(`/api/reports/income-statement?${reportQs}`)
|
||
.then((res) => res.json())
|
||
.then((result) => {
|
||
if (result.error) {
|
||
setError(result.error)
|
||
} else {
|
||
setData(result.data)
|
||
}
|
||
setLoading(false)
|
||
})
|
||
.catch(() => {
|
||
setError('Kunde inte hämta resultaträkning')
|
||
setLoading(false)
|
||
})
|
||
|
||
// Monthly breakdown is full-period by design (it IS the per-month view),
|
||
// so the date range only affects the headline numbers above the chart.
|
||
// The dimension filter DOES apply: a dimension-scoped view must not
|
||
// silently chart company-wide months.
|
||
fetch(`/api/reports/monthly-breakdown?${reportQuery(periodId, undefined, dimensionFilter)}`)
|
||
.then((res) => res.json())
|
||
.then((result) => {
|
||
if (result.data?.months) {
|
||
setMonthlyData(result.data.months)
|
||
}
|
||
setMonthlyLoading(false)
|
||
})
|
||
.catch(() => {
|
||
setMonthlyLoading(false)
|
||
})
|
||
}, [periodId, reportQs])
|
||
|
||
if (loading) {
|
||
return (
|
||
<Card>
|
||
<CardContent className="p-6 space-y-2">
|
||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||
<Skeleton key={i} className="h-4 w-full" />
|
||
))}
|
||
</CardContent>
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
if (error) {
|
||
return (
|
||
<Card>
|
||
<CardContent className="p-8 text-center text-destructive">
|
||
<AlertCircle className="h-6 w-6 mx-auto mb-2" />
|
||
{error}
|
||
</CardContent>
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
if (!data) {
|
||
return (
|
||
<Card>
|
||
<CardContent className="p-8 text-center text-muted-foreground">
|
||
Ingen data för denna period.
|
||
</CardContent>
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<ReportExportMenu
|
||
items={[
|
||
{ format: 'pdf', href: `/api/reports/income-statement/pdf?${reportQs}` },
|
||
{ format: 'xlsx', href: `/api/reports/income-statement/xlsx?${reportQs}` },
|
||
]}
|
||
/>
|
||
|
||
{!monthlyLoading && monthlyData.length > 0 && (
|
||
<IncomeExpenseChart months={monthlyData} />
|
||
)}
|
||
|
||
{/* Revenue */}
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-lg">Rörelseintäkter</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<ReportSectionTable sections={data.revenue_sections} onNavigateToAccount={onNavigateToAccount} />
|
||
<div className="flex justify-between font-semibold pt-2 border-t mt-2">
|
||
<span>Summa rörelseintäkter</span>
|
||
<span>{formatAmount(data.total_revenue)} kr</span>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* Expenses */}
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-lg">Rörelsekostnader</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<ReportSectionTable sections={data.expense_sections} negate onNavigateToAccount={onNavigateToAccount} />
|
||
<div className="flex justify-between font-semibold pt-2 border-t mt-2">
|
||
<span>Summa rörelsekostnader</span>
|
||
<span>-{formatAmount(data.total_expenses)} kr</span>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* Operating result */}
|
||
<Card>
|
||
<CardContent className="py-4">
|
||
<div className="flex justify-between font-bold text-lg">
|
||
<span>Rörelseresultat</span>
|
||
<span className={data.total_revenue - data.total_expenses >= 0 ? 'text-success' : 'text-destructive'}>
|
||
{formatAmount(data.total_revenue - data.total_expenses)} kr
|
||
</span>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* Financial items */}
|
||
{data.financial_sections.length > 0 && (
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-lg">Finansiella poster</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<ReportSectionTable sections={data.financial_sections} onNavigateToAccount={onNavigateToAccount} />
|
||
<div className="flex justify-between font-semibold pt-2 border-t mt-2">
|
||
<span>Summa finansiella poster</span>
|
||
<span>{formatAmount(data.total_financial)} kr</span>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{/* Net result */}
|
||
<Card className="border-2">
|
||
<CardContent className="py-4">
|
||
<div className="flex justify-between font-bold text-xl">
|
||
<span>Årets resultat</span>
|
||
<span className={data.net_result >= 0 ? 'text-success' : 'text-destructive'}>
|
||
{formatAmount(data.net_result)} kr
|
||
</span>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export function BalanceSheetView({ periodId, dateRange, onNavigateToAccount }: { periodId: string; dateRange: DateRangeValue; onNavigateToAccount: (account: string) => void }) {
|
||
const [data, setData] = useState<BalanceSheetReport | null>(null)
|
||
const [loading, setLoading] = useState(true)
|
||
const [error, setError] = useState<string | null>(null)
|
||
const reportQs = reportQuery(periodId, dateRange)
|
||
|
||
useEffect(() => {
|
||
setLoading(true)
|
||
setError(null)
|
||
fetch(`/api/reports/balance-sheet?${reportQs}`)
|
||
.then((res) => res.json())
|
||
.then((result) => {
|
||
if (result.error) {
|
||
setError(result.error)
|
||
} else {
|
||
setData(result.data)
|
||
}
|
||
setLoading(false)
|
||
})
|
||
.catch(() => {
|
||
setError('Kunde inte hämta balansräkning')
|
||
setLoading(false)
|
||
})
|
||
}, [periodId, reportQs])
|
||
|
||
if (loading) {
|
||
return (
|
||
<Card>
|
||
<CardContent className="p-8 text-center text-muted-foreground">
|
||
Laddar balansräkning...
|
||
</CardContent>
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
if (error) {
|
||
return (
|
||
<Card>
|
||
<CardContent className="p-8 text-center text-destructive">
|
||
<AlertCircle className="h-6 w-6 mx-auto mb-2" />
|
||
{error}
|
||
</CardContent>
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
if (!data) {
|
||
return (
|
||
<Card>
|
||
<CardContent className="p-8 text-center text-muted-foreground">
|
||
Ingen data för denna period.
|
||
</CardContent>
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
const isBalanced = Math.abs(data.total_assets - data.total_equity_liabilities) < 0.01
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<ReportExportMenu
|
||
items={[
|
||
{ format: 'pdf', href: `/api/reports/balance-sheet/pdf?${reportQs}` },
|
||
{ format: 'xlsx', href: `/api/reports/balance-sheet/xlsx?${reportQs}` },
|
||
]}
|
||
/>
|
||
|
||
{/* Assets */}
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-lg">Tillgångar</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<ReportSectionTable sections={data.asset_sections} onNavigateToAccount={onNavigateToAccount} />
|
||
<div className="flex justify-between font-semibold pt-2 border-t mt-2">
|
||
<span>Summa tillgångar</span>
|
||
<span>{formatAmount(data.total_assets)} kr</span>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* Equity and liabilities */}
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-lg">Eget kapital och skulder</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<ReportSectionTable sections={data.equity_liability_sections} onNavigateToAccount={onNavigateToAccount} />
|
||
<div className="flex justify-between font-semibold pt-2 border-t mt-2">
|
||
<span>Summa eget kapital och skulder</span>
|
||
<span>{formatAmount(data.total_equity_liabilities)} kr</span>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* Balance check */}
|
||
<Card className="border-2">
|
||
<CardContent className="py-4">
|
||
<div className="flex justify-between items-center">
|
||
<span className="font-bold text-lg">Balanscheck</span>
|
||
{isBalanced ? (
|
||
<Badge variant="success" className="text-base px-3 py-1">
|
||
Balanserar
|
||
</Badge>
|
||
) : (
|
||
<div className="text-right">
|
||
<Badge variant="destructive" className="text-base px-3 py-1">
|
||
Balanserar ej
|
||
</Badge>
|
||
<p className="text-sm text-destructive mt-1">
|
||
Differens: {formatAmount(Math.abs(data.total_assets - data.total_equity_liabilities))} kr
|
||
</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
{!isBalanced && data.imbalance_diagnosis && (
|
||
<p className="text-sm text-muted-foreground mt-3 pt-3 border-t">
|
||
{data.imbalance_diagnosis.message}
|
||
</p>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export function ResultatrapportView({ periodId, dateRange, dimensionFilter = null, onNavigateToAccount }: { periodId: string; dateRange: DateRangeValue; dimensionFilter?: DimensionFilterValue | null; onNavigateToAccount: (account: string) => void }) {
|
||
const [data, setData] = useState<ResultatrapportReport | null>(null)
|
||
const [loading, setLoading] = useState(true)
|
||
const [error, setError] = useState<string | null>(null)
|
||
const reportQs = reportQuery(periodId, dateRange, dimensionFilter)
|
||
|
||
useEffect(() => {
|
||
setLoading(true)
|
||
setError(null)
|
||
fetch(`/api/reports/resultatrapport?${reportQs}`)
|
||
.then((res) => res.json())
|
||
.then((result) => {
|
||
if (result.error) {
|
||
setError(result.error)
|
||
} else {
|
||
setData(result.data)
|
||
}
|
||
setLoading(false)
|
||
})
|
||
.catch(() => {
|
||
setError('Kunde inte hämta resultatrapport')
|
||
setLoading(false)
|
||
})
|
||
}, [periodId, reportQs])
|
||
|
||
if (loading) {
|
||
return (
|
||
<Card>
|
||
<CardContent className="p-6 space-y-2">
|
||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||
<Skeleton key={i} className="h-4 w-full" />
|
||
))}
|
||
</CardContent>
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
if (error) {
|
||
return (
|
||
<Card>
|
||
<CardContent className="p-8 text-center text-destructive">
|
||
<AlertCircle className="h-6 w-6 mx-auto mb-2" />
|
||
{error}
|
||
</CardContent>
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
if (!data || data.groups.length === 0) {
|
||
return (
|
||
<Card>
|
||
<CardContent className="p-8 text-center text-muted-foreground">
|
||
Inga bokförda intäkter eller kostnader i denna period.
|
||
</CardContent>
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
const hasPrior = data.prior_period !== null
|
||
const colCount = 4
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<ReportExportMenu
|
||
items={[
|
||
{ format: 'pdf', href: `/api/reports/resultatrapport/pdf?${reportQs}` },
|
||
{ format: 'xlsx', href: `/api/reports/resultatrapport/xlsx?${reportQs}` },
|
||
]}
|
||
/>
|
||
|
||
<Card>
|
||
<CardContent className="p-0">
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-sm">
|
||
<thead>
|
||
<tr className="border-b text-[11px] uppercase tracking-wider text-muted-foreground">
|
||
<th className="text-left font-medium px-4 py-2 w-20">Konto</th>
|
||
<th className="text-left font-medium px-4 py-2">Kontonamn</th>
|
||
<th
|
||
className="text-right font-medium px-4 py-2 w-32 tabular-nums"
|
||
title={`${data.period.start} till ${data.period.end}`}
|
||
>
|
||
Innevarande
|
||
</th>
|
||
<th
|
||
className="text-right font-medium px-4 py-2 w-32 tabular-nums"
|
||
title={hasPrior ? `${data.prior_period!.start} till ${data.prior_period!.end}` : undefined}
|
||
>
|
||
Föregående
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{data.groups.map((group) => (
|
||
<React.Fragment key={group.class}>
|
||
<tr className="bg-muted/30">
|
||
<td colSpan={colCount} className="px-4 py-2 text-[12px] font-semibold text-muted-foreground">
|
||
{group.class_label}
|
||
</td>
|
||
</tr>
|
||
{group.rows.map((row) => (
|
||
<tr
|
||
key={row.account_number}
|
||
className="border-b last:border-0 cursor-pointer hover:bg-muted/50 transition-colors"
|
||
onClick={() => onNavigateToAccount(row.account_number)}
|
||
>
|
||
<td className="px-4 py-1.5">
|
||
<AccountNumber number={row.account_number} name={row.account_name} />
|
||
</td>
|
||
<td className="px-4 py-1.5">{row.account_name}</td>
|
||
<td className="px-4 py-1.5 text-right tabular-nums">{formatAmount(row.current_period)}</td>
|
||
<td className="px-4 py-1.5 text-right tabular-nums text-muted-foreground">
|
||
{hasPrior ? formatAmount(row.prior_period) : '-'}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
<tr className="border-b font-medium">
|
||
<td colSpan={2} className="px-4 py-1.5 text-right text-muted-foreground">
|
||
Summa
|
||
</td>
|
||
<td className="px-4 py-1.5 text-right tabular-nums">{formatAmount(group.subtotal_current)}</td>
|
||
<td className="px-4 py-1.5 text-right tabular-nums text-muted-foreground">
|
||
{hasPrior ? formatAmount(group.subtotal_prior) : '-'}
|
||
</td>
|
||
</tr>
|
||
</React.Fragment>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card className="border-2">
|
||
<CardContent className="py-4">
|
||
<div className="grid gap-x-6 items-baseline grid-cols-[1fr_auto_auto]">
|
||
<span className="font-bold text-lg">Beräknat resultat</span>
|
||
<span className={`tabular-nums font-bold text-lg w-32 text-right ${data.net_result_current >= 0 ? 'text-success' : 'text-destructive'}`}>
|
||
{formatAmount(data.net_result_current)} kr
|
||
</span>
|
||
<span className="tabular-nums text-base text-muted-foreground w-32 text-right">
|
||
{hasPrior ? `${formatAmount(data.net_result_prior)} kr` : '-'}
|
||
</span>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export function BalansrapportView({ periodId, dateRange, onNavigateToAccount }: { periodId: string; dateRange: DateRangeValue; onNavigateToAccount: (account: string) => void }) {
|
||
const [data, setData] = useState<BalansrapportReport | null>(null)
|
||
const [loading, setLoading] = useState(true)
|
||
const [error, setError] = useState<string | null>(null)
|
||
const reportQs = reportQuery(periodId, dateRange)
|
||
|
||
useEffect(() => {
|
||
setLoading(true)
|
||
setError(null)
|
||
fetch(`/api/reports/balansrapport?${reportQs}`)
|
||
.then((res) => res.json())
|
||
.then((result) => {
|
||
if (result.error) {
|
||
setError(result.error)
|
||
} else {
|
||
setData(result.data)
|
||
}
|
||
setLoading(false)
|
||
})
|
||
.catch(() => {
|
||
setError('Kunde inte hämta balansrapport')
|
||
setLoading(false)
|
||
})
|
||
}, [periodId, reportQs])
|
||
|
||
if (loading) {
|
||
return (
|
||
<Card>
|
||
<CardContent className="p-8 text-center text-muted-foreground">
|
||
Laddar balansrapport...
|
||
</CardContent>
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
if (error) {
|
||
return (
|
||
<Card>
|
||
<CardContent className="p-8 text-center text-destructive">
|
||
<AlertCircle className="h-6 w-6 mx-auto mb-2" />
|
||
{error}
|
||
</CardContent>
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
if (!data || data.groups.length === 0) {
|
||
return (
|
||
<Card>
|
||
<CardContent className="p-8 text-center text-muted-foreground">
|
||
Inga balansposter i denna period.
|
||
</CardContent>
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<ReportExportMenu
|
||
items={[
|
||
{ format: 'pdf', href: `/api/reports/balansrapport/pdf?${reportQs}` },
|
||
{ format: 'xlsx', href: `/api/reports/balansrapport/xlsx?${reportQs}` },
|
||
]}
|
||
/>
|
||
|
||
<Card>
|
||
<CardContent className="p-0">
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-sm">
|
||
<thead>
|
||
<tr className="border-b text-[11px] uppercase tracking-wider text-muted-foreground">
|
||
<th className="text-left font-medium px-4 py-2 w-20">Konto</th>
|
||
<th className="text-left font-medium px-4 py-2">Kontonamn</th>
|
||
<th className="text-right font-medium px-4 py-2 w-32 tabular-nums">Ingående balans</th>
|
||
<th className="text-right font-medium px-4 py-2 w-32 tabular-nums">Förändring</th>
|
||
<th className="text-right font-medium px-4 py-2 w-32 tabular-nums">Utgående balans</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{data.groups.map((group) => (
|
||
<React.Fragment key={group.class}>
|
||
<tr className="bg-muted/30">
|
||
<td colSpan={5} className="px-4 py-2 text-[12px] font-semibold text-muted-foreground">
|
||
{group.class_label}
|
||
</td>
|
||
</tr>
|
||
{group.rows.map((row) => (
|
||
<tr
|
||
key={row.account_number}
|
||
className="border-b last:border-0 cursor-pointer hover:bg-muted/50 transition-colors"
|
||
onClick={() => onNavigateToAccount(row.account_number)}
|
||
>
|
||
<td className="px-4 py-1.5">
|
||
<AccountNumber number={row.account_number} name={row.account_name} />
|
||
</td>
|
||
<td className="px-4 py-1.5">{row.account_name}</td>
|
||
<td className="px-4 py-1.5 text-right tabular-nums text-muted-foreground">{formatAmount(row.ib)}</td>
|
||
<td className="px-4 py-1.5 text-right tabular-nums text-muted-foreground">{formatAmount(row.period_change)}</td>
|
||
<td className="px-4 py-1.5 text-right tabular-nums">{formatAmount(row.ub)}</td>
|
||
</tr>
|
||
))}
|
||
<tr className="border-b font-medium">
|
||
<td colSpan={2} className="px-4 py-1.5 text-right text-muted-foreground">
|
||
Summa
|
||
</td>
|
||
<td className="px-4 py-1.5 text-right tabular-nums text-muted-foreground">{formatAmount(group.subtotal_ib)}</td>
|
||
<td className="px-4 py-1.5 text-right tabular-nums text-muted-foreground">
|
||
{formatAmount(group.subtotal_ub - group.subtotal_ib)}
|
||
</td>
|
||
<td className="px-4 py-1.5 text-right tabular-nums">{formatAmount(group.subtotal_ub)}</td>
|
||
</tr>
|
||
</React.Fragment>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card className="border-2">
|
||
<CardContent className="py-4 space-y-2">
|
||
<div className="flex justify-between text-sm">
|
||
<span className="text-muted-foreground">Summa tillgångar</span>
|
||
<span className="tabular-nums">{formatAmount(data.total_assets_ub)} kr</span>
|
||
</div>
|
||
<div className="flex justify-between text-sm">
|
||
<span className="text-muted-foreground">Summa eget kapital, reserver, avsättningar och skulder</span>
|
||
<span className="tabular-nums">{formatAmount(data.total_equity_liabilities_ub)} kr</span>
|
||
</div>
|
||
<div className="flex justify-between text-sm">
|
||
<span className="text-muted-foreground">Beräknat resultat (ej bokslutsjusterat)</span>
|
||
<span className="tabular-nums">{formatAmount(data.beraknat_resultat)} kr</span>
|
||
</div>
|
||
<div className="flex justify-between items-center pt-2 border-t">
|
||
<span className="font-bold text-lg">Balanscheck</span>
|
||
{data.is_balanced ? (
|
||
<Badge variant="success" className="text-base px-3 py-1">
|
||
Balanserar
|
||
</Badge>
|
||
) : (
|
||
<div className="text-right">
|
||
<Badge variant="destructive" className="text-base px-3 py-1">
|
||
Balanserar ej
|
||
</Badge>
|
||
{data.imbalance_diagnosis && (
|
||
<p className="text-sm text-destructive mt-1">
|
||
Differens: {formatAmount(Math.abs(data.imbalance_diagnosis.differens))} kr
|
||
</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
{!data.is_balanced && data.imbalance_diagnosis && (
|
||
<p className="text-sm text-muted-foreground pt-2 border-t">
|
||
{data.imbalance_diagnosis.message}
|
||
</p>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function ReportSectionTable({
|
||
sections,
|
||
negate,
|
||
onNavigateToAccount,
|
||
}: {
|
||
sections: { title: string; rows: { account_number: string; account_name: string; amount: number }[]; subtotal: number }[]
|
||
negate?: boolean
|
||
onNavigateToAccount?: (account: string) => void
|
||
}) {
|
||
if (sections.length === 0) {
|
||
return <p className="text-sm text-muted-foreground">Inga poster.</p>
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-3">
|
||
{sections.map((section) => (
|
||
<div key={section.title}>
|
||
<h4 className="text-sm font-semibold text-muted-foreground mb-1">{section.title}</h4>
|
||
<div className="overflow-x-auto -mx-2 px-2"><table className="w-full text-sm min-w-[400px]">
|
||
<tbody>
|
||
{section.rows.map((row) => (
|
||
<tr
|
||
key={row.account_number}
|
||
className={`border-b last:border-0 ${onNavigateToAccount ? 'cursor-pointer hover:bg-muted/50 transition-colors' : ''}`}
|
||
onClick={onNavigateToAccount ? () => onNavigateToAccount(row.account_number) : undefined}
|
||
>
|
||
<td className="py-1 w-16"><AccountNumber number={row.account_number} name={row.account_name} /></td>
|
||
<td className="py-1">{row.account_name}</td>
|
||
<td className="py-1 text-right w-28">
|
||
{negate ? `-${formatAmount(row.amount)}` : formatAmount(row.amount)} kr
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table></div>
|
||
<div className="flex justify-between text-sm font-medium border-t pt-1 mt-1">
|
||
<span>{section.title}</span>
|
||
<span>
|
||
{negate ? `-${formatAmount(section.subtotal)}` : formatAmount(section.subtotal)} kr
|
||
</span>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// Carries the selected fiscal period into the ruta drill-down rows so their
|
||
// source-verifikat query matches the report's period. Only set for yearly
|
||
// (räkenskapsår); undefined for monthly/quarterly (calendar periods).
|
||
const VatDrillContext = React.createContext<{ fiscalPeriodId?: string }>({})
|
||
|
||
// Skatteverket's e-service entry point for manually filing the momsdeklaration.
|
||
// Manual filing needs no connection, so this link is the default path for
|
||
// anyone who hasn't set up (or doesn't want) the direct-submission integration.
|
||
const SKATTEVERKET_MOMS_URL =
|
||
'https://www.skatteverket.se/foretag/etjansterochblanketter/etjanster/momsocharbetsgivardeklarationer'
|
||
|
||
/**
|
||
* Manual-filing affordance shown directly under the calculated momsdeklaration.
|
||
* The report is generated purely from the bookkeeping and never depends on the
|
||
* Skatteverket connection, so every user (including core builds with the
|
||
* skatteverket extension disabled) can file manually. Two paths are offered:
|
||
* an eSKD XML file to upload directly under "Deklarera via fil" (the fast path),
|
||
* and a PDF (in hela kronor) to read off if the user would rather type the
|
||
* boxes into the form. A PDF cannot be uploaded to Skatteverket, only the XML.
|
||
*/
|
||
function VatManualFilingCard({ xmlHref, pdfHref }: { xmlHref: string; pdfHref: string }) {
|
||
return (
|
||
<section>
|
||
<div className="mb-3 flex items-center gap-3 px-1">
|
||
<h3 className="font-sans text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||
Lämna in själv, med fil
|
||
</h3>
|
||
<div className="h-px flex-1 bg-border/60" />
|
||
</div>
|
||
<div className="space-y-4">
|
||
<p className="text-[13px] leading-6 text-muted-foreground">
|
||
Du behöver inte vara ansluten till Skatteverket för att lämna in.
|
||
</p>
|
||
<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" 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" 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" asChild className="gap-2">
|
||
<a href={SKATTEVERKET_MOMS_URL} target="_blank" rel="noopener noreferrer">
|
||
<ExternalLink className="h-4 w-4" />
|
||
Öppna skatteverket.se
|
||
</a>
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
)
|
||
}
|
||
|
||
/**
|
||
* "Bokför momsrapport" (issue #980): builds an editable verifikat proposal
|
||
* from the momsrapport (clearing the period's 26xx accounts to 2650/1650,
|
||
* öre gap on 3740) and books it through the ordinary journal entry form, so
|
||
* every line can be adjusted before committing. The proposal comes from
|
||
* /api/reports/vat-declaration/settlement-proposal; booking goes through
|
||
* POST /api/bookkeeping/journal-entries with source_type 'vat_settlement',
|
||
* which the declaration projection excludes, so the report above keeps
|
||
* showing the declared figures after booking.
|
||
*/
|
||
function VatBookingCard({
|
||
periodType,
|
||
year,
|
||
period,
|
||
fiscalPeriodId,
|
||
checksBlocked,
|
||
onStatus,
|
||
}: {
|
||
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
|
||
/** Lets the surrounding stepper mirror the booking state on its dot. */
|
||
onStatus?: (status: 'booked' | 'draft' | 'none') => void
|
||
}) {
|
||
const { canWrite } = useCanWrite()
|
||
const [dialogOpen, setDialogOpen] = useState(false)
|
||
const [refreshKey, setRefreshKey] = useState(0)
|
||
// Fetch outcome tagged with the key it was requested under; proposal/failed
|
||
// are derived by comparing that tag with the current key, so the effect
|
||
// never sets state synchronously (same pattern as VatDeclarationView).
|
||
const [result, setResult] = useState<{
|
||
key: string
|
||
proposal?: VatSettlementProposal
|
||
failed?: boolean
|
||
} | null>(null)
|
||
const fetchKey = `${periodType}:${year}:${period}:${fiscalPeriodId ?? ''}:${refreshKey}`
|
||
|
||
useEffect(() => {
|
||
const params = new URLSearchParams({
|
||
periodType,
|
||
year: String(year),
|
||
period: String(period),
|
||
})
|
||
if (fiscalPeriodId) params.set('fiscal_period_id', fiscalPeriodId)
|
||
let cancelled = false
|
||
fetch(`/api/reports/vat-declaration/settlement-proposal?${params.toString()}`)
|
||
.then(async (res) => {
|
||
const json = await res.json().catch(() => null)
|
||
if (cancelled) return
|
||
if (!res.ok || !json?.data) setResult({ key: fetchKey, failed: true })
|
||
else setResult({ key: fetchKey, proposal: json.data })
|
||
})
|
||
.catch(() => {
|
||
if (!cancelled) setResult({ key: fetchKey, failed: true })
|
||
})
|
||
return () => {
|
||
cancelled = true
|
||
}
|
||
}, [fetchKey, periodType, year, period, fiscalPeriodId])
|
||
|
||
const upToDate = result !== null && result.key === fetchKey
|
||
const proposal = upToDate ? (result.proposal ?? null) : null
|
||
const failed = upToDate && !!result.failed
|
||
|
||
const booked = proposal?.existing_entries.find((e) => e.status === 'posted')
|
||
const draft = booked ? undefined : proposal?.existing_entries.find((e) => e.status === 'draft')
|
||
|
||
const bookingStatus = booked ? 'booked' : draft ? 'draft' : 'none'
|
||
useEffect(() => {
|
||
if (upToDate && proposal) onStatus?.(bookingStatus)
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [upToDate, bookingStatus])
|
||
|
||
// FormLine amounts are input strings; the proposal's numbers are already
|
||
// öre-rounded server-side, so this is display formatting, not money math.
|
||
const initialLines: FormLine[] = (proposal?.lines ?? []).map((l) => ({
|
||
account_number: l.account_number,
|
||
debit_amount: l.debit_amount > 0 ? l.debit_amount.toFixed(2) : '',
|
||
credit_amount: l.credit_amount > 0 ? l.credit_amount.toFixed(2) : '',
|
||
line_description: l.line_description ?? '',
|
||
}))
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<p className="text-[13px] leading-6 text-muted-foreground">
|
||
Skapa ett verifikat som nollställer periodens momskonton och bokför
|
||
momsen att betala eller få tillbaka på redovisningskontot. Du granskar
|
||
förslaget och kan ändra raderna innan verifikatet bokförs.
|
||
</p>
|
||
|
||
{booked && (
|
||
<div className="flex items-start gap-2 text-[13px] leading-6 text-muted-foreground">
|
||
<AlertCircle className="mt-1 h-4 w-4 shrink-0" aria-hidden="true" />
|
||
<p>
|
||
Momsen för perioden är redan bokförd:{' '}
|
||
<Link
|
||
href={`/bookkeeping/${booked.id}`}
|
||
className="underline underline-offset-2 hover:text-foreground"
|
||
>
|
||
verifikat {formatVoucher(booked)} ({formatDate(booked.entry_date)})
|
||
</Link>
|
||
. Annullera det verifikatet först om perioden behöver bokföras om.
|
||
</p>
|
||
</div>
|
||
)}
|
||
{draft && (
|
||
<div className="flex items-start gap-2 text-[13px] leading-6 text-muted-foreground">
|
||
<AlertCircle className="mt-1 h-4 w-4 shrink-0" aria-hidden="true" />
|
||
<p>
|
||
Det finns redan ett{' '}
|
||
<Link
|
||
href={`/bookkeeping/${draft.id}`}
|
||
className="underline underline-offset-2 hover:text-foreground"
|
||
>
|
||
utkast för momsen i perioden
|
||
</Link>
|
||
.
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{checksBlocked && (
|
||
<p className="text-[12.5px] leading-5 text-attn">
|
||
Det finns fel under steg 1, Kontrollera. Du kan bokföra momsen ändå, men
|
||
åtgärda felen innan du lämnar in.
|
||
</p>
|
||
)}
|
||
|
||
{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" 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>
|
||
) : (
|
||
<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>
|
||
)}
|
||
|
||
{proposal && (
|
||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||
<DialogContent
|
||
className="sm:max-w-3xl max-h-[95dvh] sm:max-h-[90vh] overflow-y-auto"
|
||
// A reviewed-but-unbooked proposal must survive an accidental
|
||
// backdrop click or stray Escape (same rationale as
|
||
// NewJournalEntryDialog): closing is explicit via the header X.
|
||
onEscapeKeyDown={(e) => e.preventDefault()}
|
||
onPointerDownOutside={(e) => e.preventDefault()}
|
||
onInteractOutside={(e) => e.preventDefault()}
|
||
>
|
||
<DialogHeader>
|
||
<DialogTitle>Bokför momsrapport</DialogTitle>
|
||
</DialogHeader>
|
||
<p className="text-sm text-muted-foreground">
|
||
Förslaget bygger på momsrapporten för {proposal.period_label}. Justera
|
||
datum, konton eller belopp vid behov och bokför sedan verifikatet.
|
||
</p>
|
||
{dialogOpen && (
|
||
<JournalEntryForm
|
||
bare
|
||
sourceType="vat_settlement"
|
||
initialDate={proposal.entry_date}
|
||
initialDescription={proposal.description}
|
||
initialLines={initialLines}
|
||
onCreated={() => {
|
||
setDialogOpen(false)
|
||
setRefreshKey((k) => k + 1)
|
||
}}
|
||
/>
|
||
)}
|
||
</DialogContent>
|
||
</Dialog>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
|
||
/** The Stegen header (concept Moms C): the filing pipeline as a clickable
|
||
* horizontal stepper with honest per-step status subs. Statutory surface,
|
||
* Swedish in both locales like the rest of the declaration. */
|
||
function VatStepper({
|
||
active,
|
||
onSelect,
|
||
errorCount,
|
||
warningCount,
|
||
ruta49,
|
||
bookingStatus,
|
||
}: {
|
||
active: number
|
||
onSelect: (step: number) => void
|
||
errorCount: number
|
||
warningCount: number
|
||
ruta49: number
|
||
bookingStatus: 'booked' | 'draft' | 'none' | null
|
||
}) {
|
||
const steps = [
|
||
{
|
||
n: 1,
|
||
label: 'Kontrollera',
|
||
sub:
|
||
errorCount > 0
|
||
? `${errorCount} fel`
|
||
: warningCount > 0
|
||
? `${warningCount} ${warningCount === 1 ? 'varning' : 'varningar'}`
|
||
: 'klart',
|
||
done: errorCount === 0,
|
||
warn: errorCount > 0,
|
||
},
|
||
{
|
||
n: 2,
|
||
label: 'Granska',
|
||
sub:
|
||
ruta49 > 0
|
||
? `${formatAmount(ruta49)} kr att betala`
|
||
: ruta49 < 0
|
||
? `${formatAmount(Math.abs(ruta49))} kr att återfå`
|
||
: 'ingen moms',
|
||
done: false,
|
||
warn: false,
|
||
},
|
||
{
|
||
n: 3,
|
||
label: 'Bokför',
|
||
sub:
|
||
bookingStatus === 'booked'
|
||
? 'bokförd'
|
||
: bookingStatus === 'draft'
|
||
? 'utkast finns'
|
||
: 'mot 2650',
|
||
done: bookingStatus === 'booked',
|
||
warn: false,
|
||
},
|
||
{ n: 4, label: 'Lämna in', sub: 'till Skatteverket', done: false, warn: false },
|
||
]
|
||
|
||
return (
|
||
<div
|
||
className="mx-auto flex w-full max-w-3xl items-center gap-3 overflow-x-auto px-1"
|
||
role="tablist"
|
||
aria-label="Momsdeklarationens steg"
|
||
>
|
||
{steps.map((step, i) => (
|
||
<React.Fragment key={step.n}>
|
||
{i > 0 && <span className="h-px min-w-4 flex-1 bg-border" aria-hidden="true" />}
|
||
<button
|
||
type="button"
|
||
role="tab"
|
||
aria-selected={active === step.n}
|
||
onClick={() => onSelect(step.n)}
|
||
className="group flex shrink-0 items-center gap-2 text-left"
|
||
>
|
||
<span
|
||
className={cn(
|
||
'flex h-7 w-7 shrink-0 items-center justify-center rounded-full border text-xs tabular-nums transition-colors duration-150',
|
||
step.done
|
||
? 'border-success/40 text-success'
|
||
: active === step.n
|
||
? 'border-foreground bg-foreground text-background'
|
||
: 'border-border text-muted-foreground group-hover:border-foreground/30',
|
||
)}
|
||
>
|
||
{step.done ? <Check className="h-3.5 w-3.5" aria-hidden="true" /> : step.n}
|
||
</span>
|
||
<span className="leading-tight">
|
||
<span
|
||
className={cn(
|
||
'block text-[12.5px] transition-colors duration-150',
|
||
active === step.n
|
||
? 'font-medium'
|
||
: 'text-muted-foreground group-hover:text-foreground',
|
||
)}
|
||
>
|
||
{step.label}
|
||
</span>
|
||
{step.sub && (
|
||
<span
|
||
className={cn(
|
||
'block text-[11px] tabular-nums',
|
||
step.warn ? 'text-attn' : 'text-muted-foreground',
|
||
)}
|
||
>
|
||
{step.sub}
|
||
</span>
|
||
)}
|
||
</span>
|
||
</button>
|
||
</React.Fragment>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const MONTH_NAMES = [
|
||
'Januari', 'Februari', 'Mars', 'April', 'Maj', 'Juni',
|
||
'Juli', 'Augusti', 'September', 'Oktober', 'November', 'December',
|
||
]
|
||
const QUARTER_SPANS = ['jan-mar', 'apr-jun', 'jul-sep', 'okt-dec']
|
||
|
||
export function VatDeclarationView({ pageTitle }: { pageTitle?: string } = {}) {
|
||
const currentYear = new Date().getFullYear()
|
||
const currentMonth = new Date().getMonth() + 1
|
||
const currentQuarter = Math.ceil(currentMonth / 3)
|
||
|
||
// periodType stays null until the company's VAT settings have settled, so
|
||
// the first (automatic) fetch runs against the configured momsperiod instead
|
||
// of a guessed default.
|
||
const [periodType, setPeriodType] = useState<VatPeriodType | null>(null)
|
||
const [year, setYear] = useState(currentYear)
|
||
const [period, setPeriod] = useState(currentQuarter)
|
||
// Annual VAT (helårsmoms) is reported per räkenskapsår, not per calendar
|
||
// year — picked inline in yearly mode. Monthly/quarterly are calendar
|
||
// periods and need no fiscal year. The period's end date rides along so
|
||
// the Skatteverket panel can target the FY-end month (broken fiscal years
|
||
// do not end in December).
|
||
const [fiscalPeriodId, setFiscalPeriodId] = useState('')
|
||
const [fiscalPeriodEnd, setFiscalPeriodEnd] = useState<string | null>(null)
|
||
// Latest fetch outcome, tagged with the fetch key it was requested under.
|
||
// loading / error / data are all derived by comparing that tag with the
|
||
// current key, so the fetch effect never sets state synchronously.
|
||
const [result, setResult] = useState<{
|
||
key: string
|
||
declaration?: VatDeclaration
|
||
error?: string
|
||
} | null>(null)
|
||
const [retryKey, setRetryKey] = useState(0)
|
||
// Stegen: which of the four pipeline steps is open. null = automatic
|
||
// (errors land on Kontrollera, otherwise Granska). A period switch resets
|
||
// to automatic so stale step choices never survive a context change.
|
||
const [chosenStep, setChosenStep] = useState<number | null>(null)
|
||
const [bookingStatus, setBookingStatus] = useState<'booked' | 'draft' | 'none' | null>(null)
|
||
// Per-verifikat RC-basis scan, fetched here (not only inside VatChecksCard)
|
||
// because the filing gate lives here and the worklist unmounts as soon as
|
||
// the user leaves steg 1. Tagged with the PERIOD it was requested for (see
|
||
// gapScanPeriodKey), so a korrigering refetches without the gate falling
|
||
// open in between; count === null means "not known" (scan failed), which is
|
||
// deliberately NOT the same as zero.
|
||
const [gapScan, setGapScan] = useState<{ key: string; count: number | null } | null>(null)
|
||
|
||
// Company settings drive both the momsregistrerad gate and the default
|
||
// periodicity (moms_period in Inställningar). Applied once per company the
|
||
// first time its settings settle — as a render-phase adjustment, not an
|
||
// effect. A later manual change to the picker is preserved, and a company
|
||
// switch re-applies the new company's setting. `useCompanySettings` only
|
||
// refetches when the active company changes, so this never clobbers a
|
||
// manual selection mid-session.
|
||
const { settings, isLoading: settingsLoading } = useCompanySettings()
|
||
const [appliedCompany, setAppliedCompany] = useState<string | null>(null)
|
||
const companyKey = settingsLoading ? null : (settings?.company_id ?? 'none')
|
||
if (companyKey !== null && appliedCompany !== companyKey) {
|
||
setAppliedCompany(companyKey)
|
||
const configured = settings?.moms_period ?? 'quarterly'
|
||
setPeriodType(configured)
|
||
setPeriod(
|
||
configured === 'monthly' ? currentMonth : configured === 'quarterly' ? currentQuarter : 1,
|
||
)
|
||
}
|
||
|
||
// Settings row present and the company answered "not VAT-registered" —
|
||
// the declaration is meaningless, so the whole view is gated below.
|
||
const notVatRegistered = !settingsLoading && settings !== null && !settings.vat_registered
|
||
// Registered but never picked a redovisningsperiod (rare — onboarding
|
||
// requires it, but companies created outside that flow can miss it).
|
||
const momsPeriodMissing = settings?.vat_registered === true && !settings.moms_period
|
||
|
||
// Switching periodicity resets the period to "now" in the new unit. Done in
|
||
// the change handler (not an effect) so the auto-fetch below never sees an
|
||
// inconsistent periodType/period pair.
|
||
const handlePeriodTypeChange = (value: VatPeriodType) => {
|
||
setPeriodType(value)
|
||
setPeriod(value === 'monthly' ? currentMonth : value === 'quarterly' ? currentQuarter : 1)
|
||
}
|
||
|
||
// Annual VAT (helårsmoms) is reported per räkenskapsår, not per calendar year.
|
||
// For yearly we pass the selected fiscal period so the API uses its actual
|
||
// bounds (handles extended/shortened years); monthly/quarterly stay calendar.
|
||
const isYearly = periodType === 'yearly'
|
||
const awaitingFiscalPeriod = isYearly && !fiscalPeriodId
|
||
const vatQueryString = () => {
|
||
const params = new URLSearchParams({
|
||
periodType: periodType ?? 'quarterly',
|
||
year: String(year),
|
||
period: String(period),
|
||
})
|
||
if (isYearly && fiscalPeriodId) params.set('fiscal_period_id', fiscalPeriodId)
|
||
return params.toString()
|
||
}
|
||
|
||
// The declaration loads as soon as the period is known — no manual "Hämta"
|
||
// step. fetchKey is null while a prerequisite is missing (settings pending,
|
||
// gated, or no redovisningsperiod configured); any change to it triggers a
|
||
// refetch and stale responses are discarded.
|
||
const fetchKey =
|
||
periodType === null || notVatRegistered || momsPeriodMissing || awaitingFiscalPeriod
|
||
? null
|
||
: `${periodType}:${year}:${period}:${isYearly ? fiscalPeriodId : ''}:${retryKey}`
|
||
|
||
// Period identity WITHOUT the retry counter, used to decide whether the gap
|
||
// scan below still speaks for what is on screen. The scan re-runs on every
|
||
// retryKey bump (each korrigering), but the last settled count stays in
|
||
// force until the new one lands: falling back to `pending` mid-korrigering
|
||
// would drop the gap finding out of `checks` for one round trip, and with
|
||
// the aggregate check silent (the tolerance case this gate exists for) the
|
||
// green "Inga fel hittades i underlaget för perioden" would render right
|
||
// above the vouchers still left in the worklist, with Skicka enabled. That
|
||
// is the exact regression being fixed, and steg 1 stays mounted across a
|
||
// korrigering, so the user would be looking straight at it. Stale-but-closed
|
||
// is the safe direction, and it matches the declaration itself, which stays
|
||
// on screen (dimmed) while the next one loads.
|
||
const gapScanPeriodKey =
|
||
periodType === null
|
||
? null
|
||
: `${periodType}:${year}:${period}:${isYearly ? fiscalPeriodId : ''}`
|
||
|
||
useEffect(() => {
|
||
setChosenStep(null)
|
||
setBookingStatus(null)
|
||
}, [periodType, year, period, fiscalPeriodId])
|
||
|
||
useEffect(() => {
|
||
if (!fetchKey || periodType === null) return
|
||
const params = new URLSearchParams({
|
||
periodType,
|
||
year: String(year),
|
||
period: String(period),
|
||
})
|
||
if (periodType === 'yearly') params.set('fiscal_period_id', fiscalPeriodId)
|
||
let cancelled = false
|
||
fetch(`/api/reports/vat-declaration?${params.toString()}`)
|
||
.then(async (res) => {
|
||
const json = await res.json().catch(() => null)
|
||
if (cancelled) return
|
||
if (!res.ok || json?.error) {
|
||
setResult({
|
||
key: fetchKey,
|
||
error:
|
||
typeof json?.error === 'string' ? json.error : 'Kunde inte hämta momsdeklaration',
|
||
})
|
||
} else {
|
||
setResult({ key: fetchKey, declaration: json.data })
|
||
}
|
||
})
|
||
.catch(() => {
|
||
if (!cancelled) setResult({ key: fetchKey, error: 'Kunde inte hämta momsdeklaration' })
|
||
})
|
||
return () => {
|
||
cancelled = true
|
||
}
|
||
}, [fetchKey, periodType, year, period, fiscalPeriodId])
|
||
|
||
// The per-verifikat gap scan runs on the same key as the declaration, so a
|
||
// korrigering (which bumps retryKey via onCorrected) re-verifies the gate
|
||
// instead of leaving it stuck on the pre-correction count.
|
||
useEffect(() => {
|
||
if (!fetchKey || periodType === null || !gapScanPeriodKey) return
|
||
const params = new URLSearchParams({
|
||
periodType,
|
||
year: String(year),
|
||
period: String(period),
|
||
})
|
||
if (periodType === 'yearly') params.set('fiscal_period_id', fiscalPeriodId)
|
||
let cancelled = false
|
||
fetch(`/api/reports/vat-declaration/rc-basis-gaps?${params.toString()}`)
|
||
.then(async (res) => {
|
||
const json = await res.json().catch(() => null)
|
||
if (cancelled) return
|
||
// count === null is a settled FAILURE, and unknown is not "inga
|
||
// brister": it becomes a visible warning row (so the banner cannot
|
||
// claim all-clear on a check that never ran) but does not block, or a
|
||
// network hiccup would lock the user out of a statutory deadline.
|
||
if (!res.ok || json?.error) setGapScan({ key: gapScanPeriodKey, count: null })
|
||
else setGapScan({ key: gapScanPeriodKey, count: (json?.data?.gaps ?? []).length })
|
||
})
|
||
.catch(() => {
|
||
if (!cancelled) setGapScan({ key: gapScanPeriodKey, count: null })
|
||
})
|
||
return () => {
|
||
cancelled = true
|
||
}
|
||
}, [fetchKey, gapScanPeriodKey, periodType, year, period, fiscalPeriodId])
|
||
|
||
// Derived fetch state: the previous declaration stays visible (dimmed)
|
||
// while the next period loads.
|
||
const upToDate = result !== null && result.key === fetchKey
|
||
const data = result?.declaration ?? null
|
||
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.
|
||
//
|
||
// The per-verifikat gap scan is folded in BEFORE anything derives from the
|
||
// list. The aggregate checks compare period totals with a tolerance that
|
||
// scales with period size, so they can stay silent while individual
|
||
// verifikat still miss their basbelopp: reading them alone let the green
|
||
// "Inga fel hittades i underlaget för perioden" render directly above the
|
||
// worklist of those verifikat, with Skicka enabled. Banner, stegen and the
|
||
// send gate now all read this ONE array. The three scan states are kept
|
||
// apart: only a settled count of zero is allowed to mean "inga brister".
|
||
const gapScanSettled = gapScan !== null && gapScan.key === gapScanPeriodKey
|
||
const rcBasisScan: RcBasisGapScan = !gapScanSettled
|
||
? { status: 'pending' }
|
||
: gapScan.count === null
|
||
? { status: 'unavailable' }
|
||
: { status: 'scanned', gapCount: gapScan.count }
|
||
// The declaration carries the 2645/2647 totals (rcInputAccountTotals), so
|
||
// RC_INPUT_VAT_MISMATCH compares rutor 30-32 against the reverse-charge INPUT
|
||
// accounts instead of the ruta 48 aggregate. Without them, 50 000 kr of
|
||
// fiktiv utgående moms with nothing on 2645 sits silently behind 60 000 kr of
|
||
// ordinary 2641 and the user pays in moms they were entitled to deduct.
|
||
// rcInputTotalsFromDeclaration returns undefined (not an empty map) when a
|
||
// response predates the field, which keeps the fallback honest.
|
||
const checks = data
|
||
? withRcBasisGapFindings(
|
||
runVatDeclarationChecks(data.rutor, rcInputTotalsFromDeclaration(data)),
|
||
rcBasisScan,
|
||
)
|
||
: []
|
||
const checksBlocked = isFilingBlocked(checks)
|
||
const errorCount = checks.filter((c) => c.status === 'ERROR').length
|
||
const warningCount = checks.filter((c) => c.status === 'WARNING').length
|
||
// Latch the automatic landing step once per period, as a render-phase
|
||
// adjustment when the period's declaration first settles. Deriving it live
|
||
// from checksBlocked navigated the user away mid-work: the refetch after a
|
||
// korrigering can clear the aggregate error while the Kontrollera worklist
|
||
// still holds broken vouchers, and the view would jump to Granska under
|
||
// their feet. The period-change effect below resets chosenStep to null,
|
||
// which re-arms this latch for the next period. It also waits for the gap
|
||
// scan: latching on the aggregate alone landed the user on Granska while
|
||
// steg 1 still held a blocking worklist.
|
||
if (chosenStep === null && upToDate && data && !error && gapScanSettled) {
|
||
setChosenStep(checksBlocked ? 1 : 2)
|
||
}
|
||
const activeStep = chosenStep ?? (checksBlocked ? 1 : 2)
|
||
|
||
// Settings not settled yet — the picker defaults and the gate both depend
|
||
// on them, so hold the whole view in a skeleton.
|
||
// The standalone page renders its own PageHeader (FocusedReport passes the
|
||
// title and skips its own), so the H1 must survive the gated/loading
|
||
// states too: each early return carries the action-less header.
|
||
const bareHeader = pageTitle ? <PageHeader title={pageTitle} /> : null
|
||
|
||
if (settingsLoading || periodType === null) {
|
||
return (
|
||
<div className="space-y-8">
|
||
{bareHeader}
|
||
<Card>
|
||
<CardContent className="p-6 space-y-4">
|
||
<Skeleton className="h-5 w-32" />
|
||
<Skeleton className="h-64" />
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (notVatRegistered) {
|
||
return (
|
||
<div className="space-y-8">
|
||
{bareHeader}
|
||
<EmptyState
|
||
icon={Percent}
|
||
title="Företaget är inte momsregistrerat"
|
||
description="Momsdeklarationen bygger på företagets skatteinställningar. Om företaget är momsregistrerat anger du momsregistrering och redovisningsperiod i inställningarna, så visas deklarationen här."
|
||
actionLabel="Öppna skatteinställningar"
|
||
actionHref="/settings/tax"
|
||
/>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// Registered but no redovisningsperiod picked: block instead of guessing.
|
||
// A declaration rendered (and submittable via panelen) for the wrong
|
||
// period type is a compliance hazard, not a convenience.
|
||
if (momsPeriodMissing) {
|
||
return (
|
||
<div className="space-y-8">
|
||
{bareHeader}
|
||
<EmptyState
|
||
icon={Percent}
|
||
title="Redovisningsperiod för moms saknas"
|
||
description="Företaget är momsregistrerat men ingen redovisningsperiod (månad, kvartal eller helår) är vald. Ange den i skatteinställningarna så visas deklarationen för rätt period."
|
||
actionLabel="Öppna skatteinställningar"
|
||
actionHref="/settings/tax"
|
||
/>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// Year and concrete period fused into ONE chip: reverse-chronological
|
||
// "Kvartal 2 2026", "Kvartal 1 2026", ... across the last five years, so
|
||
// switching period never needs two pickers. Yearly keeps the FyPicker,
|
||
// which is already a fused räkenskapsår chip.
|
||
const fusedPeriodItems: { id: string; label: string; annotation?: string }[] = []
|
||
if (!isYearly) {
|
||
for (let y = currentYear; y > currentYear - 5; y--) {
|
||
if (periodType === 'quarterly') {
|
||
for (let q = 4; q >= 1; q--) {
|
||
fusedPeriodItems.push({
|
||
id: `${y}:${q}`,
|
||
label: `Kvartal ${q} ${y}`,
|
||
annotation: QUARTER_SPANS[q - 1],
|
||
})
|
||
}
|
||
} else {
|
||
for (let m = 12; m >= 1; m--) {
|
||
fusedPeriodItems.push({ id: `${y}:${m}`, label: `${MONTH_NAMES[m - 1]} ${y}` })
|
||
}
|
||
}
|
||
}
|
||
}
|
||
const fusedLabel =
|
||
periodType === 'quarterly' ? `Kvartal ${period} ${year}` : `${MONTH_NAMES[period - 1]} ${year}`
|
||
|
||
return (
|
||
<VatDrillContext.Provider value={{ fiscalPeriodId: isYearly ? fiscalPeriodId : undefined }}>
|
||
<div className="space-y-8">
|
||
{/* Standalone page: the title row carries the primary action (locked
|
||
convention 9), so Exportera sits beside the H1 and the period chips
|
||
get their own row below. XML and PDF live in "Lämna in": they are
|
||
filing artifacts, not report exports. */}
|
||
{pageTitle && (
|
||
<PageHeader
|
||
title={pageTitle}
|
||
action={
|
||
<ReportExportMenu
|
||
variant="default"
|
||
items={[
|
||
{ format: 'xlsx', href: `/api/reports/vat-declaration/xlsx?${vatQueryString()}` },
|
||
]}
|
||
/>
|
||
}
|
||
/>
|
||
)}
|
||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||
{/* Cadence lives behind a settings-style "Period" chip: the
|
||
concrete period chip next to it already shows the cadence
|
||
("Kvartal 2" implies quarterly, "Räkenskapsår ..." yearly). */}
|
||
<ContextPicker
|
||
items={[
|
||
{ id: 'monthly', label: 'Månadsvis' },
|
||
{ id: 'quarterly', label: 'Kvartalsvis' },
|
||
{ id: 'yearly', label: 'Årsvis' },
|
||
]}
|
||
value={periodType}
|
||
onChange={(value) => handlePeriodTypeChange(value as VatPeriodType)}
|
||
triggerLabel="Period"
|
||
ariaLabel="Periodicitet"
|
||
/>
|
||
{isYearly ? (
|
||
// Annual VAT covers a räkenskapsår: picked here, not a
|
||
// calendar year.
|
||
<FyPicker
|
||
value={fiscalPeriodId || null}
|
||
onChange={(id, fp) => {
|
||
setFiscalPeriodId(id || '')
|
||
setFiscalPeriodEnd(fp?.period_end ?? null)
|
||
}}
|
||
includeAllOption={false}
|
||
hideFuturePeriods
|
||
/>
|
||
) : (
|
||
<ContextPicker
|
||
items={fusedPeriodItems}
|
||
value={`${year}:${period}`}
|
||
onChange={(id) => {
|
||
const [y, p] = id.split(':').map(Number)
|
||
setYear(y)
|
||
setPeriod(p)
|
||
}}
|
||
triggerLabel={fusedLabel}
|
||
ariaLabel="Redovisningsperiod"
|
||
/>
|
||
)}
|
||
{!pageTitle && (
|
||
<ReportExportMenu
|
||
variant="default"
|
||
items={[
|
||
{ format: 'xlsx', href: `/api/reports/vat-declaration/xlsx?${vatQueryString()}` },
|
||
]}
|
||
/>
|
||
)}
|
||
</div>
|
||
|
||
{error && (
|
||
<Card>
|
||
<CardContent className="flex flex-col items-center p-8 text-center">
|
||
<AlertCircle className="mb-2 h-6 w-6 text-destructive" />
|
||
<p className="mb-4 text-sm text-destructive">{error}</p>
|
||
<Button variant="outline" size="sm" onClick={() => setRetryKey((k) => k + 1)}>
|
||
Försök igen
|
||
</Button>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{!error && (awaitingFiscalPeriod || (loading && !data)) && (
|
||
<Card>
|
||
<CardContent className="p-6 space-y-4">
|
||
<Skeleton className="h-5 w-48" />
|
||
<Skeleton className="h-64" />
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{data && !awaitingFiscalPeriod && (
|
||
<div
|
||
className={`space-y-8 transition-opacity duration-150 ${loading ? 'opacity-60' : ''}`}
|
||
>
|
||
{/* Stegen (concept): the filing pipeline as a horizontal stepper —
|
||
kontrollera, granska, bokför, lämna in — showing one step's
|
||
content at a time. Errors land on step 1, otherwise Granska. */}
|
||
<VatStepper
|
||
active={activeStep}
|
||
onSelect={setChosenStep}
|
||
errorCount={errorCount}
|
||
warningCount={warningCount}
|
||
ruta49={data.rutor.ruta49}
|
||
bookingStatus={bookingStatus}
|
||
/>
|
||
|
||
{activeStep === 1 && (
|
||
<section className="mx-auto max-w-3xl space-y-3">
|
||
<VatChecksCard
|
||
checks={checks}
|
||
periodType={periodType}
|
||
year={year}
|
||
period={period}
|
||
fiscalPeriodId={isYearly ? fiscalPeriodId : undefined}
|
||
onCorrected={() => setRetryKey((k) => k + 1)}
|
||
/>
|
||
<div className="flex justify-end">
|
||
<Button variant="outline" size="sm" onClick={() => setChosenStep(2)}>
|
||
Nästa: Granska deklarationen →
|
||
</Button>
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
{activeStep === 2 && (
|
||
<section className="space-y-3">
|
||
<div className="mx-auto max-w-2xl">
|
||
<div className="flex flex-wrap items-baseline justify-between gap-3 px-1">
|
||
<h3 className="font-sans text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||
Momsdeklaration · {data.period.start} till {data.period.end}
|
||
</h3>
|
||
<span className="text-[11.5px] tabular-nums text-muted-foreground">
|
||
{data.invoiceCount} fakturor · {data.transactionCount} transaktioner
|
||
</span>
|
||
</div>
|
||
<div className="mt-4 space-y-8">
|
||
<div>
|
||
{/* Utgående moms */}
|
||
<div>
|
||
<h3 className="mb-3 font-sans text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||
Utgående moms (försäljning)
|
||
</h3>
|
||
<Table>
|
||
<TableBody>
|
||
{data.rutor.ruta05 > 0 && (
|
||
<VatRutaRow
|
||
ruta="05"
|
||
label="Momspliktig försäljning"
|
||
amount={data.rutor.ruta05}
|
||
baseAmount={0}
|
||
periodType={periodType}
|
||
year={year}
|
||
period={period}
|
||
/>
|
||
)}
|
||
<VatRutaRow
|
||
ruta="10"
|
||
label="Utgående moms 25%"
|
||
amount={data.rutor.ruta10}
|
||
baseAmount={data.breakdown.invoices.base25}
|
||
periodType={periodType}
|
||
year={year}
|
||
period={period}
|
||
/>
|
||
<VatRutaRow
|
||
ruta="11"
|
||
label="Utgående moms 12%"
|
||
amount={data.rutor.ruta11}
|
||
baseAmount={data.breakdown.invoices.base12}
|
||
periodType={periodType}
|
||
year={year}
|
||
period={period}
|
||
/>
|
||
<VatRutaRow
|
||
ruta="12"
|
||
label="Utgående moms 6%"
|
||
amount={data.rutor.ruta12}
|
||
baseAmount={data.breakdown.invoices.base6}
|
||
periodType={periodType}
|
||
year={year}
|
||
period={period}
|
||
/>
|
||
<VatRutaRow
|
||
ruta="39"
|
||
label="Tjänster EU (omvänd skattskyldighet)"
|
||
amount={0}
|
||
baseAmount={data.rutor.ruta39}
|
||
noVat
|
||
periodType={periodType}
|
||
year={year}
|
||
period={period}
|
||
/>
|
||
<VatRutaRow
|
||
ruta="40"
|
||
label="Export utanför EU"
|
||
amount={0}
|
||
baseAmount={data.rutor.ruta40}
|
||
noVat
|
||
periodType={periodType}
|
||
year={year}
|
||
period={period}
|
||
/>
|
||
</TableBody>
|
||
<tfoot>
|
||
<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.ruta60 + data.rutor.ruta61 + data.rutor.ruta62
|
||
)} kr
|
||
</td>
|
||
</tr>
|
||
</tfoot>
|
||
</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) && (
|
||
<>
|
||
<h3 className="mb-3 mt-6 font-sans text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||
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} />
|
||
<VatRutaRow ruta="23" label="Inköp av varor i Sverige" amount={0} baseAmount={data.rutor.ruta23} noVat periodType={periodType} year={year} period={period} />
|
||
<VatRutaRow ruta="24" label="Övriga inköp av tjänster i Sverige" amount={0} baseAmount={data.rutor.ruta24} noVat periodType={periodType} year={year} period={period} />
|
||
<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} />
|
||
</TableBody>
|
||
</Table>
|
||
</>
|
||
)}
|
||
|
||
{/* Moms vid import */}
|
||
{(data.rutor.ruta50 > 0 || data.rutor.ruta60 > 0 || data.rutor.ruta61 > 0 || data.rutor.ruta62 > 0) && (
|
||
<>
|
||
<h3 className="mb-3 mt-6 font-sans text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||
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>
|
||
</div>
|
||
<div>
|
||
{/* Ingående moms */}
|
||
<div>
|
||
<h3 className="mb-3 font-sans text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||
Ingående moms (avdragsgill)
|
||
</h3>
|
||
<Table>
|
||
<TableBody>
|
||
<VatRutaRow
|
||
ruta="48"
|
||
label="Ingående moms att dra av"
|
||
amount={data.rutor.ruta48}
|
||
baseAmount={0}
|
||
periodType={periodType}
|
||
year={year}
|
||
period={period}
|
||
/>
|
||
{data.breakdown.transactions.ruta48 > 0 && (
|
||
<tr className="text-muted-foreground">
|
||
<td className="py-1 pl-6 text-xs">- från transaktioner</td>
|
||
<td className="py-1 text-right text-xs">
|
||
{formatAmount(data.breakdown.transactions.ruta48)} kr
|
||
</td>
|
||
</tr>
|
||
)}
|
||
{data.breakdown.receipts.ruta48 > 0 && (
|
||
<tr className="text-muted-foreground">
|
||
<td className="py-1 pl-6 text-xs">- från kvitton</td>
|
||
<td className="py-1 text-right text-xs">
|
||
{formatAmount(data.breakdown.receipts.ruta48)} kr
|
||
</td>
|
||
</tr>
|
||
)}
|
||
</TableBody>
|
||
<tfoot>
|
||
<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>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Ruta 49 as the emphasized document foot (concept skv-foot). */}
|
||
<div className="mt-8 flex items-baseline gap-3 border-t-2 border-foreground/80 pt-3">
|
||
<span className="font-mono text-xs text-muted-foreground">49</span>
|
||
<span className="flex-1 text-sm font-medium">
|
||
{data.rutor.ruta49 >= 0 ? 'Moms att betala' : 'Moms att återfå'}
|
||
</span>
|
||
<span className="text-sm font-semibold tabular-nums">
|
||
{formatAmount(Math.abs(data.rutor.ruta49))} kr
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div className="flex justify-end">
|
||
<Button variant="outline" size="sm" onClick={() => setChosenStep(3)}>
|
||
Nästa: Bokför momsen →
|
||
</Button>
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
{activeStep === 3 && (
|
||
<section className="mx-auto max-w-3xl space-y-3">
|
||
<VatBookingCard
|
||
periodType={periodType}
|
||
year={year}
|
||
period={period}
|
||
fiscalPeriodId={isYearly ? fiscalPeriodId : undefined}
|
||
checksBlocked={checksBlocked}
|
||
onStatus={setBookingStatus}
|
||
/>
|
||
<div className="flex justify-end">
|
||
<Button variant="outline" size="sm" onClick={() => setChosenStep(4)}>
|
||
Nästa: Lämna in →
|
||
</Button>
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
{activeStep === 4 && (
|
||
<section className="mx-auto max-w-3xl space-y-8">
|
||
<SkatteverketPanel
|
||
periodType={periodType}
|
||
year={year}
|
||
period={period}
|
||
fiscalPeriodId={isYearly ? fiscalPeriodId : undefined}
|
||
fiscalYearEnd={
|
||
isYearly && fiscalPeriodEnd
|
||
? {
|
||
year: Number(fiscalPeriodEnd.slice(0, 4)),
|
||
month: Number(fiscalPeriodEnd.slice(5, 7)),
|
||
}
|
||
: undefined
|
||
}
|
||
hasData={data !== null}
|
||
localBlocked={checksBlocked}
|
||
/>
|
||
<VatManualFilingCard
|
||
xmlHref={`/api/reports/vat-declaration/eskd?${vatQueryString()}`}
|
||
pdfHref={`/api/reports/vat-declaration/pdf?${vatQueryString()}`}
|
||
/>
|
||
</section>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Skatteverket integration panel for the no-data states (fetch error,
|
||
empty period): with data it lives inside steg 4. Hidden while the
|
||
räkenskapsår for helårsmoms is unresolved, so its actions can never
|
||
target an unconfirmed period. */}
|
||
{!awaitingFiscalPeriod && !data && (
|
||
<SkatteverketPanel
|
||
periodType={periodType}
|
||
year={year}
|
||
period={period}
|
||
fiscalPeriodId={isYearly ? fiscalPeriodId : undefined}
|
||
fiscalYearEnd={
|
||
isYearly && fiscalPeriodEnd
|
||
? {
|
||
year: Number(fiscalPeriodEnd.slice(0, 4)),
|
||
month: Number(fiscalPeriodEnd.slice(5, 7)),
|
||
}
|
||
: undefined
|
||
}
|
||
hasData={data !== null}
|
||
localBlocked={checksBlocked}
|
||
/>
|
||
)}
|
||
</div>
|
||
</VatDrillContext.Provider>
|
||
)
|
||
}
|
||
|
||
function makeVatFetcher(
|
||
ruta: string,
|
||
periodType: VatPeriodType,
|
||
year: number,
|
||
period: number,
|
||
fiscalPeriodId?: string,
|
||
): ReportSourceFetcher {
|
||
return async () => {
|
||
const params = new URLSearchParams({
|
||
periodType,
|
||
year: String(year),
|
||
period: String(period),
|
||
})
|
||
// Yearly drill-down resolves against the räkenskapsår, matching the report.
|
||
if (periodType === 'yearly' && fiscalPeriodId) {
|
||
params.set('fiscal_period_id', fiscalPeriodId)
|
||
}
|
||
const res = await fetch(
|
||
`/api/reports/vat-declaration/ruta/${encodeURIComponent(ruta)}/sources?${params.toString()}`
|
||
)
|
||
const json = await res.json()
|
||
if (!res.ok) throw new Error(json.error || 'Kunde inte hämta verifikat')
|
||
const lines: ReportSourceLine[] = json.data?.lines || []
|
||
return { lines, next_cursor: json.data?.next_cursor ?? null }
|
||
}
|
||
}
|
||
|
||
function VatRutaRow({
|
||
ruta,
|
||
label,
|
||
amount,
|
||
baseAmount,
|
||
noVat,
|
||
periodType,
|
||
year,
|
||
period,
|
||
}: {
|
||
ruta: string
|
||
label: string
|
||
amount: number
|
||
baseAmount: number
|
||
noVat?: boolean
|
||
periodType?: VatPeriodType
|
||
year?: number
|
||
period?: number
|
||
}) {
|
||
const { fiscalPeriodId } = React.useContext(VatDrillContext)
|
||
const canDrill = periodType !== undefined && year !== undefined && period !== undefined
|
||
const fetcher = React.useMemo(
|
||
() => (canDrill ? makeVatFetcher(ruta, periodType!, year!, period!, fiscalPeriodId) : null),
|
||
[canDrill, ruta, periodType, year, period, fiscalPeriodId]
|
||
)
|
||
// Hooks must be called unconditionally: provide a noop fetcher when drill
|
||
// is disabled. The early-return for zero rows lives below the hooks.
|
||
const expansion = useReportRowExpansion(
|
||
fetcher ?? (async () => ({ lines: [], next_cursor: null })),
|
||
`vat-${ruta}`
|
||
)
|
||
|
||
// Don't show rows with zero values
|
||
if (baseAmount === 0 && amount === 0) return null
|
||
|
||
return (
|
||
<>
|
||
<tr className="border-b">
|
||
<td className="py-2">
|
||
{canDrill && (
|
||
<span className="inline-block align-middle mr-1">
|
||
<expansion.Toggle />
|
||
</span>
|
||
)}
|
||
<span className="font-mono text-xs bg-muted px-1 rounded mr-2">{ruta}</span>
|
||
{label}
|
||
</td>
|
||
<td className="py-2 text-right tabular-nums">{noVat ? `${formatAmount(baseAmount)} kr` : `${formatAmount(amount)} kr`}</td>
|
||
</tr>
|
||
{!noVat && baseAmount > 0 && (
|
||
<tr className="text-muted-foreground">
|
||
<td className="py-1 pl-6 text-xs">Underlag</td>
|
||
<td className="py-1 text-right text-xs tabular-nums">{formatAmount(baseAmount)} kr</td>
|
||
</tr>
|
||
)}
|
||
{canDrill && <expansion.Panel colSpan={2} />}
|
||
</>
|
||
)
|
||
}
|
||
|
||
interface SupplierLedgerData {
|
||
ledger: {
|
||
entries: {
|
||
supplier_id: string
|
||
supplier_name: string
|
||
current: number
|
||
days_1_30: number
|
||
days_31_60: number
|
||
days_61_90: number
|
||
days_90_plus: number
|
||
total_outstanding: number
|
||
}[]
|
||
total_outstanding: number
|
||
total_current: number
|
||
total_overdue: number
|
||
unpaid_count: number
|
||
unconverted_fx_count: number
|
||
}
|
||
reconciliation: {
|
||
supplier_ledger_total: number
|
||
account_2440_balance: number
|
||
difference: number
|
||
is_reconciled: boolean
|
||
unconverted_fx_count: number
|
||
} | null
|
||
}
|
||
|
||
// Local calendar date (YYYY-MM-DD) for the reskontra "per datum" default:
|
||
// toISOString() is UTC and rolls the date over an hour early in Sweden.
|
||
function localIsoDate(): string {
|
||
const now = new Date()
|
||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
|
||
}
|
||
|
||
// Shared "Per datum" control + export menu header for the two reskontra views
|
||
// (#1020/#1021): pick an arbitrary as-of date and export PDF/Excel for it.
|
||
function ReskontraToolbar({
|
||
asOfDate,
|
||
onAsOfDateChange,
|
||
inputId,
|
||
exportBase,
|
||
}: {
|
||
asOfDate: string
|
||
onAsOfDateChange: (date: string) => void
|
||
inputId: string
|
||
exportBase: string
|
||
}) {
|
||
return (
|
||
<div className="flex flex-wrap items-end justify-between gap-4">
|
||
<div className="space-y-1">
|
||
<Label htmlFor={inputId} className="text-xs text-muted-foreground">
|
||
Per datum
|
||
</Label>
|
||
<Input
|
||
id={inputId}
|
||
type="date"
|
||
value={asOfDate}
|
||
onChange={(e) => {
|
||
if (e.target.value) onAsOfDateChange(e.target.value)
|
||
}}
|
||
className="w-40"
|
||
/>
|
||
</div>
|
||
<ReportExportMenu
|
||
items={[
|
||
{ format: 'pdf', href: `${exportBase}/pdf?as_of_date=${asOfDate}` },
|
||
{ format: 'xlsx', href: `${exportBase}/xlsx?as_of_date=${asOfDate}` },
|
||
]}
|
||
/>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export function SupplierLedgerView({ periodId }: { periodId: string }) {
|
||
const [data, setData] = useState<SupplierLedgerData | null>(null)
|
||
const [loading, setLoading] = useState(false)
|
||
const [error, setError] = useState<string | null>(null)
|
||
const [asOfDate, setAsOfDate] = useState(localIsoDate)
|
||
|
||
const fetchData = async () => {
|
||
setLoading(true)
|
||
setError(null)
|
||
try {
|
||
const res = await fetch(`/api/reports/supplier-ledger?period_id=${periodId}&as_of_date=${asOfDate}`)
|
||
const result = await res.json()
|
||
if (result.error) {
|
||
setError(result.error)
|
||
} else {
|
||
setData(result.data)
|
||
}
|
||
} catch {
|
||
setError('Kunde inte hämta leverantörsreskontra')
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
useEffect(() => {
|
||
if (periodId) fetchData()
|
||
}, [periodId, asOfDate])
|
||
|
||
if (loading) {
|
||
return (
|
||
<Card>
|
||
<CardContent className="p-8 text-center text-muted-foreground">
|
||
Laddar leverantörsreskontra...
|
||
</CardContent>
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
if (error) {
|
||
return (
|
||
<Card>
|
||
<CardContent className="p-8 text-center text-destructive">
|
||
<AlertCircle className="h-6 w-6 mx-auto mb-2" />
|
||
{error}
|
||
</CardContent>
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
if (!data || !data.ledger) {
|
||
return (
|
||
<Card>
|
||
<CardContent className="p-8 text-center text-muted-foreground">
|
||
Ingen data tillgänglig.
|
||
</CardContent>
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
const { ledger, reconciliation } = data
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<ReskontraToolbar
|
||
asOfDate={asOfDate}
|
||
onAsOfDateChange={setAsOfDate}
|
||
inputId="supplier-ledger-as-of"
|
||
exportBase="/api/reports/supplier-ledger"
|
||
/>
|
||
{/* Summary cards */}
|
||
<div className="grid md:grid-cols-3 gap-4">
|
||
<Card>
|
||
<CardHeader className="pb-2">
|
||
<CardTitle className="text-sm text-muted-foreground">Totalt utestående</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<p className="font-display text-2xl tabular-nums">{formatAmount(ledger.total_outstanding)} kr</p>
|
||
<p className="text-xs text-muted-foreground">{ledger.unpaid_count} fakturor</p>
|
||
{ledger.unconverted_fx_count > 0 && (
|
||
<p className="mt-1 text-xs text-muted-foreground">
|
||
{ledger.unconverted_fx_count} faktura i utländsk valuta utan växelkurs är inte med i totalen.
|
||
</p>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
<Card>
|
||
<CardHeader className="pb-2">
|
||
<CardTitle className="text-sm text-muted-foreground">Ej förfallet</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<p className="font-display text-2xl tabular-nums text-success">{formatAmount(ledger.total_current)} kr</p>
|
||
</CardContent>
|
||
</Card>
|
||
<Card>
|
||
<CardHeader className="pb-2">
|
||
<CardTitle className="text-sm text-muted-foreground">Förfallet</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<p className="font-display text-2xl tabular-nums text-destructive">{formatAmount(ledger.total_overdue)} kr</p>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
|
||
{/* Aging table */}
|
||
{ledger.entries.length > 0 && (
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>Ålderfördelning per leverantör</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="overflow-x-auto -mx-2 px-2"><table className="w-full text-sm min-w-[500px]">
|
||
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
|
||
<tr className="border-b text-left">
|
||
<th className="py-2 w-8"></th>
|
||
<th className="py-2">Leverantör</th>
|
||
<th className="py-2 text-right">Ej förfallet</th>
|
||
<th className="py-2 text-right">1-30 dagar</th>
|
||
<th className="py-2 text-right">31-60 dagar</th>
|
||
<th className="py-2 text-right">61-90 dagar</th>
|
||
<th className="py-2 text-right">90+ dagar</th>
|
||
<th className="py-2 text-right font-semibold">Totalt</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{ledger.entries.map((entry) => (
|
||
<SupplierLedgerRow key={entry.supplier_id} entry={entry} />
|
||
))}
|
||
</tbody>
|
||
<tfoot>
|
||
<tr className="font-semibold border-t-2">
|
||
<td className="py-2"></td>
|
||
<td className="py-2">Summa</td>
|
||
<td className="py-2 text-right">{formatAmount(ledger.entries.reduce((s, e) => s + e.current, 0))}</td>
|
||
<td className="py-2 text-right">{formatAmount(ledger.entries.reduce((s, e) => s + e.days_1_30, 0))}</td>
|
||
<td className="py-2 text-right">{formatAmount(ledger.entries.reduce((s, e) => s + e.days_31_60, 0))}</td>
|
||
<td className="py-2 text-right">{formatAmount(ledger.entries.reduce((s, e) => s + e.days_61_90, 0))}</td>
|
||
<td className="py-2 text-right text-destructive">{formatAmount(ledger.entries.reduce((s, e) => s + e.days_90_plus, 0))}</td>
|
||
<td className="py-2 text-right">{formatAmount(ledger.total_outstanding)}</td>
|
||
</tr>
|
||
</tfoot>
|
||
</table></div>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{/* Reconciliation */}
|
||
{reconciliation && (
|
||
<Card className="border-2">
|
||
<CardHeader>
|
||
<CardTitle>Avstämning mot <AccountNumber number="2440" /></CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="space-y-2 text-sm">
|
||
<div className="flex justify-between">
|
||
<span>Leverantörsreskontra (summa utestående)</span>
|
||
<span className="font-mono">{formatAmount(reconciliation.supplier_ledger_total)} kr</span>
|
||
</div>
|
||
<div className="flex justify-between">
|
||
<span><AccountNumber number="2440" /> saldo (huvudbok)</span>
|
||
<span className="font-mono">{formatAmount(reconciliation.account_2440_balance)} kr</span>
|
||
</div>
|
||
<div className="flex justify-between pt-2 border-t font-semibold">
|
||
<span>Differens</span>
|
||
<span className={reconciliation.is_reconciled ? 'text-success' : 'text-destructive'}>
|
||
{formatAmount(reconciliation.difference)} kr
|
||
</span>
|
||
</div>
|
||
<div className="pt-2 space-y-2">
|
||
{reconciliation.is_reconciled ? (
|
||
<Badge variant="success">Avstämd</Badge>
|
||
) : (
|
||
<Badge variant="destructive">Ej avstämd - kontrollera bokföring</Badge>
|
||
)}
|
||
{reconciliation.unconverted_fx_count > 0 && (
|
||
<p className="text-xs text-muted-foreground">
|
||
{reconciliation.unconverted_fx_count} leverantörsfaktura i utländsk valuta saknar växelkurs: differensen kan bero på saknade kursuppgifter snarare än felbokning.
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function makeSupplierFetcher(supplierId: string): ReportSourceFetcher {
|
||
return async () => {
|
||
const res = await fetch(
|
||
`/api/reports/supplier-ledger/supplier/${encodeURIComponent(supplierId)}/invoices`
|
||
)
|
||
const json = await res.json()
|
||
if (!res.ok) throw new Error(json.error || 'Kunde inte hämta leverantörsfakturor')
|
||
const lines: ReportSourceLine[] = json.data?.lines || []
|
||
return { lines, next_cursor: json.data?.next_cursor ?? null }
|
||
}
|
||
}
|
||
|
||
function SupplierLedgerRow({
|
||
entry,
|
||
}: {
|
||
entry: {
|
||
supplier_id: string
|
||
supplier_name: string
|
||
current: number
|
||
days_1_30: number
|
||
days_31_60: number
|
||
days_61_90: number
|
||
days_90_plus: number
|
||
total_outstanding: number
|
||
}
|
||
}) {
|
||
const fetcher = React.useMemo(
|
||
() => makeSupplierFetcher(entry.supplier_id),
|
||
[entry.supplier_id]
|
||
)
|
||
const { Toggle, Panel } = useReportRowExpansion(fetcher, `sup-${entry.supplier_id}`)
|
||
return (
|
||
<>
|
||
<tr className="border-b last:border-0 hover:bg-muted/30 transition-colors">
|
||
<td className="py-2"><Toggle /></td>
|
||
<td className="py-2">{entry.supplier_name}</td>
|
||
<td className="py-2 text-right tabular-nums">{entry.current > 0 ? formatAmount(entry.current) : ''}</td>
|
||
<td className="py-2 text-right tabular-nums">{entry.days_1_30 > 0 ? formatAmount(entry.days_1_30) : ''}</td>
|
||
<td className="py-2 text-right tabular-nums">{entry.days_31_60 > 0 ? formatAmount(entry.days_31_60) : ''}</td>
|
||
<td className="py-2 text-right tabular-nums">{entry.days_61_90 > 0 ? formatAmount(entry.days_61_90) : ''}</td>
|
||
<td className="py-2 text-right tabular-nums text-destructive">{entry.days_90_plus > 0 ? formatAmount(entry.days_90_plus) : ''}</td>
|
||
<td className="py-2 text-right tabular-nums font-semibold">{formatAmount(entry.total_outstanding)}</td>
|
||
</tr>
|
||
<Panel colSpan={8} />
|
||
</>
|
||
)
|
||
}
|
||
|
||
// --- General Ledger (Huvudbok) ---
|
||
|
||
interface GeneralLedgerData {
|
||
accounts: {
|
||
account_number: string
|
||
account_name: string
|
||
opening_balance: number
|
||
lines: {
|
||
date: string
|
||
voucher_series: string
|
||
voucher_number: number
|
||
journal_entry_id: string
|
||
description: string
|
||
source_type: string
|
||
debit: number
|
||
credit: number
|
||
balance: number
|
||
dimensions?: Record<string, string>
|
||
}[]
|
||
closing_balance: number
|
||
total_debit: number
|
||
total_credit: number
|
||
}[]
|
||
period: { start: string; end: string }
|
||
}
|
||
|
||
// Stable default: an inline `= {}` would change identity every render and
|
||
// re-trigger the fetch effect for callers that omit the prop.
|
||
const EMPTY_DATE_RANGE: DateRangeValue = {}
|
||
|
||
export function GeneralLedgerView({ periodId, initialAccountFilter, dimensionFilter = null, dateRange = EMPTY_DATE_RANGE }: { periodId: string; initialAccountFilter: string | null; dimensionFilter?: DimensionFilterValue | null; dateRange?: DateRangeValue }) {
|
||
const [data, setData] = useState<GeneralLedgerData | null>(null)
|
||
const [loading, setLoading] = useState(false)
|
||
const [error, setError] = useState<string | null>(null)
|
||
const [accountFrom, setAccountFrom] = useState('')
|
||
const [accountTo, setAccountTo] = useState('')
|
||
|
||
const fetchData = useCallback(async (fromOverride?: string, toOverride?: string) => {
|
||
const from = fromOverride ?? accountFrom
|
||
const to = toOverride ?? accountTo
|
||
setLoading(true)
|
||
setError(null)
|
||
try {
|
||
const params = new URLSearchParams({ period_id: periodId })
|
||
if (from) params.set('account_from', from)
|
||
if (to) params.set('account_to', to)
|
||
if (dateRange.fromDate) params.set('from_date', dateRange.fromDate)
|
||
if (dateRange.toDate) params.set('to_date', dateRange.toDate)
|
||
if (dimensionFilter) {
|
||
params.set('dim_no', dimensionFilter.dimNo)
|
||
params.set('dim_code', dimensionFilter.code)
|
||
}
|
||
const res = await fetch(`/api/reports/general-ledger?${params}`)
|
||
const result = await res.json()
|
||
if (result.error) {
|
||
setError(result.error)
|
||
} else {
|
||
setData(result.data)
|
||
}
|
||
} catch {
|
||
setError('Kunde inte hämta huvudbok')
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}, [periodId, accountFrom, accountTo, dimensionFilter, dateRange])
|
||
|
||
// When initialAccountFilter changes (drill-down from another report), apply it
|
||
useEffect(() => {
|
||
if (initialAccountFilter) {
|
||
setAccountFrom(initialAccountFilter)
|
||
setAccountTo(initialAccountFilter)
|
||
fetchData(initialAccountFilter, initialAccountFilter)
|
||
} else {
|
||
fetchData()
|
||
}
|
||
}, [periodId, initialAccountFilter, dimensionFilter, dateRange])
|
||
|
||
if (loading) {
|
||
return (
|
||
<Card>
|
||
<CardContent className="p-8 text-center text-muted-foreground">
|
||
Laddar huvudbok...
|
||
</CardContent>
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
if (error) {
|
||
return (
|
||
<Card>
|
||
<CardContent className="p-8 text-center text-destructive">
|
||
<AlertCircle className="h-6 w-6 mx-auto mb-2" />
|
||
{error}
|
||
</CardContent>
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
if (!data || data.accounts.length === 0) {
|
||
return (
|
||
<Card>
|
||
<CardContent className="p-8 text-center text-muted-foreground">
|
||
Inga bokförda verifikationer i denna period.
|
||
</CardContent>
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<ReportExportMenu items={[{ format: 'xlsx', href: `/api/reports/general-ledger/xlsx?${reportQuery(periodId, dateRange, dimensionFilter)}` }]} />
|
||
{/* Account range filter */}
|
||
<Card>
|
||
<CardContent className="pt-6">
|
||
<div className="flex flex-wrap items-end gap-4">
|
||
<div>
|
||
<Label>Konto från</Label>
|
||
<input
|
||
type="text"
|
||
value={accountFrom}
|
||
onChange={(e) => setAccountFrom(e.target.value)}
|
||
placeholder="t.ex. 1510"
|
||
className="w-full mt-1 rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<Label>Konto till</Label>
|
||
<input
|
||
type="text"
|
||
value={accountTo}
|
||
onChange={(e) => setAccountTo(e.target.value)}
|
||
placeholder="t.ex. 1519"
|
||
className="w-full mt-1 rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||
/>
|
||
</div>
|
||
<Button onClick={() => fetchData()} variant="outline">
|
||
Filtrera
|
||
</Button>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{data.period.start && (
|
||
<p className="text-sm text-muted-foreground">
|
||
Period: {data.period.start}: {data.period.end} | {data.accounts.length} konton
|
||
</p>
|
||
)}
|
||
|
||
{data.accounts.map((account) => (
|
||
<Card key={account.account_number}>
|
||
<CardHeader className="pb-2">
|
||
<div className="flex items-center justify-between">
|
||
<CardTitle className="text-base">
|
||
<AccountNumber number={account.account_number} name={account.account_name} showName />
|
||
</CardTitle>
|
||
<span className="text-sm text-muted-foreground">
|
||
IB: {formatAmount(account.opening_balance)} kr
|
||
</span>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="overflow-x-auto -mx-2 px-2"><table className="w-full text-sm min-w-[500px]">
|
||
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
|
||
<tr className="border-b text-left">
|
||
<th className="py-2 w-16">Ver.nr</th>
|
||
<th className="py-2 w-24">Datum</th>
|
||
<th className="py-2">Beskrivning</th>
|
||
<th className="py-2 w-24 text-right">Debet</th>
|
||
<th className="py-2 w-24 text-right">Kredit</th>
|
||
<th className="py-2 w-28 text-right">Saldo</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{account.lines.map((line, i) => (
|
||
<tr key={i} className="border-b last:border-0">
|
||
<td className="py-1.5 font-mono text-xs">
|
||
<Link
|
||
href={`/bookkeeping/${line.journal_entry_id}`}
|
||
className="text-foreground underline underline-offset-4 decoration-muted-foreground/40 hover:decoration-foreground transition-colors"
|
||
>
|
||
{formatVoucher(line)}
|
||
</Link>
|
||
</td>
|
||
<td className="py-1.5">{formatDate(line.date)}</td>
|
||
<td className="py-1.5 max-w-[240px]">
|
||
<span className="truncate block">{line.description}</span>
|
||
{line.dimensions && Object.keys(line.dimensions).length > 0 && (
|
||
<span className="text-[11px] text-muted-foreground tabular-nums">
|
||
{Object.entries(line.dimensions)
|
||
.sort(([a], [b]) => Number(a) - Number(b))
|
||
.map(([, code]) => code)
|
||
.join(' · ')}
|
||
</span>
|
||
)}
|
||
</td>
|
||
<td className="py-1.5 text-right tabular-nums">
|
||
{line.debit > 0 ? formatAmount(line.debit) : ''}
|
||
</td>
|
||
<td className="py-1.5 text-right tabular-nums">
|
||
{line.credit > 0 ? formatAmount(line.credit) : ''}
|
||
</td>
|
||
<td className="py-1.5 text-right font-mono tabular-nums">{formatAmount(line.balance)}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
<tfoot>
|
||
<tr className="font-semibold border-t-2">
|
||
<td colSpan={3} className="py-2">Summa / Utgående balans</td>
|
||
<td className="py-2 text-right">{formatAmount(account.total_debit)}</td>
|
||
<td className="py-2 text-right">{formatAmount(account.total_credit)}</td>
|
||
<td className="py-2 text-right font-mono">{formatAmount(account.closing_balance)}</td>
|
||
</tr>
|
||
</tfoot>
|
||
</table></div>
|
||
</CardContent>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// --- Journal Register (Grundbok) ---
|
||
|
||
interface JournalRegisterData {
|
||
entries: {
|
||
voucher_series: string
|
||
voucher_number: number
|
||
date: string
|
||
description: string
|
||
source_type: string
|
||
status: string
|
||
lines: {
|
||
account_number: string
|
||
account_name: string
|
||
debit: number
|
||
credit: number
|
||
}[]
|
||
total_debit: number
|
||
total_credit: number
|
||
}[]
|
||
total_entries: number
|
||
total_debit: number
|
||
total_credit: number
|
||
period: { start: string; end: string }
|
||
}
|
||
|
||
export function JournalRegisterView({ periodId }: { periodId: string }) {
|
||
const [data, setData] = useState<JournalRegisterData | null>(null)
|
||
const [loading, setLoading] = useState(false)
|
||
const [error, setError] = useState<string | null>(null)
|
||
const [expandedEntries, setExpandedEntries] = useState<Set<number>>(new Set())
|
||
|
||
const fetchData = async () => {
|
||
setLoading(true)
|
||
setError(null)
|
||
setExpandedEntries(new Set())
|
||
try {
|
||
const res = await fetch(`/api/reports/journal-register?period_id=${periodId}`)
|
||
const result = await res.json()
|
||
if (result.error) {
|
||
setError(result.error)
|
||
} else {
|
||
setData(result.data)
|
||
}
|
||
} catch {
|
||
setError('Kunde inte hämta grundbok')
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
useEffect(() => {
|
||
if (periodId) fetchData()
|
||
}, [periodId])
|
||
|
||
const toggleEntry = (index: number) => {
|
||
setExpandedEntries((prev) => {
|
||
const next = new Set(prev)
|
||
if (next.has(index)) {
|
||
next.delete(index)
|
||
} else {
|
||
next.add(index)
|
||
}
|
||
return next
|
||
})
|
||
}
|
||
|
||
if (loading) {
|
||
return (
|
||
<Card>
|
||
<CardContent className="p-8 text-center text-muted-foreground">
|
||
Laddar grundbok...
|
||
</CardContent>
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
if (error) {
|
||
return (
|
||
<Card>
|
||
<CardContent className="p-8 text-center text-destructive">
|
||
<AlertCircle className="h-6 w-6 mx-auto mb-2" />
|
||
{error}
|
||
</CardContent>
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
if (!data || data.entries.length === 0) {
|
||
return (
|
||
<Card>
|
||
<CardContent className="p-8 text-center text-muted-foreground">
|
||
Inga bokförda verifikationer i denna period.
|
||
</CardContent>
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<ReportExportMenu items={[{ format: 'xlsx', href: `/api/reports/journal-register/xlsx?period_id=${periodId}` }]} />
|
||
{data.period.start && (
|
||
<p className="text-sm text-muted-foreground">
|
||
Period: {data.period.start}: {data.period.end} | {data.total_entries} verifikationer
|
||
</p>
|
||
)}
|
||
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>Grundbok (registreringsordning)</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="overflow-x-auto -mx-2 px-2"><table className="w-full text-sm min-w-[500px]">
|
||
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
|
||
<tr className="border-b text-left">
|
||
<th className="py-2 w-8"></th>
|
||
<th className="py-2 w-16">Ver.nr</th>
|
||
<th className="py-2 w-24">Datum</th>
|
||
<th className="py-2">Beskrivning</th>
|
||
<th className="py-2 w-24">Typ</th>
|
||
<th className="py-2 w-24 text-right">Debet</th>
|
||
<th className="py-2 w-24 text-right">Kredit</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{data.entries.map((entry, index) => {
|
||
const isExpanded = expandedEntries.has(index)
|
||
const isReversed = entry.status === 'reversed'
|
||
|
||
return (
|
||
<React.Fragment key={index}>
|
||
<tr
|
||
className={`border-b cursor-pointer hover:bg-muted/50 ${isReversed ? 'line-through opacity-60' : ''}`}
|
||
onClick={() => toggleEntry(index)}
|
||
>
|
||
<td className="py-2">
|
||
{isExpanded ? (
|
||
<ChevronDown className="h-4 w-4" />
|
||
) : (
|
||
<ChevronRight className="h-4 w-4" />
|
||
)}
|
||
</td>
|
||
<td className="py-2 font-mono text-xs">
|
||
{formatVoucher(entry)}
|
||
</td>
|
||
<td className="py-2">{formatDate(entry.date)}</td>
|
||
<td className="py-2">
|
||
{entry.description}
|
||
{isReversed && (
|
||
<Badge variant="outline" className="ml-2 text-xs">Makulerad</Badge>
|
||
)}
|
||
</td>
|
||
<td className="py-2 text-xs text-muted-foreground">{entry.source_type}</td>
|
||
<td className="py-2 text-right tabular-nums">{formatAmount(entry.total_debit)}</td>
|
||
<td className="py-2 text-right tabular-nums">{formatAmount(entry.total_credit)}</td>
|
||
</tr>
|
||
{isExpanded && entry.lines.map((line, lineIndex) => (
|
||
<tr key={`${index}-${lineIndex}`} className="bg-muted/30 border-b last:border-0">
|
||
<td></td>
|
||
<td></td>
|
||
<td className="py-1"><AccountNumber number={line.account_number} name={line.account_name} size="sm" /></td>
|
||
<td className="py-1 text-muted-foreground">{line.account_name}</td>
|
||
<td></td>
|
||
<td className="py-1 text-right">
|
||
{line.debit > 0 ? formatAmount(line.debit) : ''}
|
||
</td>
|
||
<td className="py-1 text-right">
|
||
{line.credit > 0 ? formatAmount(line.credit) : ''}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</React.Fragment>
|
||
)
|
||
})}
|
||
</tbody>
|
||
<tfoot>
|
||
<tr className="font-semibold border-t-2">
|
||
<td colSpan={5} className="py-2">Summa</td>
|
||
<td className="py-2 text-right">{formatAmount(data.total_debit)}</td>
|
||
<td className="py-2 text-right">{formatAmount(data.total_credit)}</td>
|
||
</tr>
|
||
</tfoot>
|
||
</table></div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// --- AR Ledger (Kundreskontra) ---
|
||
|
||
interface ARLedgerData {
|
||
ledger: {
|
||
entries: {
|
||
customer_id: string
|
||
customer_name: string
|
||
invoices: {
|
||
invoice_id: string
|
||
invoice_number: string
|
||
invoice_date: string
|
||
due_date: string
|
||
total: number
|
||
paid_amount: number
|
||
outstanding: number
|
||
outstanding_sek: number | null
|
||
days_overdue: number
|
||
currency: string
|
||
}[]
|
||
current: number
|
||
days_1_30: number
|
||
days_31_60: number
|
||
days_61_90: number
|
||
days_90_plus: number
|
||
total_outstanding: number
|
||
}[]
|
||
total_outstanding: number
|
||
total_current: number
|
||
total_overdue: number
|
||
unpaid_count: number
|
||
unconverted_fx_count: number
|
||
}
|
||
reconciliation: {
|
||
ar_ledger_total: number
|
||
account_1510_balance: number
|
||
difference: number
|
||
is_reconciled: boolean
|
||
unconverted_fx_count: number
|
||
} | null
|
||
}
|
||
|
||
// Inner expansion row component for AR ledger.
|
||
// Fetches per-customer invoices (with journal_entry_id) and renders each as a
|
||
// link to /bookkeeping/[id] when posted, /invoices/[id] when still draft.
|
||
function ARCustomerInvoiceRows({
|
||
customerId,
|
||
invoices,
|
||
}: {
|
||
customerId: string
|
||
invoices: {
|
||
invoice_id: string
|
||
invoice_number: string
|
||
invoice_date: string
|
||
due_date: string
|
||
total: number
|
||
paid_amount: number
|
||
outstanding: number
|
||
outstanding_sek: number | null
|
||
days_overdue: number
|
||
currency: string
|
||
}[]
|
||
}) {
|
||
// ARCustomerInvoiceRows is mounted lazily: only when a customer is
|
||
// expanded, so initial state matches "still loading" and resets on
|
||
// unmount. No synchronous setState in the effect is needed.
|
||
const [enriched, setEnriched] = useState<Record<string, { journal_entry_id: string; voucher_series: string; voucher_number: number } | undefined>>({})
|
||
const [loaded, setLoaded] = useState(false)
|
||
|
||
useEffect(() => {
|
||
let cancelled = false
|
||
fetch(`/api/reports/ar-ledger/customer/${encodeURIComponent(customerId)}/invoices`)
|
||
.then((r) => r.json())
|
||
.then((json) => {
|
||
if (cancelled) return
|
||
const map: typeof enriched = {}
|
||
for (const line of json.data?.lines || []) {
|
||
if (line.invoice_id && line.journal_entry_id) {
|
||
map[line.invoice_id] = {
|
||
journal_entry_id: line.journal_entry_id,
|
||
voucher_series: line.voucher_series,
|
||
voucher_number: line.voucher_number,
|
||
}
|
||
}
|
||
}
|
||
setEnriched(map)
|
||
})
|
||
.catch(() => { /* fail silently; rows still render without verifikat link */ })
|
||
.finally(() => { if (!cancelled) setLoaded(true) })
|
||
return () => { cancelled = true }
|
||
}, [customerId])
|
||
const loading = !loaded
|
||
|
||
return (
|
||
<>
|
||
{invoices.map((inv) => {
|
||
const entry = enriched[inv.invoice_id]
|
||
const targetHref = entry?.journal_entry_id
|
||
? `/bookkeeping/${entry.journal_entry_id}`
|
||
: `/invoices/${inv.invoice_id}`
|
||
return (
|
||
<tr key={inv.invoice_id} className="bg-muted/30 border-b last:border-0">
|
||
<td></td>
|
||
<td className="py-1 text-xs" colSpan={2}>
|
||
<Link href={targetHref} className="font-mono hover:underline underline-offset-4">
|
||
{inv.invoice_number || '(utkast)'}
|
||
</Link>
|
||
{entry && (
|
||
<span className="ml-2 text-muted-foreground font-mono">
|
||
{formatVoucher(entry)}
|
||
</span>
|
||
)}
|
||
<span className="text-muted-foreground ml-2 tabular-nums">{formatDate(inv.invoice_date)}</span>
|
||
<span className="text-muted-foreground ml-2 tabular-nums">förfaller {formatDate(inv.due_date)}</span>
|
||
</td>
|
||
<td className="py-1 text-right text-xs text-muted-foreground" colSpan={2}>
|
||
{inv.days_overdue > 0 ? `${inv.days_overdue} dagar förfallen` : 'Ej förfallen'}
|
||
</td>
|
||
<td className="py-1 text-right text-xs text-muted-foreground">
|
||
{inv.paid_amount > 0 ? `Betalt: ${formatAmount(inv.paid_amount)} ${inv.currency}` : ''}
|
||
</td>
|
||
<td></td>
|
||
<td className="py-1 text-right text-xs font-medium tabular-nums">
|
||
{formatAmount(inv.outstanding)} {inv.currency}
|
||
</td>
|
||
</tr>
|
||
)
|
||
})}
|
||
{loading && (
|
||
<tr className="bg-muted/30">
|
||
<td></td>
|
||
<td colSpan={7} className="py-1 text-[10px] text-muted-foreground">Letar verifikat…</td>
|
||
</tr>
|
||
)}
|
||
</>
|
||
)
|
||
}
|
||
|
||
export function ARLedgerView({ periodId }: { periodId: string }) {
|
||
const [data, setData] = useState<ARLedgerData | null>(null)
|
||
const [loading, setLoading] = useState(false)
|
||
const [error, setError] = useState<string | null>(null)
|
||
const [expandedCustomers, setExpandedCustomers] = useState<Set<string>>(new Set())
|
||
const [asOfDate, setAsOfDate] = useState(localIsoDate)
|
||
|
||
const fetchData = async () => {
|
||
setLoading(true)
|
||
setError(null)
|
||
try {
|
||
const res = await fetch(`/api/reports/ar-ledger?period_id=${periodId}&as_of_date=${asOfDate}`)
|
||
const result = await res.json()
|
||
if (result.error) {
|
||
setError(result.error)
|
||
} else {
|
||
setData(result.data)
|
||
}
|
||
} catch {
|
||
setError('Kunde inte hämta kundreskontra')
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
useEffect(() => {
|
||
if (periodId) fetchData()
|
||
}, [periodId, asOfDate])
|
||
|
||
const toggleCustomer = (customerId: string) => {
|
||
setExpandedCustomers((prev) => {
|
||
const next = new Set(prev)
|
||
if (next.has(customerId)) {
|
||
next.delete(customerId)
|
||
} else {
|
||
next.add(customerId)
|
||
}
|
||
return next
|
||
})
|
||
}
|
||
|
||
if (loading) {
|
||
return (
|
||
<Card>
|
||
<CardContent className="p-8 text-center text-muted-foreground">
|
||
Laddar kundreskontra...
|
||
</CardContent>
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
if (error) {
|
||
return (
|
||
<Card>
|
||
<CardContent className="p-8 text-center text-destructive">
|
||
<AlertCircle className="h-6 w-6 mx-auto mb-2" />
|
||
{error}
|
||
</CardContent>
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
if (!data || !data.ledger) {
|
||
return (
|
||
<Card>
|
||
<CardContent className="p-8 text-center text-muted-foreground">
|
||
Ingen data tillgänglig.
|
||
</CardContent>
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
const { ledger, reconciliation } = data
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<ReskontraToolbar
|
||
asOfDate={asOfDate}
|
||
onAsOfDateChange={setAsOfDate}
|
||
inputId="ar-ledger-as-of"
|
||
exportBase="/api/reports/ar-ledger"
|
||
/>
|
||
{/* Summary cards */}
|
||
<div className="grid md:grid-cols-3 gap-4">
|
||
<Card>
|
||
<CardHeader className="pb-2">
|
||
<CardTitle className="text-sm text-muted-foreground">Totalt utestående</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<p className="font-display text-2xl tabular-nums">{formatAmount(ledger.total_outstanding)} kr</p>
|
||
<p className="text-xs text-muted-foreground">{ledger.unpaid_count} fakturor</p>
|
||
{ledger.unconverted_fx_count > 0 && (
|
||
<p className="mt-1 text-xs text-muted-foreground">
|
||
{ledger.unconverted_fx_count} faktura i utländsk valuta utan växelkurs är inte med i totalen.
|
||
</p>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
<Card>
|
||
<CardHeader className="pb-2">
|
||
<CardTitle className="text-sm text-muted-foreground">Ej förfallet</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<p className="font-display text-2xl tabular-nums text-success">{formatAmount(ledger.total_current)} kr</p>
|
||
</CardContent>
|
||
</Card>
|
||
<Card>
|
||
<CardHeader className="pb-2">
|
||
<CardTitle className="text-sm text-muted-foreground">Förfallet</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<p className="font-display text-2xl tabular-nums text-destructive">{formatAmount(ledger.total_overdue)} kr</p>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
|
||
{/* Aging table with expandable invoice details */}
|
||
{ledger.entries.length > 0 && (
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle>Ålderfördelning per kund</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="overflow-x-auto -mx-2 px-2"><table className="w-full text-sm min-w-[500px]">
|
||
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
|
||
<tr className="border-b text-left">
|
||
<th className="py-2 w-8"></th>
|
||
<th className="py-2">Kund</th>
|
||
<th className="py-2 text-right">Ej förfallet</th>
|
||
<th className="py-2 text-right">1-30 dagar</th>
|
||
<th className="py-2 text-right">31-60 dagar</th>
|
||
<th className="py-2 text-right">61-90 dagar</th>
|
||
<th className="py-2 text-right">90+ dagar</th>
|
||
<th className="py-2 text-right font-semibold">Totalt</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{ledger.entries.map((entry) => {
|
||
const isExpanded = expandedCustomers.has(entry.customer_id)
|
||
return (
|
||
<React.Fragment key={entry.customer_id}>
|
||
<tr
|
||
className="border-b cursor-pointer hover:bg-muted/50"
|
||
onClick={() => toggleCustomer(entry.customer_id)}
|
||
>
|
||
<td className="py-2">
|
||
{isExpanded ? (
|
||
<ChevronDown className="h-4 w-4" />
|
||
) : (
|
||
<ChevronRight className="h-4 w-4" />
|
||
)}
|
||
</td>
|
||
<td className="py-2">{entry.customer_name}</td>
|
||
<td className="py-2 text-right">{entry.current > 0 ? formatAmount(entry.current) : ''}</td>
|
||
<td className="py-2 text-right">{entry.days_1_30 > 0 ? formatAmount(entry.days_1_30) : ''}</td>
|
||
<td className="py-2 text-right">{entry.days_31_60 > 0 ? formatAmount(entry.days_31_60) : ''}</td>
|
||
<td className="py-2 text-right">{entry.days_61_90 > 0 ? formatAmount(entry.days_61_90) : ''}</td>
|
||
<td className="py-2 text-right text-destructive">{entry.days_90_plus > 0 ? formatAmount(entry.days_90_plus) : ''}</td>
|
||
<td className="py-2 text-right font-semibold">{formatAmount(entry.total_outstanding)}</td>
|
||
</tr>
|
||
{isExpanded && (
|
||
<ARCustomerInvoiceRows
|
||
customerId={entry.customer_id}
|
||
invoices={entry.invoices}
|
||
/>
|
||
)}
|
||
</React.Fragment>
|
||
)
|
||
})}
|
||
</tbody>
|
||
<tfoot>
|
||
<tr className="font-semibold border-t-2">
|
||
<td className="py-2"></td>
|
||
<td className="py-2">Summa</td>
|
||
<td className="py-2 text-right">{formatAmount(ledger.entries.reduce((s, e) => s + e.current, 0))}</td>
|
||
<td className="py-2 text-right">{formatAmount(ledger.entries.reduce((s, e) => s + e.days_1_30, 0))}</td>
|
||
<td className="py-2 text-right">{formatAmount(ledger.entries.reduce((s, e) => s + e.days_31_60, 0))}</td>
|
||
<td className="py-2 text-right">{formatAmount(ledger.entries.reduce((s, e) => s + e.days_61_90, 0))}</td>
|
||
<td className="py-2 text-right text-destructive">{formatAmount(ledger.entries.reduce((s, e) => s + e.days_90_plus, 0))}</td>
|
||
<td className="py-2 text-right">{formatAmount(ledger.total_outstanding)}</td>
|
||
</tr>
|
||
</tfoot>
|
||
</table></div>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{/* Reconciliation */}
|
||
{reconciliation && (
|
||
<Card className="border-2">
|
||
<CardHeader>
|
||
<CardTitle>Avstämning mot <AccountNumber number="1510" /></CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="space-y-2 text-sm">
|
||
<div className="flex justify-between">
|
||
<span>Kundreskontra (summa utestående)</span>
|
||
<span className="font-mono">{formatAmount(reconciliation.ar_ledger_total)} kr</span>
|
||
</div>
|
||
<div className="flex justify-between">
|
||
<span>Kundfordringar (<AccountNumber number="1510" /> + <AccountNumber number="1513" />) saldo</span>
|
||
<span className="font-mono">{formatAmount(reconciliation.account_1510_balance)} kr</span>
|
||
</div>
|
||
<div className="flex justify-between pt-2 border-t font-semibold">
|
||
<span>Differens</span>
|
||
<span className={reconciliation.is_reconciled ? 'text-success' : 'text-destructive'}>
|
||
{formatAmount(reconciliation.difference)} kr
|
||
</span>
|
||
</div>
|
||
<div className="pt-2 space-y-2">
|
||
{reconciliation.is_reconciled ? (
|
||
<Badge variant="success">Avstämd</Badge>
|
||
) : (
|
||
<Badge variant="destructive">Ej avstämd - kontrollera bokföring</Badge>
|
||
)}
|
||
{reconciliation.unconverted_fx_count > 0 && (
|
||
<p className="text-xs text-muted-foreground">
|
||
{reconciliation.unconverted_fx_count} kundfaktura i utländsk valuta saknar växelkurs: differensen kan bero på saknade kursuppgifter snarare än felbokning.
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// --- Resultat per projekt/kostnadsställe (dimension P&L matrix) ---
|
||
|
||
export function DimensionPnlView({ periodId, dateRange }: { periodId: string; dateRange: DateRangeValue }) {
|
||
// Loading is DERIVED (result key ≠ current query string) instead of a
|
||
// setState at effect start: keeps react-hooks/set-state-in-effect clean
|
||
// and is race-safe when the pivot/date changes mid-flight.
|
||
const [result, setResult] = useState<{
|
||
qs: string
|
||
data: DimensionPnlReport | null
|
||
error: string | null
|
||
} | null>(null)
|
||
const [dims, setDims] = useState<{ sie_dim_no: number; name: string }[]>([])
|
||
const [dimNo, setDimNo] = useState('6')
|
||
const reportQs = `${reportQuery(periodId, dateRange)}&dim_no=${encodeURIComponent(dimNo)}`
|
||
|
||
// Registered dimensions for the pivot picker (best-effort; the report
|
||
// defaults to projekt if the registry read fails).
|
||
useEffect(() => {
|
||
fetch('/api/dimensions')
|
||
.then((res) => res.json())
|
||
.then((payload) => {
|
||
if (Array.isArray(payload.data)) {
|
||
setDims(payload.data.map((d: { sie_dim_no: number; name: string }) => ({ sie_dim_no: d.sie_dim_no, name: d.name })))
|
||
}
|
||
})
|
||
.catch(() => {})
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
let cancelled = false
|
||
fetch(`/api/reports/dimension-pnl?${reportQs}`)
|
||
.then((res) => res.json())
|
||
.then((payload) => {
|
||
if (cancelled) return
|
||
if (payload.error) {
|
||
setResult({
|
||
qs: reportQs,
|
||
data: null,
|
||
error: typeof payload.error === 'string' ? payload.error : 'Kunde inte hämta rapporten',
|
||
})
|
||
} else {
|
||
setResult({ qs: reportQs, data: payload.data, error: null })
|
||
}
|
||
})
|
||
.catch(() => {
|
||
if (!cancelled) setResult({ qs: reportQs, data: null, error: 'Kunde inte hämta rapporten' })
|
||
})
|
||
return () => {
|
||
cancelled = true
|
||
}
|
||
}, [reportQs])
|
||
|
||
const loading = result?.qs !== reportQs
|
||
const error = loading ? null : result?.error ?? null
|
||
const data = loading ? null : result?.data ?? null
|
||
|
||
const pivotPicker = dims.length > 1 && (
|
||
<div className="flex flex-wrap items-center gap-1.5">
|
||
{dims.map((d) => {
|
||
const active = String(d.sie_dim_no) === dimNo
|
||
return (
|
||
<button
|
||
key={d.sie_dim_no}
|
||
type="button"
|
||
onClick={() => setDimNo(String(d.sie_dim_no))}
|
||
className={
|
||
active
|
||
? 'px-3 py-1.5 text-xs rounded-md border transition-colors duration-150 bg-secondary border-border text-foreground'
|
||
: 'px-3 py-1.5 text-xs rounded-md border transition-colors duration-150 bg-transparent border-border text-muted-foreground hover:bg-secondary/60 hover:text-foreground'
|
||
}
|
||
>
|
||
{d.name}
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
)
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="space-y-4">
|
||
{pivotPicker}
|
||
<Card>
|
||
<CardContent className="p-8 text-center text-muted-foreground">
|
||
Laddar rapport...
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (error) {
|
||
return (
|
||
<div className="space-y-4">
|
||
{pivotPicker}
|
||
<Card>
|
||
<CardContent className="p-8 text-center text-destructive">
|
||
<AlertCircle className="h-6 w-6 mx-auto mb-2" />
|
||
{error}
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (!data || data.groups.length === 0) {
|
||
return (
|
||
<div className="space-y-4">
|
||
{pivotPicker}
|
||
<Card>
|
||
<CardContent className="p-8 text-center text-muted-foreground">
|
||
Inga taggade intäkter eller kostnader i denna period.
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const columnLabel = (c: DimensionPnlReport['columns'][number]) =>
|
||
c.code === null ? '(Utan dimension)' : c.code
|
||
|
||
const colCount = 2 + data.columns.length + 1
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||
{pivotPicker || <span />}
|
||
<ReportExportMenu items={[{ format: 'xlsx', href: `/api/reports/dimension-pnl/xlsx?${reportQs}` }]} />
|
||
</div>
|
||
|
||
<Card>
|
||
<CardContent className="p-0">
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-sm">
|
||
<thead>
|
||
<tr className="border-b text-[11px] uppercase tracking-wider text-muted-foreground">
|
||
<th className="text-left font-medium px-4 py-2 w-20">Konto</th>
|
||
<th className="text-left font-medium px-4 py-2">Kontonamn</th>
|
||
{data.columns.map((c, i) => (
|
||
<th key={i} className="text-right font-medium px-4 py-2 w-32 tabular-nums" title={c.name ?? undefined}>
|
||
{columnLabel(c)}
|
||
</th>
|
||
))}
|
||
<th className="text-right font-medium px-4 py-2 w-32 tabular-nums">Totalt</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{data.groups.map((group) => (
|
||
<React.Fragment key={group.class}>
|
||
<tr className="bg-muted/30">
|
||
<td colSpan={colCount} className="px-4 py-2 text-[12px] font-semibold text-muted-foreground">
|
||
{group.class_label}
|
||
</td>
|
||
</tr>
|
||
{group.rows.map((row) => (
|
||
<tr key={row.account_number} className="border-b last:border-0">
|
||
<td className="px-4 py-1.5">
|
||
<AccountNumber number={row.account_number} name={row.account_name} />
|
||
</td>
|
||
<td className="px-4 py-1.5">{row.account_name}</td>
|
||
{row.values.map((v, i) => (
|
||
<td key={i} className="px-4 py-1.5 text-right tabular-nums">
|
||
{Math.abs(v) >= 0.005 ? formatAmount(v) : ''}
|
||
</td>
|
||
))}
|
||
<td className="px-4 py-1.5 text-right tabular-nums font-medium">{formatAmount(row.total)}</td>
|
||
</tr>
|
||
))}
|
||
<tr className="border-b font-medium">
|
||
<td colSpan={2} className="px-4 py-1.5 text-right text-muted-foreground">
|
||
Summa
|
||
</td>
|
||
{group.subtotals.map((v, i) => (
|
||
<td key={i} className="px-4 py-1.5 text-right tabular-nums">{formatAmount(v)}</td>
|
||
))}
|
||
<td className="px-4 py-1.5 text-right tabular-nums">{formatAmount(group.subtotal_total)}</td>
|
||
</tr>
|
||
</React.Fragment>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card className="border-2">
|
||
<CardContent className="py-4">
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-sm">
|
||
<tbody>
|
||
<tr>
|
||
<td className="px-4 font-bold text-lg">Beräknat resultat</td>
|
||
<td className="px-4" />
|
||
{data.net_per_column.map((v, i) => (
|
||
<td key={i} className={`px-4 text-right tabular-nums font-semibold w-32 ${v >= 0 ? 'text-success' : 'text-destructive'}`}>
|
||
{formatAmount(v)}
|
||
</td>
|
||
))}
|
||
<td className={`px-4 text-right tabular-nums font-bold text-lg w-32 ${data.net_total >= 0 ? 'text-success' : 'text-destructive'}`}>
|
||
{formatAmount(data.net_total)} kr
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
)
|
||
}
|