refactor(ui): migrate remaining inline pages to the concept design language (#1470)
* refactor(ui): migrate remaining inline pages to the concept design language Catch-up pass for surfaces the 2026-07 UI migration missed: - Bankavstämning: de-boxed toolbar, dry-table sections (preview, omatchade verifikationer, ignorerade, matchade), instructional copy moved behind the page "?" (HelpPopover via FocusedReport, sv+en), AttnLine for the dirty- dates hint, EmptyState for the blank page, space-y-8 rhythm. - Report detail views (trial balance, income statement, balance sheet, resultat-/balansrapport, reskontror, huvudbok, grundbok, dimension-P&L): shared Skeleton/Error/EmptyState shells, border-2 totals bands flattened to hairline cards with font-display tabular-nums headline numbers, ReportSectionTable rebuilt on the group-band idiom, font-mono money -> tabular-nums, house tablist for Förenklad/Detaljerad, GL filter de-boxed onto Input primitives, verdicts follow chips-mark-exceptions. - Extensions browse: PageHeader, locked section headers, rounded-lg secondary icon tiles, flat hover shift on cards, p-6 content. - Återkommande fakturor: page-level list moved off ui/table onto dry-table with hover-revealed quiet row actions; Skeleton loading. - Help: EmptyState for no search hits, flat hover shift on resource links. - Chart of accounts: spinner loading blocks -> Skeleton rows. - Kunskap graph + salary calendar popovers: rounded-lg, Input/Textarea primitives instead of hand-rolled shadow-sm controls. No logic, endpoint, or data changes. Verified via sandbox screenshots; lint 0 errors, 13214 tests green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ui): review triage: skip empty industry sectors, keyboard path to schedule edit Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
645ed0a53e
commit
02a5d10538
@@ -63,7 +63,7 @@ export function AgentKnowledgePanel() {
|
||||
// Mirrors the loaded layout: graph hero, section header, two profile cards.
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<Skeleton className="h-96 w-full rounded-xl" />
|
||||
<Skeleton className="h-96 w-full rounded-lg" />
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
|
||||
@@ -277,7 +277,7 @@ export function LedgerGraph({ deep, companyName }: { deep: DeepLedgerContext; co
|
||||
if (model.payees.length === 0) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-xl border p-16 text-center text-sm"
|
||||
className="rounded-lg border border-border p-16 text-center text-sm"
|
||||
style={{ background: INK, borderColor: HAIR, color: MUTED }}
|
||||
>
|
||||
{t('none_cp')}
|
||||
@@ -304,7 +304,7 @@ export function LedgerGraph({ deep, companyName }: { deep: DeepLedgerContext; co
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative overflow-hidden rounded-xl border"
|
||||
className="relative overflow-hidden rounded-lg border border-border"
|
||||
style={{
|
||||
borderColor: HAIR_STRONG,
|
||||
background: `radial-gradient(120% 120% at 50% 42%, #17171b 0%, ${INK} 62%)`,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { TH_CLASS, TD_CLASS, QUIET_LINK_CLASS } from '@/components/ui/dry-table'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { AccountNumber } from '@/components/ui/account-number'
|
||||
@@ -528,9 +529,10 @@ export default function ChartOfAccountsManager() {
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="p-8 text-center text-muted-foreground">
|
||||
<Loader2 className="mx-auto mb-2 h-5 w-5 animate-spin" />
|
||||
{t('loading')}
|
||||
<div className="space-y-2 py-2">
|
||||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||
<Skeleton key={i} className="h-8 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : view === 'my-accounts' ? (
|
||||
filteredAccounts.length === 0 ? (
|
||||
@@ -669,9 +671,10 @@ export default function ChartOfAccountsManager() {
|
||||
) : (
|
||||
<div>
|
||||
{referenceLoading && referenceAccounts.length === 0 ? (
|
||||
<div className="p-8 text-center text-muted-foreground">
|
||||
<Loader2 className="mx-auto mb-2 h-5 w-5 animate-spin" />
|
||||
{t('loading')}
|
||||
<div className="space-y-2 py-2">
|
||||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||
<Skeleton key={i} className="h-8 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : filteredReference.length === 0 ? (
|
||||
<p className="px-1 py-12 text-center text-sm text-muted-foreground">{t('no_matches')}</p>
|
||||
|
||||
@@ -18,10 +18,10 @@ export default async function ExtensionCard({ extension }: { extension: Extensio
|
||||
|
||||
return (
|
||||
<Card className="group relative">
|
||||
<CardContent className="pt-6">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-start gap-3 min-w-0">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10 flex-shrink-0">
|
||||
<Icon className="h-5 w-5 text-primary" />
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-secondary flex-shrink-0">
|
||||
<Icon className="h-5 w-5 text-foreground" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<Link
|
||||
|
||||
@@ -17,14 +17,14 @@ export default async function SectorCard({ sector }: { sector: Sector }) {
|
||||
|
||||
return (
|
||||
<Link href={`/extensions/${sector.slug}`} className="h-full">
|
||||
<Card className="group hover:border-primary/30 transition-colors cursor-pointer h-full">
|
||||
<CardContent className="pt-6">
|
||||
<Card className="group h-full cursor-pointer transition-colors duration-150 hover:bg-secondary/60">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10 flex-shrink-0">
|
||||
<Icon className="h-5 w-5 text-primary" />
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-secondary flex-shrink-0">
|
||||
<Icon className="h-5 w-5 text-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-medium group-hover:text-primary transition-colors">
|
||||
<h3 className="text-sm font-medium">
|
||||
{name}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">{description}</p>
|
||||
|
||||
@@ -10,8 +10,11 @@ import { Label } from '@/components/ui/label'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { EmptyState } from '@/components/ui/empty-state'
|
||||
import { AttnLine } from '@/components/ui/attn-line'
|
||||
import { TH_CLASS, TD_CLASS } from '@/components/ui/dry-table'
|
||||
import { AccountNumber } from '@/components/ui/account-number'
|
||||
import { AlertCircle, ChevronDown, ChevronRight, Link2, Unlink, Play, Eye, EyeOff, PiggyBank, MoreHorizontal } from 'lucide-react'
|
||||
import { AlertCircle, ChevronDown, ChevronRight, Landmark, Link2, Unlink, Play, Eye, EyeOff, PiggyBank, MoreHorizontal } from 'lucide-react'
|
||||
import { formatCurrency, formatDate } from '@/lib/utils'
|
||||
import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
|
||||
import { CashAccountSelector } from '@/components/common/CashAccountSelector'
|
||||
@@ -878,7 +881,7 @@ export function BankReconciliationView({ periodId, periodBounds }: BankReconcili
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-8">
|
||||
{error && (
|
||||
<Card>
|
||||
<CardContent className="py-3 text-center text-destructive text-sm">
|
||||
@@ -898,14 +901,11 @@ export function BankReconciliationView({ periodId, periodBounds }: BankReconcili
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>Avstämning mot <AccountNumber number={accountNumber} /></CardTitle>
|
||||
{status.is_reconciled ? (
|
||||
<Badge variant="success">Avstämd</Badge>
|
||||
<span className="text-sm text-muted-foreground">Avstämd</span>
|
||||
) : (
|
||||
<Badge variant="destructive">Ej avstämd</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Avstämningen körs mot <AccountNumber number={accountNumber} /> ({accountCurrency}). Övriga bankkonton (t.ex. Plusgiro <AccountNumber number="1920" />, kreditkort <AccountNumber number="1940" /> eller valutakonton) stäms av separat. Välj kontot i listan nedan.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2 text-sm">
|
||||
@@ -963,139 +963,124 @@ export function BankReconciliationView({ periodId, periodBounds }: BankReconcili
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Action Bar */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
<CashAccountSelector
|
||||
value={accountNumber}
|
||||
onChange={setAccountNumber}
|
||||
{/* Toolbar: flat on the panel, no box (UI-migration language) */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
<CashAccountSelector
|
||||
value={accountNumber}
|
||||
onChange={setAccountNumber}
|
||||
/>
|
||||
<div>
|
||||
<Label>Datum från</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={dateFrom}
|
||||
onChange={(e) => setDateFrom(e.target.value)}
|
||||
className="mt-1"
|
||||
/>
|
||||
<div>
|
||||
<Label>Datum från</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={dateFrom}
|
||||
onChange={(e) => setDateFrom(e.target.value)}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Datum till</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={dateTo}
|
||||
onChange={(e) => setDateTo(e.target.value)}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={() => fetchAll()} variant={datesDirty ? 'default' : 'outline'}>
|
||||
Filtrera
|
||||
</Button>
|
||||
<div className="flex-1" />
|
||||
<Button onClick={handleDryRun} disabled={runLoading || datesDirty} variant="outline">
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
{runLoading ? 'Analyserar...' : 'Förhandsgranska'}
|
||||
</Button>
|
||||
{dryRunResults && dryRunResults.length > 0 && (
|
||||
<Button onClick={handleApply} disabled={applyLoading || selectedPairs.size === 0}>
|
||||
<Play className="h-4 w-4 mr-2" />
|
||||
{applyLoading
|
||||
? 'Tillämpar...'
|
||||
: `Tillämpa ${selectedPairs.size} ${selectedPairs.size === 1 ? 'matchning' : 'matchningar'}`}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{datesDirty && (
|
||||
<p className="mt-3 text-xs text-muted-foreground">
|
||||
Datumfiltret är ändrat men inte tillämpat: klicka Filtrera för att uppdatera
|
||||
listorna innan du förhandsgranskar.
|
||||
</p>
|
||||
<div>
|
||||
<Label>Datum till</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={dateTo}
|
||||
onChange={(e) => setDateTo(e.target.value)}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={() => fetchAll()} variant={datesDirty ? 'default' : 'outline'}>
|
||||
Filtrera
|
||||
</Button>
|
||||
<div className="flex-1" />
|
||||
<Button onClick={handleDryRun} disabled={runLoading || datesDirty} variant="outline">
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
{runLoading ? 'Analyserar...' : 'Förhandsgranska'}
|
||||
</Button>
|
||||
{dryRunResults && dryRunResults.length > 0 && (
|
||||
<Button onClick={handleApply} disabled={applyLoading || selectedPairs.size === 0}>
|
||||
<Play className="h-4 w-4 mr-2" />
|
||||
{applyLoading
|
||||
? 'Tillämpar...'
|
||||
: `Tillämpa ${selectedPairs.size} ${selectedPairs.size === 1 ? 'matchning' : 'matchningar'}`}
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
{datesDirty && (
|
||||
<AttnLine>
|
||||
Datumfiltret är ändrat men inte tillämpat: klicka Filtrera för att uppdatera
|
||||
listorna innan du förhandsgranskar.
|
||||
</AttnLine>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Dry Run Preview */}
|
||||
{dryRunResults && dryRunResults.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
Förhandsgranskning: {dryRunResults.length}{' '}
|
||||
{dryRunResults.length === 1 ? 'matchning hittad' : 'matchningar hittade'}
|
||||
</CardTitle>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Starka träffar är förvalda. Ungefärliga träffar kräver att du bockar i dem själv:
|
||||
granska verifikationen först.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<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">Transaktion</th>
|
||||
<th className="py-2 w-24">Datum</th>
|
||||
<th className="py-2 w-28 text-right">Belopp</th>
|
||||
<th className="py-2 w-8 text-center">↔</th>
|
||||
<th className="py-2">Verifikation</th>
|
||||
<th className="py-2 w-24">Datum</th>
|
||||
<th className="py-2 w-28">Metod</th>
|
||||
<th className="py-2 w-20">Träff</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{dryRunResults.map((m) => {
|
||||
const key = matchKey(m.transaction_id, m.journal_entry_id)
|
||||
const badge = confidenceLabel(m.confidence)
|
||||
return (
|
||||
<tr key={key} className="border-b last:border-0">
|
||||
<td className="py-2">
|
||||
<Checkbox
|
||||
checked={selectedPairs.has(key)}
|
||||
onCheckedChange={() => toggleMatchSelection(key)}
|
||||
aria-label={`Tillämpa matchning för ${m.transaction_description}`}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 truncate max-w-[180px]">{m.transaction_description}</td>
|
||||
<td className="py-2 tabular-nums">{formatDate(m.transaction_date)}</td>
|
||||
<td className="py-2 text-right tabular-nums">{formatAmount(m.transaction_amount)}</td>
|
||||
<td className="py-2 text-center text-muted-foreground">↔</td>
|
||||
<td className="py-2">
|
||||
<Link
|
||||
href={`/bookkeeping/${m.journal_entry_id}`}
|
||||
className="font-mono text-xs underline-offset-2 hover:underline"
|
||||
target="_blank"
|
||||
>
|
||||
{formatVoucher(m)}
|
||||
</Link>
|
||||
<span className="ml-2 text-muted-foreground truncate">{m.entry_description}</span>
|
||||
</td>
|
||||
<td className="py-2 tabular-nums">{formatDate(m.entry_date)}</td>
|
||||
<td className="py-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{METHOD_LABELS[m.method] || m.method}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2">
|
||||
<Badge variant={badge.variant}>{badge.label}</Badge>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Förhandsgranskning ({dryRunResults.length})
|
||||
</h2>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse text-[13px]">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={`${TH_CLASS} w-8`}></th>
|
||||
<th className={TH_CLASS}>Transaktion</th>
|
||||
<th className={`${TH_CLASS} w-24`}>Datum</th>
|
||||
<th className={`${TH_CLASS} w-28 text-right`}>Belopp</th>
|
||||
<th className={`${TH_CLASS} w-8 text-center`}>↔</th>
|
||||
<th className={TH_CLASS}>Verifikation</th>
|
||||
<th className={`${TH_CLASS} w-24`}>Datum</th>
|
||||
<th className={`${TH_CLASS} w-28`}>Metod</th>
|
||||
<th className={`${TH_CLASS} w-20`}>Träff</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="stagger-enter">
|
||||
{dryRunResults.map((m) => {
|
||||
const key = matchKey(m.transaction_id, m.journal_entry_id)
|
||||
const badge = confidenceLabel(m.confidence)
|
||||
return (
|
||||
<tr key={key} className="transition-colors duration-150 hover:bg-secondary/35">
|
||||
<td className={TD_CLASS}>
|
||||
<Checkbox
|
||||
checked={selectedPairs.has(key)}
|
||||
onCheckedChange={() => toggleMatchSelection(key)}
|
||||
aria-label={`Tillämpa matchning för ${m.transaction_description}`}
|
||||
/>
|
||||
</td>
|
||||
<td className={`${TD_CLASS} truncate max-w-[180px]`}>{m.transaction_description}</td>
|
||||
<td className={`${TD_CLASS} tabular-nums`}>{formatDate(m.transaction_date)}</td>
|
||||
<td className={`${TD_CLASS} text-right tabular-nums`}>{formatAmount(m.transaction_amount)}</td>
|
||||
<td className={`${TD_CLASS} text-center text-muted-foreground`}>↔</td>
|
||||
<td className={TD_CLASS}>
|
||||
<Link
|
||||
href={`/bookkeeping/${m.journal_entry_id}`}
|
||||
className="font-mono text-xs underline-offset-2 hover:underline"
|
||||
target="_blank"
|
||||
>
|
||||
{formatVoucher(m)}
|
||||
</Link>
|
||||
<span className="ml-2 text-muted-foreground truncate">{m.entry_description}</span>
|
||||
</td>
|
||||
<td className={`${TD_CLASS} tabular-nums`}>{formatDate(m.entry_date)}</td>
|
||||
<td className={`${TD_CLASS} text-xs text-muted-foreground`}>
|
||||
{METHOD_LABELS[m.method] || m.method}
|
||||
</td>
|
||||
<td className={TD_CLASS}>
|
||||
<Badge variant={badge.variant}>{badge.label}</Badge>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{dryRunResults && dryRunResults.length === 0 && (
|
||||
<Card>
|
||||
<CardContent className="p-6 text-center text-muted-foreground">
|
||||
Inga automatiska matchningar hittades.
|
||||
</CardContent>
|
||||
</Card>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Inga automatiska matchningar hittades.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Unmatched Transactions */}
|
||||
@@ -1278,230 +1263,208 @@ export function BankReconciliationView({ periodId, periodBounds }: BankReconcili
|
||||
|
||||
{/* Unmatched GL Lines */}
|
||||
{unmatchedGlLines.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
Omatchade verifikationer på <AccountNumber number={accountNumber} /> ({unmatchedGlLines.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="mb-3 text-xs text-muted-foreground">
|
||||
Är en manuellt eller importerat bokförd verifikation egentligen en ingående balans? Markera den som IB: då räknas den inte med i avstämningen utan visas separat som ingående balans.
|
||||
</p>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<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-28 text-right">Belopp</th>
|
||||
<th className="py-2 w-24">Typ</th>
|
||||
<th className="py-2 w-36"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{unmatchedGlLines.map((line) => {
|
||||
const amount = line.debit_amount > 0 ? line.debit_amount : -line.credit_amount
|
||||
const isRetaggable = line.source_type === 'manual' || line.source_type === 'import'
|
||||
return (
|
||||
<tr key={line.line_id} className="border-b last:border-0">
|
||||
<td className="py-2">
|
||||
<Link
|
||||
href={`/bookkeeping/${line.journal_entry_id}`}
|
||||
className="font-mono text-xs underline-offset-2 hover:underline"
|
||||
target="_blank"
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Omatchade verifikationer på <AccountNumber number={accountNumber} /> ({unmatchedGlLines.length})
|
||||
</h2>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse text-[13px]">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={`${TH_CLASS} w-16`}>Ver.nr</th>
|
||||
<th className={`${TH_CLASS} w-24`}>Datum</th>
|
||||
<th className={TH_CLASS}>Beskrivning</th>
|
||||
<th className={`${TH_CLASS} w-28 text-right`}>Belopp</th>
|
||||
<th className={`${TH_CLASS} w-24`}>Typ</th>
|
||||
<th className={`${TH_CLASS} w-36`}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="stagger-enter">
|
||||
{unmatchedGlLines.map((line) => {
|
||||
const amount = line.debit_amount > 0 ? line.debit_amount : -line.credit_amount
|
||||
const isRetaggable = line.source_type === 'manual' || line.source_type === 'import'
|
||||
return (
|
||||
<tr key={line.line_id} className="group transition-colors duration-150 hover:bg-secondary/35">
|
||||
<td className={TD_CLASS}>
|
||||
<Link
|
||||
href={`/bookkeeping/${line.journal_entry_id}`}
|
||||
className="font-mono text-xs underline-offset-2 hover:underline"
|
||||
target="_blank"
|
||||
>
|
||||
{formatVoucher(line)}
|
||||
</Link>
|
||||
</td>
|
||||
<td className={`${TD_CLASS} tabular-nums`}>{formatDate(line.entry_date)}</td>
|
||||
<td className={`${TD_CLASS} truncate max-w-[300px]`}>
|
||||
{line.line_description || line.entry_description}
|
||||
</td>
|
||||
<td className={`${TD_CLASS} text-right tabular-nums`}>
|
||||
{formatCurrency(amount)}
|
||||
</td>
|
||||
<td className={`${TD_CLASS} text-xs text-muted-foreground`}>
|
||||
{SOURCE_TYPE_LABELS[line.source_type] ?? line.source_type}
|
||||
</td>
|
||||
<td className={`${TD_CLASS} text-right`}>
|
||||
{isRetaggable && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-8 text-xs"
|
||||
disabled={markLoading === line.journal_entry_id}
|
||||
onClick={() => handleMarkOpeningBalance(line.journal_entry_id)}
|
||||
title="Markera verifikationen som ingående balans: den utesluts då från avstämningen"
|
||||
>
|
||||
{formatVoucher(line)}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="py-2 tabular-nums">{formatDate(line.entry_date)}</td>
|
||||
<td className="py-2 truncate max-w-[300px]">
|
||||
{line.line_description || line.entry_description}
|
||||
</td>
|
||||
<td className="py-2 text-right tabular-nums">
|
||||
{formatCurrency(amount)}
|
||||
</td>
|
||||
<td className="py-2 text-xs text-muted-foreground">
|
||||
{SOURCE_TYPE_LABELS[line.source_type] ?? line.source_type}
|
||||
</td>
|
||||
<td className="py-2 text-right">
|
||||
{isRetaggable && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-8 text-xs"
|
||||
disabled={markLoading === line.journal_entry_id}
|
||||
onClick={() => handleMarkOpeningBalance(line.journal_entry_id)}
|
||||
title="Markera verifikationen som ingående balans: den utesluts då från avstämningen"
|
||||
>
|
||||
{markLoading === line.journal_entry_id ? 'Markerar…' : 'Märk som IB'}
|
||||
</Button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{markLoading === line.journal_entry_id ? 'Markerar…' : 'Märk som IB'}
|
||||
</Button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Ignored transactions (undo) */}
|
||||
{ignoredTx.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader
|
||||
className="cursor-pointer"
|
||||
<section className="space-y-3">
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={showIgnored}
|
||||
onClick={() => setShowIgnored(!showIgnored)}
|
||||
className="flex items-center gap-2 text-sm font-medium uppercase tracking-wider text-muted-foreground transition-colors duration-150 hover:text-foreground"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{showIgnored ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
<CardTitle className="text-base">
|
||||
Ignorerade transaktioner ({ignoredTx.length})
|
||||
</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
{showIgnored ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
Ignorerade transaktioner ({ignoredTx.length})
|
||||
</button>
|
||||
{showIgnored && (
|
||||
<CardContent>
|
||||
<p className="text-xs text-muted-foreground mb-3">
|
||||
Rader du valt att dölja från avstämningen. De påverkar inte saldot på <AccountNumber number={accountNumber} />: de är bara gömda från listan.
|
||||
</p>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<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-24">Datum</th>
|
||||
<th className="py-2">Beskrivning</th>
|
||||
<th className="py-2 w-20">Valuta</th>
|
||||
<th className="py-2 w-28 text-right">Belopp</th>
|
||||
<th className="py-2 w-28"></th>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse text-[13px]">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={`${TH_CLASS} w-24`}>Datum</th>
|
||||
<th className={TH_CLASS}>Beskrivning</th>
|
||||
<th className={`${TH_CLASS} w-20`}>Valuta</th>
|
||||
<th className={`${TH_CLASS} w-28 text-right`}>Belopp</th>
|
||||
<th className={`${TH_CLASS} w-28`}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="stagger-enter">
|
||||
{ignoredTx.map((tx) => (
|
||||
<tr key={tx.id} className="text-muted-foreground transition-colors duration-150 hover:bg-secondary/35">
|
||||
<td className={`${TD_CLASS} tabular-nums`}>{formatDate(tx.date)}</td>
|
||||
<td className={`${TD_CLASS} truncate max-w-[300px]`}>{tx.description}</td>
|
||||
<td className={`${TD_CLASS} text-xs tabular-nums`}>{tx.currency}</td>
|
||||
<td className={`${TD_CLASS} text-right tabular-nums`}>
|
||||
{formatCurrency(tx.amount, tx.currency)}
|
||||
</td>
|
||||
<td className={TD_CLASS}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={actionLoading === tx.id}
|
||||
onClick={() => handleUnignore(tx.id)}
|
||||
>
|
||||
{actionLoading === tx.id ? '...' : 'Återställ'}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ignoredTx.map((tx) => (
|
||||
<tr key={tx.id} className="border-b last:border-0 text-muted-foreground">
|
||||
<td className="py-2 tabular-nums">{formatDate(tx.date)}</td>
|
||||
<td className="py-2 truncate max-w-[300px]">{tx.description}</td>
|
||||
<td className="py-2 text-xs">
|
||||
<span className="tabular-nums">{tx.currency}</span>
|
||||
</td>
|
||||
<td className="py-2 text-right tabular-nums">
|
||||
{formatCurrency(tx.amount, tx.currency)}
|
||||
</td>
|
||||
<td className="py-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={actionLoading === tx.id}
|
||||
onClick={() => handleUnignore(tx.id)}
|
||||
>
|
||||
{actionLoading === tx.id ? '...' : 'Återställ'}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Recently Matched */}
|
||||
{matchedTx.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader
|
||||
className="cursor-pointer"
|
||||
<section className="space-y-3">
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={showMatched}
|
||||
onClick={() => setShowMatched(!showMatched)}
|
||||
className="flex items-center gap-2 text-sm font-medium uppercase tracking-wider text-muted-foreground transition-colors duration-150 hover:text-foreground"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{showMatched ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
<CardTitle className="text-base">
|
||||
Matchade transaktioner ({matchedTx.length})
|
||||
</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
{showMatched ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
Matchade transaktioner ({matchedTx.length})
|
||||
</button>
|
||||
{showMatched && (
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<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-24">Datum</th>
|
||||
<th className="py-2">Beskrivning</th>
|
||||
<th className="py-2 w-28 text-right">Belopp</th>
|
||||
<th className="py-2 w-32">Metod</th>
|
||||
<th className="py-2 w-28">Verifikation</th>
|
||||
<th className="py-2 w-24"></th>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse text-[13px]">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={`${TH_CLASS} w-24`}>Datum</th>
|
||||
<th className={TH_CLASS}>Beskrivning</th>
|
||||
<th className={`${TH_CLASS} w-28 text-right`}>Belopp</th>
|
||||
<th className={`${TH_CLASS} w-32`}>Metod</th>
|
||||
<th className={`${TH_CLASS} w-28`}>Verifikation</th>
|
||||
<th className={`${TH_CLASS} w-24`}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="stagger-enter">
|
||||
{matchedTx.map((tx) => (
|
||||
<tr key={tx.id} className="transition-colors duration-150 hover:bg-secondary/35">
|
||||
<td className={`${TD_CLASS} tabular-nums`}>{formatDate(tx.date)}</td>
|
||||
<td className={`${TD_CLASS} truncate max-w-[300px]`}>{tx.description}</td>
|
||||
<td className={`${TD_CLASS} text-right tabular-nums`}>
|
||||
{formatCurrency(tx.amount, accountCurrency)}
|
||||
</td>
|
||||
<td className={`${TD_CLASS} text-xs text-muted-foreground`}>
|
||||
{tx.reconciliation_method
|
||||
? METHOD_LABELS[tx.reconciliation_method] || tx.reconciliation_method
|
||||
: null}
|
||||
</td>
|
||||
<td className={TD_CLASS}>
|
||||
{tx.journal_entry_id && (
|
||||
<Link
|
||||
href={`/bookkeeping/${tx.journal_entry_id}`}
|
||||
className="text-xs underline-offset-2 hover:underline"
|
||||
target="_blank"
|
||||
>
|
||||
Öppna verifikat
|
||||
</Link>
|
||||
)}
|
||||
</td>
|
||||
<td className={TD_CLASS}>
|
||||
{tx.reconciliation_method && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={unlinkLoading === tx.id}
|
||||
onClick={() => handleUnlink(tx.id)}
|
||||
>
|
||||
<Unlink className="h-3 w-3 mr-1" />
|
||||
{unlinkLoading === tx.id ? '...' : 'Avmatcha'}
|
||||
</Button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{matchedTx.map((tx) => (
|
||||
<tr key={tx.id} className="border-b last:border-0">
|
||||
<td className="py-2 tabular-nums">{formatDate(tx.date)}</td>
|
||||
<td className="py-2 truncate max-w-[300px]">{tx.description}</td>
|
||||
<td className="py-2 text-right tabular-nums">
|
||||
{formatCurrency(tx.amount, accountCurrency)}
|
||||
</td>
|
||||
<td className="py-2">
|
||||
{tx.reconciliation_method && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{METHOD_LABELS[tx.reconciliation_method] || tx.reconciliation_method}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2">
|
||||
{tx.journal_entry_id && (
|
||||
<Link
|
||||
href={`/bookkeeping/${tx.journal_entry_id}`}
|
||||
className="text-xs underline-offset-2 hover:underline"
|
||||
target="_blank"
|
||||
>
|
||||
Öppna verifikat
|
||||
</Link>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2">
|
||||
{tx.reconciliation_method && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={unlinkLoading === tx.id}
|
||||
onClick={() => handleUnlink(tx.id)}
|
||||
>
|
||||
<Unlink className="h-3 w-3 mr-1" />
|
||||
{unlinkLoading === tx.id ? '...' : 'Avmatcha'}
|
||||
</Button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{unmatchedTx.length === 0 && glLines.length === 0 && matchedTx.length === 0 && ignoredTx.length === 0 && !loading && (
|
||||
<Card>
|
||||
<CardContent className="p-8 text-center text-muted-foreground">
|
||||
Inga transaktioner eller verifikationer att stämma av.
|
||||
</CardContent>
|
||||
</Card>
|
||||
<EmptyState
|
||||
icon={Landmark}
|
||||
title="Inget att stämma av"
|
||||
description="Det finns inga banktransaktioner eller verifikationer i den valda perioden. Importera eller synka banktransaktioner så visas de här."
|
||||
/>
|
||||
)}
|
||||
|
||||
<DestructiveConfirmDialog {...confirmDialogProps} />
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { ChevronLeft } from 'lucide-react'
|
||||
import { PageHeader } from '@/components/ui/page-header'
|
||||
import { HelpPopover } from '@/components/ui/help-popover'
|
||||
import { EmptyState } from '@/components/ui/empty-state'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
@@ -119,6 +120,20 @@ function FocusedReportInner({
|
||||
{!isStandalone && (
|
||||
<PageHeader
|
||||
title={reportName}
|
||||
// Page help behind a "?" (UI-migration convention 7): the report
|
||||
// bodies carry no instructional copy in the page flow.
|
||||
help={
|
||||
slug === 'bank-reconciliation' ? (
|
||||
<HelpPopover>
|
||||
<div className="space-y-2">
|
||||
<p>{t('help_bank_reconciliation_scope')}</p>
|
||||
<p>{t('help_bank_reconciliation_preview')}</p>
|
||||
<p>{t('help_bank_reconciliation_ib')}</p>
|
||||
<p>{t('help_bank_reconciliation_ignored')}</p>
|
||||
</div>
|
||||
</HelpPopover>
|
||||
) : undefined
|
||||
}
|
||||
action={
|
||||
<FyPicker
|
||||
value={selectedPeriod || null}
|
||||
|
||||
+259
-357
@@ -12,7 +12,7 @@ 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 { AlertCircle, Check, ChevronDown, ChevronRight, ExternalLink, FileCode, FileDown, FileText, Percent } from 'lucide-react'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { EmptyState } from '@/components/ui/empty-state'
|
||||
import { FyPicker } from '@/components/common/FyPicker'
|
||||
@@ -83,6 +83,36 @@ function formatAmount(amount: number): string {
|
||||
return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
}
|
||||
|
||||
// Shared shells for the report bodies, so all views read as the same
|
||||
// instrument: Skeleton while loading, EmptyState when the period has no data,
|
||||
// a quiet destructive card on fetch errors (design.md primitives).
|
||||
function ReportLoadingCard() {
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
function ReportErrorCard({ message }: { message: string }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-8 text-center text-destructive">
|
||||
<AlertCircle className="h-6 w-6 mx-auto mb-2" />
|
||||
{message}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function ReportEmptyState({ title, description }: { title: string; description: string }) {
|
||||
return <EmptyState icon={FileText} title={title} description={description} />
|
||||
}
|
||||
|
||||
function reportQuery(
|
||||
periodId: string,
|
||||
range?: DateRangeValue,
|
||||
@@ -132,35 +162,19 @@ export function TrialBalanceView({ periodId, onNavigateToAccount }: { periodId:
|
||||
}, [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>
|
||||
)
|
||||
return <ReportLoadingCard />
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
return <ReportErrorCard message={error} />
|
||||
}
|
||||
|
||||
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>
|
||||
<ReportEmptyState
|
||||
title="Inga verifikationer i perioden"
|
||||
description="Det finns inga bokförda verifikationer i den valda perioden."
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -195,30 +209,38 @@ export function TrialBalanceView({ periodId, onNavigateToAccount }: { periodId:
|
||||
<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">
|
||||
<div className="inline-flex shrink-0 gap-0.5 rounded-lg bg-muted/70 p-[3px]" role="tablist">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={viewMode === 'simplified'}
|
||||
onClick={() => setViewMode('simplified')}
|
||||
className={`px-3 py-1 text-xs rounded-sm transition-colors ${
|
||||
className={cn(
|
||||
'rounded-md px-3.5 py-[5px] text-[12.5px] transition-colors duration-150',
|
||||
viewMode === 'simplified'
|
||||
? 'bg-secondary text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
? 'border border-border bg-card font-medium text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
Förenklad
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={viewMode === 'detailed'}
|
||||
onClick={() => setViewMode('detailed')}
|
||||
className={`px-3 py-1 text-xs rounded-sm transition-colors ${
|
||||
className={cn(
|
||||
'rounded-md px-3.5 py-[5px] text-[12.5px] transition-colors duration-150',
|
||||
viewMode === 'detailed'
|
||||
? 'bg-secondary text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
? 'border border-border bg-card font-medium text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
Detaljerad
|
||||
</button>
|
||||
</div>
|
||||
{data.isBalanced ? (
|
||||
<Badge variant="success">Balanserad</Badge>
|
||||
<span className="text-sm text-muted-foreground">Balanserad</span>
|
||||
) : (
|
||||
<Badge variant="destructive">Ej balanserad</Badge>
|
||||
)}
|
||||
@@ -471,35 +493,19 @@ export function IncomeStatementView({ periodId, dateRange, dimensionFilter = nul
|
||||
}, [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>
|
||||
)
|
||||
return <ReportLoadingCard />
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
return <ReportErrorCard message={error} />
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-8 text-center text-muted-foreground">
|
||||
Ingen data för denna period.
|
||||
</CardContent>
|
||||
</Card>
|
||||
<ReportEmptyState
|
||||
title="Ingen data för perioden"
|
||||
description="Det finns inget bokfört underlag för den valda perioden."
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -521,12 +527,12 @@ export function IncomeStatementView({ periodId, dateRange, dimensionFilter = nul
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">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 className="p-0">
|
||||
<ReportSectionTable
|
||||
sections={data.revenue_sections}
|
||||
onNavigateToAccount={onNavigateToAccount}
|
||||
footer={{ label: 'Summa rörelseintäkter', amount: data.total_revenue }}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -535,24 +541,23 @@ export function IncomeStatementView({ periodId, dateRange, dimensionFilter = nul
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">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 className="p-0">
|
||||
<ReportSectionTable
|
||||
sections={data.expense_sections}
|
||||
negate
|
||||
onNavigateToAccount={onNavigateToAccount}
|
||||
footer={{ label: 'Summa rörelsekostnader', amount: data.total_expenses, negate: true }}
|
||||
/>
|
||||
</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 className="flex items-baseline justify-between p-6">
|
||||
<span className="text-sm font-medium">Rörelseresultat</span>
|
||||
<span className={`font-display text-xl tabular-nums ${data.total_revenue - data.total_expenses >= 0 ? 'text-success' : 'text-destructive'}`}>
|
||||
{formatAmount(data.total_revenue - data.total_expenses)} kr
|
||||
</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -562,25 +567,23 @@ export function IncomeStatementView({ periodId, dateRange, dimensionFilter = nul
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">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 className="p-0">
|
||||
<ReportSectionTable
|
||||
sections={data.financial_sections}
|
||||
onNavigateToAccount={onNavigateToAccount}
|
||||
footer={{ label: 'Summa finansiella poster', amount: data.total_financial }}
|
||||
/>
|
||||
</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>
|
||||
<Card>
|
||||
<CardContent className="flex items-baseline justify-between p-6">
|
||||
<span className="text-sm font-medium">Årets resultat</span>
|
||||
<span className={`font-display text-xl tabular-nums ${data.net_result >= 0 ? 'text-success' : 'text-destructive'}`}>
|
||||
{formatAmount(data.net_result)} kr
|
||||
</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -616,33 +619,19 @@ export function BalanceSheetView({ periodId, dateRange, onNavigateToAccount }: {
|
||||
}, [periodId, reportQs])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-8 text-center text-muted-foreground">
|
||||
Laddar balansräkning...
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
return <ReportLoadingCard />
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
return <ReportErrorCard message={error} />
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-8 text-center text-muted-foreground">
|
||||
Ingen data för denna period.
|
||||
</CardContent>
|
||||
</Card>
|
||||
<ReportEmptyState
|
||||
title="Ingen data för perioden"
|
||||
description="Det finns inget bokfört underlag för den valda perioden."
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -662,12 +651,12 @@ export function BalanceSheetView({ periodId, dateRange, onNavigateToAccount }: {
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">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 className="p-0">
|
||||
<ReportSectionTable
|
||||
sections={data.asset_sections}
|
||||
onNavigateToAccount={onNavigateToAccount}
|
||||
footer={{ label: 'Summa tillgångar', amount: data.total_assets }}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -676,30 +665,26 @@ export function BalanceSheetView({ periodId, dateRange, onNavigateToAccount }: {
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">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 className="p-0">
|
||||
<ReportSectionTable
|
||||
sections={data.equity_liability_sections}
|
||||
onNavigateToAccount={onNavigateToAccount}
|
||||
footer={{ label: 'Summa eget kapital och skulder', amount: data.total_equity_liabilities }}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Balance check */}
|
||||
<Card className="border-2">
|
||||
<CardContent className="py-4">
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="font-bold text-lg">Balanscheck</span>
|
||||
<span className="text-sm font-medium">Balanscheck</span>
|
||||
{isBalanced ? (
|
||||
<Badge variant="success" className="text-base px-3 py-1">
|
||||
Balanserar
|
||||
</Badge>
|
||||
<span className="text-sm text-muted-foreground">Balanserar</span>
|
||||
) : (
|
||||
<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">
|
||||
<Badge variant="destructive">Balanserar ej</Badge>
|
||||
<p className="text-sm text-destructive mt-1 tabular-nums">
|
||||
Differens: {formatAmount(Math.abs(data.total_assets - data.total_equity_liabilities))} kr
|
||||
</p>
|
||||
</div>
|
||||
@@ -747,35 +732,19 @@ export function ResultatrapportView({ periodId, dateRange, dimensionFilter = nul
|
||||
}, [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>
|
||||
)
|
||||
return <ReportLoadingCard />
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
return <ReportErrorCard message={error} />
|
||||
}
|
||||
|
||||
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>
|
||||
<ReportEmptyState
|
||||
title="Inga intäkter eller kostnader"
|
||||
description="Det finns inga bokförda intäkter eller kostnader i den valda perioden."
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -859,14 +828,14 @@ export function ResultatrapportView({ periodId, dateRange, dimensionFilter = nul
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-2">
|
||||
<CardContent className="py-4">
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<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'}`}>
|
||||
<span className="text-sm font-medium">Beräknat resultat</span>
|
||||
<span className={`font-display text-xl tabular-nums 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">
|
||||
<span className="tabular-nums text-sm text-muted-foreground w-32 text-right">
|
||||
{hasPrior ? `${formatAmount(data.net_result_prior)} kr` : '-'}
|
||||
</span>
|
||||
</div>
|
||||
@@ -907,33 +876,19 @@ export function BalansrapportView({ periodId, dateRange, onNavigateToAccount }:
|
||||
}, [periodId, reportQs])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-8 text-center text-muted-foreground">
|
||||
Laddar balansrapport...
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
return <ReportLoadingCard />
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
return <ReportErrorCard message={error} />
|
||||
}
|
||||
|
||||
if (!data || data.groups.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-8 text-center text-muted-foreground">
|
||||
Inga balansposter i denna period.
|
||||
</CardContent>
|
||||
</Card>
|
||||
<ReportEmptyState
|
||||
title="Inga balansposter"
|
||||
description="Det finns inga bokförda balansposter i den valda perioden."
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1005,8 +960,8 @@ export function BalansrapportView({ periodId, dateRange, onNavigateToAccount }:
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-2">
|
||||
<CardContent className="py-4 space-y-2">
|
||||
<Card>
|
||||
<CardContent className="p-6 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>
|
||||
@@ -1020,18 +975,14 @@ export function BalansrapportView({ periodId, dateRange, onNavigateToAccount }:
|
||||
<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>
|
||||
<span className="text-sm font-medium">Balanscheck</span>
|
||||
{data.is_balanced ? (
|
||||
<Badge variant="success" className="text-base px-3 py-1">
|
||||
Balanserar
|
||||
</Badge>
|
||||
<span className="text-sm text-muted-foreground">Balanserar</span>
|
||||
) : (
|
||||
<div className="text-right">
|
||||
<Badge variant="destructive" className="text-base px-3 py-1">
|
||||
Balanserar ej
|
||||
</Badge>
|
||||
<Badge variant="destructive">Balanserar ej</Badge>
|
||||
{data.imbalance_diagnosis && (
|
||||
<p className="text-sm text-destructive mt-1">
|
||||
<p className="text-sm text-destructive mt-1 tabular-nums">
|
||||
Differens: {formatAmount(Math.abs(data.imbalance_diagnosis.differens))} kr
|
||||
</p>
|
||||
)}
|
||||
@@ -1053,45 +1004,64 @@ function ReportSectionTable({
|
||||
sections,
|
||||
negate,
|
||||
onNavigateToAccount,
|
||||
footer,
|
||||
}: {
|
||||
sections: { title: string; rows: { account_number: string; account_name: string; amount: number }[]; subtotal: number }[]
|
||||
negate?: boolean
|
||||
onNavigateToAccount?: (account: string) => void
|
||||
/** Report total, rendered as the table's tfoot (e.g. "Summa tillgångar"). */
|
||||
footer?: { label: string; amount: number; negate?: boolean }
|
||||
}) {
|
||||
const fmt = (amount: number, neg?: boolean) =>
|
||||
neg ? `-${formatAmount(amount)}` : formatAmount(amount)
|
||||
|
||||
if (sections.length === 0) {
|
||||
return <p className="text-sm text-muted-foreground">Inga poster.</p>
|
||||
return <p className="p-6 pt-0 text-sm text-muted-foreground">Inga poster.</p>
|
||||
}
|
||||
|
||||
// One table with group-band rows (design.md tabular rules), matching the
|
||||
// Resultatrapport/Balansrapport idiom, instead of one boxed sub-table per
|
||||
// section with a repeated title line.
|
||||
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>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm min-w-[400px]">
|
||||
<tbody>
|
||||
{sections.map((section) => (
|
||||
<React.Fragment key={section.title}>
|
||||
<tr className="bg-muted/30">
|
||||
<td colSpan={3} className="px-4 py-2 text-[12px] font-semibold text-muted-foreground">
|
||||
{section.title}
|
||||
</td>
|
||||
</tr>
|
||||
{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 className="px-4 py-1.5 w-20"><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 w-32 whitespace-nowrap">
|
||||
{fmt(row.amount, negate)} 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>
|
||||
))}
|
||||
<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 whitespace-nowrap">{fmt(section.subtotal, negate)} kr</td>
|
||||
</tr>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
{footer && (
|
||||
<tfoot>
|
||||
<tr className="font-medium">
|
||||
<td colSpan={2} className="px-4 py-2">{footer.label}</td>
|
||||
<td className="px-4 py-2 text-right tabular-nums whitespace-nowrap">{fmt(footer.amount, footer.negate)} kr</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
)}
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2524,33 +2494,19 @@ export function SupplierLedgerView({ periodId }: { periodId: string }) {
|
||||
}, [periodId, asOfDate])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-8 text-center text-muted-foreground">
|
||||
Laddar leverantörsreskontra...
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
return <ReportLoadingCard />
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
return <ReportErrorCard message={error} />
|
||||
}
|
||||
|
||||
if (!data || !data.ledger) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-8 text-center text-muted-foreground">
|
||||
Ingen data tillgänglig.
|
||||
</CardContent>
|
||||
</Card>
|
||||
<ReportEmptyState
|
||||
title="Ingen leverantörsreskontra"
|
||||
description="Det finns inga leverantörsfakturor att visa för den valda perioden."
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2642,7 +2598,7 @@ export function SupplierLedgerView({ periodId }: { periodId: string }) {
|
||||
|
||||
{/* Reconciliation */}
|
||||
{reconciliation && (
|
||||
<Card className="border-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Avstämning mot <AccountNumber number="2440" /></CardTitle>
|
||||
</CardHeader>
|
||||
@@ -2650,21 +2606,21 @@ export function SupplierLedgerView({ periodId }: { periodId: string }) {
|
||||
<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>
|
||||
<span className="tabular-nums">{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>
|
||||
<span className="tabular-nums">{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'}>
|
||||
<span className={`tabular-nums ${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>
|
||||
<span className="text-sm text-muted-foreground">Avstämd</span>
|
||||
) : (
|
||||
<Badge variant="destructive">Ej avstämd - kontrollera bokföring</Badge>
|
||||
)}
|
||||
@@ -2810,73 +2766,57 @@ export function GeneralLedgerView({ periodId, initialAccountFilter, dimensionFil
|
||||
}, [periodId, initialAccountFilter, dimensionFilter, dateRange])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-8 text-center text-muted-foreground">
|
||||
Laddar huvudbok...
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
return <ReportLoadingCard />
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
return <ReportErrorCard message={error} />
|
||||
}
|
||||
|
||||
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>
|
||||
<ReportEmptyState
|
||||
title="Inga verifikationer i perioden"
|
||||
description="Det finns inga bokförda verifikationer i den valda perioden."
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
{/* Account range filter: flat toolbar on the panel, no box */}
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
<div>
|
||||
<Label htmlFor="gl-account-from">Konto från</Label>
|
||||
<Input
|
||||
id="gl-account-from"
|
||||
type="text"
|
||||
value={accountFrom}
|
||||
onChange={(e) => setAccountFrom(e.target.value)}
|
||||
placeholder="t.ex. 1510"
|
||||
className="mt-1 w-32"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="gl-account-to">Konto till</Label>
|
||||
<Input
|
||||
id="gl-account-to"
|
||||
type="text"
|
||||
value={accountTo}
|
||||
onChange={(e) => setAccountTo(e.target.value)}
|
||||
placeholder="t.ex. 1519"
|
||||
className="mt-1 w-32"
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={() => fetchData()} variant="outline">
|
||||
Filtrera
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{data.period.start && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Period: {data.period.start}: {data.period.end} | {data.accounts.length} konton
|
||||
<p className="text-sm text-muted-foreground tabular-nums">
|
||||
Period {data.period.start} till {data.period.end} · {data.accounts.length} konton
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -2933,16 +2873,16 @@ export function GeneralLedgerView({ periodId, initialAccountFilter, dimensionFil
|
||||
<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>
|
||||
<td className="py-1.5 text-right 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>
|
||||
<td className="py-2 text-right tabular-nums">{formatAmount(account.total_debit)}</td>
|
||||
<td className="py-2 text-right tabular-nums">{formatAmount(account.total_credit)}</td>
|
||||
<td className="py-2 text-right tabular-nums">{formatAmount(account.closing_balance)}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table></div>
|
||||
@@ -3022,33 +2962,19 @@ export function JournalRegisterView({ periodId }: { periodId: string }) {
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-8 text-center text-muted-foreground">
|
||||
Laddar grundbok...
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
return <ReportLoadingCard />
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
return <ReportErrorCard message={error} />
|
||||
}
|
||||
|
||||
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>
|
||||
<ReportEmptyState
|
||||
title="Inga verifikationer i perioden"
|
||||
description="Det finns inga bokförda verifikationer i den valda perioden."
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3056,8 +2982,8 @@ export function JournalRegisterView({ periodId }: { periodId: string }) {
|
||||
<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 className="text-sm text-muted-foreground tabular-nums">
|
||||
Period {data.period.start} till {data.period.end} · {data.total_entries} verifikationer
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -3324,33 +3250,19 @@ export function ARLedgerView({ periodId }: { periodId: string }) {
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-8 text-center text-muted-foreground">
|
||||
Laddar kundreskontra...
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
return <ReportLoadingCard />
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
return <ReportErrorCard message={error} />
|
||||
}
|
||||
|
||||
if (!data || !data.ledger) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-8 text-center text-muted-foreground">
|
||||
Ingen data tillgänglig.
|
||||
</CardContent>
|
||||
</Card>
|
||||
<ReportEmptyState
|
||||
title="Ingen kundreskontra"
|
||||
description="Det finns inga kundfakturor att visa för den valda perioden."
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3471,7 +3383,7 @@ export function ARLedgerView({ periodId }: { periodId: string }) {
|
||||
|
||||
{/* Reconciliation */}
|
||||
{reconciliation && (
|
||||
<Card className="border-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Avstämning mot <AccountNumber number="1510" /></CardTitle>
|
||||
</CardHeader>
|
||||
@@ -3479,21 +3391,21 @@ export function ARLedgerView({ periodId }: { periodId: string }) {
|
||||
<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>
|
||||
<span className="tabular-nums">{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>
|
||||
<span className="tabular-nums">{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'}>
|
||||
<span className={`tabular-nums ${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>
|
||||
<span className="text-sm text-muted-foreground">Avstämd</span>
|
||||
) : (
|
||||
<Badge variant="destructive">Ej avstämd - kontrollera bokföring</Badge>
|
||||
)}
|
||||
@@ -3593,11 +3505,7 @@ export function DimensionPnlView({ periodId, dateRange }: { periodId: string; da
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{pivotPicker}
|
||||
<Card>
|
||||
<CardContent className="p-8 text-center text-muted-foreground">
|
||||
Laddar rapport...
|
||||
</CardContent>
|
||||
</Card>
|
||||
<ReportLoadingCard />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3606,12 +3514,7 @@ export function DimensionPnlView({ periodId, dateRange }: { periodId: string; da
|
||||
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>
|
||||
<ReportErrorCard message={error} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3620,11 +3523,10 @@ export function DimensionPnlView({ periodId, dateRange }: { periodId: string; da
|
||||
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>
|
||||
<ReportEmptyState
|
||||
title="Inga taggade poster"
|
||||
description="Inga intäkter eller kostnader är taggade med en dimension i den valda perioden."
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3696,20 +3598,20 @@ export function DimensionPnlView({ periodId, dateRange }: { periodId: string; da
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-2">
|
||||
<CardContent className="py-4">
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<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 text-sm font-medium">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'}`}>
|
||||
<td key={i} className={`px-4 text-right tabular-nums font-medium 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'}`}>
|
||||
<td className={`px-4 text-right font-display text-xl tabular-nums w-32 ${data.net_total >= 0 ? 'text-success' : 'text-destructive'}`}>
|
||||
{formatAmount(data.net_total)} kr
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -29,6 +29,8 @@ import {
|
||||
} from 'lucide-react'
|
||||
import { useLocale, useTranslations } from 'next-intl'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
DestructiveConfirmDialog,
|
||||
useDestructiveConfirm,
|
||||
@@ -696,7 +698,7 @@ function BulkWorkedDialog({
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium" htmlFor="bulk-w-hours">{t('hours_per_day')}</label>
|
||||
<input
|
||||
<Input
|
||||
id="bulk-w-hours"
|
||||
type="number"
|
||||
min={0}
|
||||
@@ -705,7 +707,7 @@ function BulkWorkedDialog({
|
||||
value={hours}
|
||||
onChange={(e) => setHours(e.target.value)}
|
||||
autoFocus
|
||||
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm tabular-nums shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
className="tabular-nums"
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{t('hours_zero_hint')}
|
||||
@@ -714,13 +716,13 @@ function BulkWorkedDialog({
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium" htmlFor="bulk-w-notes">{t('notes_label')}</label>
|
||||
<textarea
|
||||
<Textarea
|
||||
id="bulk-w-notes"
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
rows={2}
|
||||
maxLength={2000}
|
||||
className="flex w-full rounded-md border border-input bg-background px-3 py-1.5 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
className="min-h-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -853,7 +855,7 @@ function BulkAbsenceDialog({
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium" htmlFor="bulk-a-hours">{t('hours_per_day')}</label>
|
||||
<input
|
||||
<Input
|
||||
id="bulk-a-hours"
|
||||
type="number"
|
||||
min={0.5}
|
||||
@@ -861,19 +863,19 @@ function BulkAbsenceDialog({
|
||||
step={0.5}
|
||||
value={hours}
|
||||
onChange={(e) => setHours(e.target.value)}
|
||||
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm tabular-nums shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
className="tabular-nums"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium" htmlFor="bulk-a-notes">{t('notes_label')}</label>
|
||||
<textarea
|
||||
<Textarea
|
||||
id="bulk-a-notes"
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
rows={2}
|
||||
maxLength={2000}
|
||||
className="flex w-full rounded-md border border-input bg-background px-3 py-1.5 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
className="min-h-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user