Files
accounted/components/reports/FocusedReport.tsx
T
Jakob Wennberg 4bc2093e51 polish(vat): title-row Exportera, fused period chip, chip classification, calm SKV status (#1181)
Founder feedback on the live momsdeklaration (2026-07-25):

- The black Exportera now sits on the title row like every other page:
  standalone report pages render their own PageHeader (FocusedReport
  passes the title and skips its own), and the period chips get their
  own row below.
- Year + quarter/month fuse into ONE chip ("Kvartal 3 2026") listing
  five years reverse-chronologically with month-span annotations;
  cadence stays behind the Period chip. Yearly keeps FyPicker, which
  is already a fused rakenskapsar chip.
- The RC-basis worklist's Leverantorstyp/Typ av inkop selects (old
  boxy style with labels) become ContextPicker chips, in the toolbar
  and in each expanded row.
- SkatteverketPanel connection status per the locked conventions: the
  contradictory "Ansluten" + "Session utgangen" badge cluster becomes
  muted "Ansluten" text for the normal state and one attn sentence
  with an embedded "Fornya med BankID" action for the expired session.

Verified via sandbox screenshots (Playwright against dev).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 11:59:06 +02:00

265 lines
10 KiB
TypeScript

'use client'
import { Suspense, useState } from 'react'
import dynamic from 'next/dynamic'
import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { ChevronLeft } from 'lucide-react'
import { PageHeader } from '@/components/ui/page-header'
import { EmptyState } from '@/components/ui/empty-state'
import { Card, CardContent } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { useCompany } from '@/contexts/CompanyContext'
import { FyPicker } from '@/components/common/FyPicker'
import { ReportDateRange, type DateRangeValue } from '@/components/common/ReportDateRange'
import { DimensionFilter, type DimensionFilterValue } from '@/components/reports/DimensionFilter'
import { DATE_RANGE_SLUGS, DIMENSION_FILTER_SLUGS, getReport } from '@/lib/reports/catalog'
import type { FiscalPeriod } from '@/types'
function ReportViewLoading() {
return (
<Card>
<CardContent className="space-y-4 p-6">
<Skeleton className="h-5 w-32" />
<Skeleton className="h-64" />
</CardContent>
</Card>
)
}
const TrialBalanceView = dynamic(() => import('./lazy-views/TrialBalanceView'), { loading: ReportViewLoading })
const IncomeStatementView = dynamic(() => import('./lazy-views/IncomeStatementView'), { loading: ReportViewLoading })
const BalanceSheetView = dynamic(() => import('./lazy-views/BalanceSheetView'), { loading: ReportViewLoading })
const ResultatrapportView = dynamic(() => import('./lazy-views/ResultatrapportView'), { loading: ReportViewLoading })
const BalansrapportView = dynamic(() => import('./lazy-views/BalansrapportView'), { loading: ReportViewLoading })
const VatDeclarationView = dynamic(() => import('./lazy-views/VatDeclarationView'), { loading: ReportViewLoading })
const SupplierLedgerView = dynamic(() => import('./lazy-views/SupplierLedgerView'), { loading: ReportViewLoading })
const GeneralLedgerView = dynamic(() => import('./lazy-views/GeneralLedgerView'), { loading: ReportViewLoading })
const JournalRegisterView = dynamic(() => import('./lazy-views/JournalRegisterView'), { loading: ReportViewLoading })
const ARLedgerView = dynamic(() => import('./lazy-views/ARLedgerView'), { loading: ReportViewLoading })
const DimensionPnlView = dynamic(() => import('./lazy-views/DimensionPnlView'), { loading: ReportViewLoading })
const NEDeclarationView = dynamic(() =>
import('./NEDeclarationView').then((module) => ({ default: module.NEDeclarationView })),
{ loading: ReportViewLoading },
)
const PeriodiskSammanstallningView = dynamic(() =>
import('./PeriodiskSammanstallningView').then((module) => ({ default: module.PeriodiskSammanstallningView })),
{ loading: ReportViewLoading },
)
const INK2DeclarationView = dynamic(() =>
import('./INK2DeclarationView').then((module) => ({ default: module.INK2DeclarationView })),
{ loading: ReportViewLoading },
)
const BankReconciliationView = dynamic(() =>
import('./BankReconciliationView').then((module) => ({ default: module.BankReconciliationView })),
{ loading: ReportViewLoading },
)
/**
* The focused single-report experience at /reports/[slug]. Carries one report:
* a back link to the library, the shared fiscal-year selector (restored from
* localStorage so it matches the year picked on the landing), the report's
* optional date-range control, and the report body. Drilling into an account
* navigates to /reports/huvudbok?account=…: drill state lives in the URL.
*/
function FocusedReportInner({
slug,
initialPeriods,
initialCompanyId,
}: {
slug: string
initialPeriods: FiscalPeriod[]
initialCompanyId: string | null
}) {
const router = useRouter()
const searchParams = useSearchParams()
const { company } = useCompany()
const t = useTranslations('reports')
const [selectedPeriod, setSelectedPeriod] = useState('')
const [selectedPeriodBounds, setSelectedPeriodBounds] = useState<{ start: string; end: string } | null>(null)
const [dateRange, setDateRange] = useState<DateRangeValue>({})
const [dimensionFilter, setDimensionFilter] = useState<DimensionFilterValue | null>(null)
const [isReady, setIsReady] = useState(false)
const report = getReport(slug)
// Calendar (VAT family) and param-less reports don't need a fiscal period.
const isPeriodless = report?.params === 'calendar' || report?.params === 'none'
// Nav-promoted pages (Momsdeklaration) drop the library chrome: no back
// link, no shell fiscal-year selector — the view owns its period controls.
const isStandalone = !!report?.standalone
const reportName = report ? t(report.labelKey) : slug
const accountFilter = searchParams.get('account')
const isEnskildFirma = company?.entity_type === 'enskild_firma'
const isAktiebolag = company?.entity_type === 'aktiebolag'
// Drilling from a report into the general ledger is a route change, so the
// account lands in the URL and the browser back button returns to the report.
const navigateToAccount = (accountNumber: string) => {
router.push(`/reports/huvudbok?account=${encodeURIComponent(accountNumber)}`)
}
return (
<div className="space-y-8">
{!isStandalone && (
<Link
href="/reports"
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ChevronLeft className="h-4 w-4" />
{t('back_to_library')}
</Link>
)}
{/* Standalone pages (Momsdeklaration) render their own PageHeader so
the primary action can live on the title row; the view receives the
title via pageTitle instead. */}
{!isStandalone && (
<PageHeader
title={reportName}
action={
<FyPicker
value={selectedPeriod || null}
onChange={(id, period) => {
setSelectedPeriod(id || '')
setSelectedPeriodBounds(
period ? { start: period.period_start, end: period.period_end } : null,
)
setDateRange({})
}}
includeAllOption={false}
hideFuturePeriods
onReady={() => setIsReady(true)}
initialPeriods={initialPeriods}
initialCompanyId={initialCompanyId}
/>
}
/>
)}
{DATE_RANGE_SLUGS.has(slug) && selectedPeriodBounds && (
<ReportDateRange
periodStart={selectedPeriodBounds.start}
periodEnd={selectedPeriodBounds.end}
value={dateRange}
onChange={setDateRange}
/>
)}
{DIMENSION_FILTER_SLUGS.has(slug) && selectedPeriod && (
<DimensionFilter value={dimensionFilter} onChange={setDimensionFilter} />
)}
{!isReady && !isPeriodless ? (
<Card>
<CardContent className="p-6 space-y-4">
<Skeleton className="h-5 w-32" />
<Skeleton className="h-64" />
</CardContent>
</Card>
) : isPeriodless || selectedPeriod ? (
<FocusedView
slug={slug}
reportName={reportName}
periodId={selectedPeriod}
periodBounds={selectedPeriodBounds}
dateRange={dateRange}
dimensionFilter={dimensionFilter}
accountFilter={accountFilter}
isEnskildFirma={isEnskildFirma}
isAktiebolag={isAktiebolag}
onNavigateToAccount={navigateToAccount}
/>
) : (
<EmptyState
title="Inget räkenskapsår valt"
description="Skapa ett räkenskapsår för att kunna se rapporter."
actionLabel="Gå till inställningar"
actionHref="/settings"
/>
)}
</div>
)
}
function FocusedView({
slug,
reportName,
periodId,
periodBounds,
dateRange,
dimensionFilter,
accountFilter,
isEnskildFirma,
isAktiebolag,
onNavigateToAccount,
}: {
slug: string
reportName: string
periodId: string
periodBounds: { start: string; end: string } | null
dateRange: DateRangeValue
dimensionFilter: DimensionFilterValue | null
accountFilter: string | null
isEnskildFirma: boolean
isAktiebolag: boolean
onNavigateToAccount: (account: string) => void
}) {
switch (slug) {
case 'resultatrapport':
return <ResultatrapportView periodId={periodId} dateRange={dateRange} dimensionFilter={dimensionFilter} onNavigateToAccount={onNavigateToAccount} />
case 'dimension-pnl':
return <DimensionPnlView periodId={periodId} dateRange={dateRange} />
case 'balansrapport':
return <BalansrapportView periodId={periodId} dateRange={dateRange} onNavigateToAccount={onNavigateToAccount} />
case 'trial-balance':
return <TrialBalanceView periodId={periodId} onNavigateToAccount={onNavigateToAccount} />
case 'income-statement':
return <IncomeStatementView periodId={periodId} dateRange={dateRange} dimensionFilter={dimensionFilter} onNavigateToAccount={onNavigateToAccount} />
case 'balance-sheet':
return <BalanceSheetView periodId={periodId} dateRange={dateRange} onNavigateToAccount={onNavigateToAccount} />
case 'vat-declaration':
return <VatDeclarationView pageTitle={reportName} />
case 'periodisk-sammanstallning':
return <PeriodiskSammanstallningView />
case 'ne-declaration':
return isEnskildFirma ? <NEDeclarationView periodId={periodId} /> : null
case 'ink2-declaration':
return isAktiebolag ? <INK2DeclarationView periodId={periodId} /> : null
case 'huvudbok':
return <GeneralLedgerView periodId={periodId} initialAccountFilter={accountFilter} dimensionFilter={dimensionFilter} dateRange={dateRange} />
case 'grundbok':
return <JournalRegisterView periodId={periodId} />
case 'kundreskontra':
return <ARLedgerView periodId={periodId} />
case 'supplier-ledger':
return <SupplierLedgerView periodId={periodId} />
case 'bank-reconciliation':
return <BankReconciliationView periodId={periodId} periodBounds={periodBounds} />
default:
return null
}
}
export function FocusedReport({
slug,
initialPeriods,
initialCompanyId,
}: {
slug: string
initialPeriods: FiscalPeriod[]
initialCompanyId: string | null
}) {
return (
<Suspense fallback={<div className="space-y-8" />}>
<FocusedReportInner
slug={slug}
initialPeriods={initialPeriods}
initialCompanyId={initialCompanyId}
/>
</Suspense>
)
}