* fix: include reversed entries in all reports (general ledger, trial balance, VAT, SIE, NE, INK2) Reversed entries (storno) must appear alongside their original posted entries in reports for a complete audit trail. Previously, filtering by status='posted' excluded them, causing discrepancies when corrections had been made. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: semi-manual invoice payment booking with editable journal lines When marking an invoice as paid, users now see a dialog where they can: - Choose which bank/cash account the payment goes to (1910, 1920, 1930, etc.) - Review and edit the proposed journal entry lines before committing - The happy path remains fast — lines are pre-filled correctly Implementation: - Pure proposePaymentLines() function for line computation (accrual + cash) - PaymentBookingDialog with AccountCombobox, balance validation, date picker - API accepts optional custom lines, falls back to auto-generation without them - 18 tests (8 unit + 10 API) all passing Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address Greptile review — validation fallback, balance check, error handling - P1: Return 400 on invalid body instead of silently falling back to auto-generated lines (split JSON parse from schema validation) - P1: Add server-side balance check for custom lines before committing (debit must equal credit, totalDebit > 0) - P2: Wrap PaymentBookingDialog init() in try/catch with toast on failure and auto-close instead of silent empty state - Add 2 new tests: unbalanced lines → 400, invalid schema → 400 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: OAuth callback redirect for local dev and timeout resilience - Pass redirectUri dynamically from NEXT_PUBLIC_APP_URL so OAuth callbacks work on localhost (not just production) - Encode consentId/provider in OAuth state (base64url JSON) so the callback doesn't depend on session storage - Add skipAuth flag to extension API routes for OAuth callbacks (external provider redirects have no user session cookie) - Wrap AbortError in descriptive timeout messages in arcim-client - Make preview endpoint resilient to partial failures (company info and SIE fetch are individually non-blocking) - Simplify login page (remove unused magic link auth mode) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: create journal entry before marking invoice as paid Move journal entry creation before the invoice status update so that if accounting fails, the invoice is not permanently marked paid without a corresponding entry. Previously the error was silently swallowed. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: update mark-paid tests for journal-first ordering Reorder mock queue to match new flow (settings before update), update failure test to expect 500 instead of silent success, add try-catch with proper error response in route handler. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add reverse charge VAT (ruta 20-32) and improve mobile UX across dashboard Add full reverse charge (omvänd skattskyldighet) support to the VAT declaration: - Map accounts 2614/2624/2634 to ruta 30/31/32 for self-assessed output VAT - Calculate purchase bases (ruta 20-24) from supplier invoices by supplier type - Include ruta 30-32 in ruta 49 formula and totalOutputVat summary - Display reverse charge section in reports UI and composition chart - Add comprehensive test coverage for all reverse charge scenarios Improve mobile UX across the app: - Convert nav drawer to bottom sheet with drag handle and safe area padding - Add mobile card layout for PaymentBookingDialog journal lines - Replace settings tab pills with dropdown selector on mobile - Make wizard step indicators responsive (collapsed on mobile) - Ensure all dialog footers stack buttons full-width on mobile - Add 44px minimum touch targets throughout - Make onboarding buttons full-width on mobile Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address Greptile review — indentation, query efficiency, tab dedup - Fix misleading try-block indentation in mark-paid route - Filter reversed entries at DB level (.eq('status', 'posted')) instead of fetching then discarding in memory - Extract shared settingsTabs array so mobile Select and desktop TabsList stay in sync automatically Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1929 lines
69 KiB
TypeScript
1929 lines
69 KiB
TypeScript
'use client'
|
|
|
|
import React, { useState, useEffect } from 'react'
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Label } from '@/components/ui/label'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
|
import { Download, AlertCircle, ChevronDown, ChevronRight } from 'lucide-react'
|
|
import { AccountNumber } from '@/components/ui/account-number'
|
|
import { NEDeclarationView } from '@/components/reports/NEDeclarationView'
|
|
import { INK2DeclarationView } from '@/components/reports/INK2DeclarationView'
|
|
import { BankReconciliationView } from '@/components/reports/BankReconciliationView'
|
|
import { TrialBalanceChart } from '@/components/reports/TrialBalanceChart'
|
|
import { VatCompositionChart } from '@/components/reports/VatCompositionChart'
|
|
import { IncomeExpenseChart } from '@/components/reports/IncomeExpenseChart'
|
|
import type { MonthlyDataPoint } from '@/components/reports/IncomeExpenseChart'
|
|
import type {
|
|
FiscalPeriod,
|
|
TrialBalanceRow,
|
|
IncomeStatementReport,
|
|
BalanceSheetReport,
|
|
VatDeclaration,
|
|
VatPeriodType,
|
|
} from '@/types'
|
|
|
|
function formatAmount(amount: number): string {
|
|
return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
|
}
|
|
|
|
export default function ReportsPage() {
|
|
const [periods, setPeriods] = useState<FiscalPeriod[]>([])
|
|
const [selectedPeriod, setSelectedPeriod] = useState('')
|
|
const [activeTab, setActiveTab] = useState('trial-balance')
|
|
const [entityType, setEntityType] = useState<string | null>(null)
|
|
const [isLoadingInit, setIsLoadingInit] = useState(true)
|
|
|
|
async function fetchPeriods() {
|
|
const res = await fetch('/api/bookkeeping/fiscal-periods')
|
|
const { data } = await res.json()
|
|
setPeriods(data || [])
|
|
if (data && data.length > 0) {
|
|
setSelectedPeriod(data[0].id)
|
|
}
|
|
}
|
|
|
|
async function fetchEntityType() {
|
|
try {
|
|
const res = await fetch('/api/settings')
|
|
const { data } = await res.json()
|
|
if (data?.entity_type) {
|
|
setEntityType(data.entity_type)
|
|
}
|
|
} catch {
|
|
// Ignore - entity type is optional for tab visibility
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
Promise.all([fetchPeriods(), fetchEntityType()]).finally(() => {
|
|
setIsLoadingInit(false)
|
|
})
|
|
}, [])
|
|
|
|
const isEnskildFirma = entityType === 'enskild_firma'
|
|
const isAktiebolag = entityType === 'aktiebolag'
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
|
<div>
|
|
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">Rapporter</h1>
|
|
<p className="text-muted-foreground">
|
|
Generera skattedeklarationer, resultaträkningar och exportera till Skatteverket
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{isLoadingInit ? (
|
|
<div className="space-y-6">
|
|
<div className="flex flex-col sm:flex-row sm:items-end gap-4">
|
|
<div>
|
|
<div className="h-4 bg-muted rounded w-24 animate-pulse mb-1" />
|
|
<div className="h-10 bg-muted rounded w-64 animate-pulse" />
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
{[1, 2, 3, 4].map((i) => (
|
|
<div key={i} className="space-y-2">
|
|
<div className="h-3 bg-muted rounded w-16 animate-pulse" />
|
|
<div className="rounded-lg border p-1 space-y-1">
|
|
<div className="h-8 bg-muted rounded animate-pulse" />
|
|
<div className="h-8 bg-muted rounded animate-pulse" />
|
|
<div className="h-8 bg-muted rounded animate-pulse" />
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<Card>
|
|
<CardContent className="p-6 space-y-4">
|
|
<div className="h-5 bg-muted rounded w-32 animate-pulse" />
|
|
<div className="h-64 bg-muted rounded animate-pulse" />
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="flex flex-col sm:flex-row sm:items-end gap-4">
|
|
<div>
|
|
<Label>Räkenskapsår</Label>
|
|
<select
|
|
value={selectedPeriod}
|
|
onChange={(e) => setSelectedPeriod(e.target.value)}
|
|
className="w-full mt-1 rounded-md border border-input bg-background px-3 py-2 text-sm"
|
|
>
|
|
{periods.map((p) => (
|
|
<option key={p.id} value={p.id}>
|
|
{p.name} ({p.period_start} — {p.period_end})
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
{selectedPeriod && (
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => {
|
|
window.open(`/api/reports/sie-export?period_id=${selectedPeriod}`, '_blank')
|
|
}}
|
|
>
|
|
<Download className="h-4 w-4 mr-2" />
|
|
Ladda ner SIE-fil
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{selectedPeriod ? (
|
|
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
|
{/* Mobile: compact select dropdown */}
|
|
<div className="sm:hidden mb-2">
|
|
<select
|
|
value={activeTab}
|
|
onChange={(e) => setActiveTab(e.target.value)}
|
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
|
>
|
|
<optgroup label="Bokslut">
|
|
<option value="trial-balance">Saldobalans</option>
|
|
<option value="income-statement">Resultaträkning</option>
|
|
<option value="balance-sheet">Balansräkning</option>
|
|
</optgroup>
|
|
<optgroup label="Skatt & moms">
|
|
<option value="vat-declaration">Momsdeklaration</option>
|
|
{isEnskildFirma && <option value="ne-declaration">NE-bilaga</option>}
|
|
{isAktiebolag && <option value="ink2-declaration">INK2</option>}
|
|
</optgroup>
|
|
<optgroup label="Huvudböcker">
|
|
<option value="huvudbok">Huvudbok</option>
|
|
<option value="grundbok">Grundbok</option>
|
|
<option value="kundreskontra">Kundreskontra</option>
|
|
<option value="supplier-ledger">Leverantörsreskontra</option>
|
|
</optgroup>
|
|
<optgroup label="Avstämning">
|
|
<option value="bank-reconciliation">Bankavstämning</option>
|
|
</optgroup>
|
|
</select>
|
|
</div>
|
|
|
|
{/* Desktop: grid tab navigation */}
|
|
<div className="hidden sm:grid sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-2">
|
|
{/* Bokslut (Financial Statements) */}
|
|
<div className="space-y-1">
|
|
<p className="text-[10px] font-semibold text-muted-foreground/70 uppercase tracking-[0.08em] px-1">
|
|
Bokslut
|
|
</p>
|
|
<TabsList className="flex flex-col h-auto w-full gap-0.5 p-1">
|
|
<TabsTrigger value="trial-balance" className="w-full justify-start">
|
|
Saldobalans
|
|
</TabsTrigger>
|
|
<TabsTrigger value="income-statement" className="w-full justify-start">
|
|
Resultaträkning
|
|
</TabsTrigger>
|
|
<TabsTrigger value="balance-sheet" className="w-full justify-start">
|
|
Balansräkning
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
</div>
|
|
|
|
{/* Skatt & moms (Tax & VAT) */}
|
|
<div className="space-y-1">
|
|
<p className="text-[10px] font-semibold text-muted-foreground/70 uppercase tracking-[0.08em] px-1">
|
|
Skatt & moms
|
|
</p>
|
|
<TabsList className="flex flex-col h-auto w-full gap-0.5 p-1">
|
|
<TabsTrigger value="vat-declaration" className="w-full justify-start">
|
|
Momsdeklaration
|
|
</TabsTrigger>
|
|
{isEnskildFirma && (
|
|
<TabsTrigger value="ne-declaration" className="w-full justify-start">
|
|
NE-bilaga
|
|
</TabsTrigger>
|
|
)}
|
|
{isAktiebolag && (
|
|
<TabsTrigger value="ink2-declaration" className="w-full justify-start">
|
|
INK2
|
|
</TabsTrigger>
|
|
)}
|
|
</TabsList>
|
|
</div>
|
|
|
|
{/* Huvudböcker (Ledgers) */}
|
|
<div className="space-y-1">
|
|
<p className="text-[10px] font-semibold text-muted-foreground/70 uppercase tracking-[0.08em] px-1">
|
|
Huvudböcker
|
|
</p>
|
|
<TabsList className="flex flex-col h-auto w-full gap-0.5 p-1">
|
|
<TabsTrigger value="huvudbok" className="w-full justify-start">
|
|
Huvudbok
|
|
</TabsTrigger>
|
|
<TabsTrigger value="grundbok" className="w-full justify-start">
|
|
Grundbok
|
|
</TabsTrigger>
|
|
<TabsTrigger value="kundreskontra" className="w-full justify-start">
|
|
Kundreskontra
|
|
</TabsTrigger>
|
|
<TabsTrigger value="supplier-ledger" className="w-full justify-start">
|
|
Leverantörsreskontra
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
</div>
|
|
|
|
{/* Avstämning (Reconciliation) */}
|
|
<div className="space-y-1">
|
|
<p className="text-[10px] font-semibold text-muted-foreground/70 uppercase tracking-[0.08em] px-1">
|
|
Avstämning
|
|
</p>
|
|
<TabsList className="flex flex-col h-auto w-full gap-0.5 p-1">
|
|
<TabsTrigger value="bank-reconciliation" className="w-full justify-start">
|
|
Bankavstämning
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
</div>
|
|
</div>
|
|
|
|
<TabsContent value="trial-balance">
|
|
<TrialBalanceView periodId={selectedPeriod} />
|
|
</TabsContent>
|
|
<TabsContent value="income-statement">
|
|
<IncomeStatementView periodId={selectedPeriod} />
|
|
</TabsContent>
|
|
<TabsContent value="balance-sheet">
|
|
<BalanceSheetView periodId={selectedPeriod} />
|
|
</TabsContent>
|
|
<TabsContent value="vat-declaration">
|
|
<VatDeclarationView />
|
|
</TabsContent>
|
|
{isEnskildFirma && (
|
|
<TabsContent value="ne-declaration">
|
|
<NEDeclarationView periodId={selectedPeriod} />
|
|
</TabsContent>
|
|
)}
|
|
{isAktiebolag && (
|
|
<TabsContent value="ink2-declaration">
|
|
<INK2DeclarationView periodId={selectedPeriod} />
|
|
</TabsContent>
|
|
)}
|
|
<TabsContent value="huvudbok">
|
|
<GeneralLedgerView periodId={selectedPeriod} />
|
|
</TabsContent>
|
|
<TabsContent value="grundbok">
|
|
<JournalRegisterView periodId={selectedPeriod} />
|
|
</TabsContent>
|
|
<TabsContent value="kundreskontra">
|
|
<ARLedgerView periodId={selectedPeriod} />
|
|
</TabsContent>
|
|
<TabsContent value="supplier-ledger">
|
|
<SupplierLedgerView periodId={selectedPeriod} />
|
|
</TabsContent>
|
|
<TabsContent value="bank-reconciliation">
|
|
<BankReconciliationView />
|
|
</TabsContent>
|
|
</Tabs>
|
|
) : (
|
|
<Card>
|
|
<CardContent className="p-8 text-center text-muted-foreground">
|
|
Inget räkenskapsår valt. Skapa ett räkenskapsår under Inställningar.
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function TrialBalanceView({ periodId }: { periodId: string }) {
|
|
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)
|
|
|
|
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-8 text-center text-muted-foreground">
|
|
Laddar saldobalans...
|
|
</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>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<TrialBalanceChart rows={data.rows} />
|
|
<Card>
|
|
<CardHeader>
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle>Saldobalans</CardTitle>
|
|
{data.isBalanced ? (
|
|
<Badge className="bg-success/10 text-success">Balanserad</Badge>
|
|
) : (
|
|
<Badge variant="destructive">Ej balanserad</Badge>
|
|
)}
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="overflow-x-auto -mx-2 px-2"><table className="w-full text-sm min-w-[600px]">
|
|
<thead>
|
|
<tr className="border-b text-left text-muted-foreground">
|
|
<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) => (
|
|
<tr key={row.account_number} className="border-b last:border-0">
|
|
<td className="py-2"><AccountNumber number={row.account_number} name={row.account_name} /></td>
|
|
<td className="py-2">{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>
|
|
))}
|
|
</tbody>
|
|
<tfoot>
|
|
<tr className="font-semibold border-t-2">
|
|
<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>
|
|
)
|
|
}
|
|
|
|
function IncomeStatementView({ periodId }: { periodId: string }) {
|
|
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)
|
|
|
|
useEffect(() => {
|
|
setLoading(true)
|
|
setError(null)
|
|
setMonthlyLoading(true)
|
|
|
|
fetch(`/api/reports/income-statement?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 resultaträkning')
|
|
setLoading(false)
|
|
})
|
|
|
|
fetch(`/api/reports/monthly-breakdown?period_id=${periodId}`)
|
|
.then((res) => res.json())
|
|
.then((result) => {
|
|
if (result.data?.months) {
|
|
setMonthlyData(result.data.months)
|
|
}
|
|
setMonthlyLoading(false)
|
|
})
|
|
.catch(() => {
|
|
setMonthlyLoading(false)
|
|
})
|
|
}, [periodId])
|
|
|
|
if (loading) {
|
|
return (
|
|
<Card>
|
|
<CardContent className="p-8 text-center text-muted-foreground">
|
|
Laddar resultaträ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>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{!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} />
|
|
<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 />
|
|
<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} />
|
|
<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>
|
|
)
|
|
}
|
|
|
|
function BalanceSheetView({ periodId }: { periodId: string }) {
|
|
const [data, setData] = useState<BalanceSheetReport | null>(null)
|
|
const [loading, setLoading] = useState(true)
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
useEffect(() => {
|
|
setLoading(true)
|
|
setError(null)
|
|
fetch(`/api/reports/balance-sheet?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 balansräkning')
|
|
setLoading(false)
|
|
})
|
|
}, [periodId])
|
|
|
|
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">
|
|
{/* Assets */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-lg">Tillgångar</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<ReportSectionTable sections={data.asset_sections} />
|
|
<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} />
|
|
<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 className="bg-success/10 text-success 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>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function ReportSectionTable({
|
|
sections,
|
|
negate,
|
|
}: {
|
|
sections: { title: string; rows: { account_number: string; account_name: string; amount: number }[]; subtotal: number }[]
|
|
negate?: boolean
|
|
}) {
|
|
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">
|
|
<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>
|
|
)
|
|
}
|
|
|
|
function VatDeclarationView() {
|
|
const currentYear = new Date().getFullYear()
|
|
const currentMonth = new Date().getMonth() + 1
|
|
const currentQuarter = Math.ceil(currentMonth / 3)
|
|
|
|
const [periodType, setPeriodType] = useState<VatPeriodType>('quarterly')
|
|
const [year, setYear] = useState(currentYear)
|
|
const [period, setPeriod] = useState(currentQuarter)
|
|
const [data, setData] = useState<VatDeclaration | null>(null)
|
|
const [loading, setLoading] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
// Generate year options (last 5 years)
|
|
const yearOptions = Array.from({ length: 5 }, (_, i) => currentYear - i)
|
|
|
|
// Generate period options based on type
|
|
const getPeriodOptions = () => {
|
|
switch (periodType) {
|
|
case 'monthly':
|
|
return [
|
|
{ value: 1, label: 'Januari' },
|
|
{ value: 2, label: 'Februari' },
|
|
{ value: 3, label: 'Mars' },
|
|
{ value: 4, label: 'April' },
|
|
{ value: 5, label: 'Maj' },
|
|
{ value: 6, label: 'Juni' },
|
|
{ value: 7, label: 'Juli' },
|
|
{ value: 8, label: 'Augusti' },
|
|
{ value: 9, label: 'September' },
|
|
{ value: 10, label: 'Oktober' },
|
|
{ value: 11, label: 'November' },
|
|
{ value: 12, label: 'December' },
|
|
]
|
|
case 'quarterly':
|
|
return [
|
|
{ value: 1, label: 'Kvartal 1 (jan-mar)' },
|
|
{ value: 2, label: 'Kvartal 2 (apr-jun)' },
|
|
{ value: 3, label: 'Kvartal 3 (jul-sep)' },
|
|
{ value: 4, label: 'Kvartal 4 (okt-dec)' },
|
|
]
|
|
case 'yearly':
|
|
return [{ value: 1, label: 'Helår' }]
|
|
default:
|
|
return []
|
|
}
|
|
}
|
|
|
|
// Reset period when type changes
|
|
useEffect(() => {
|
|
if (periodType === 'monthly') {
|
|
setPeriod(currentMonth)
|
|
} else if (periodType === 'quarterly') {
|
|
setPeriod(currentQuarter)
|
|
} else {
|
|
setPeriod(1)
|
|
}
|
|
}, [periodType, currentMonth, currentQuarter])
|
|
|
|
const fetchDeclaration = async () => {
|
|
setLoading(true)
|
|
setError(null)
|
|
try {
|
|
const res = await fetch(
|
|
`/api/reports/vat-declaration?periodType=${periodType}&year=${year}&period=${period}`
|
|
)
|
|
const result = await res.json()
|
|
if (result.error) {
|
|
setError(result.error)
|
|
} else {
|
|
setData(result.data)
|
|
}
|
|
} catch {
|
|
setError('Kunde inte hämta momsdeklaration')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Period selection */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-lg">Välj period</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="flex flex-wrap items-end gap-4">
|
|
<div>
|
|
<Label>Periodicitet</Label>
|
|
<select
|
|
value={periodType}
|
|
onChange={(e) => setPeriodType(e.target.value as VatPeriodType)}
|
|
className="w-full mt-1 rounded-md border border-input bg-background px-3 py-2 text-sm"
|
|
>
|
|
<option value="monthly">Månadsvis</option>
|
|
<option value="quarterly">Kvartalsvis</option>
|
|
<option value="yearly">Årsvis</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<Label>År</Label>
|
|
<select
|
|
value={year}
|
|
onChange={(e) => setYear(parseInt(e.target.value))}
|
|
className="w-full mt-1 rounded-md border border-input bg-background px-3 py-2 text-sm"
|
|
>
|
|
{yearOptions.map((y) => (
|
|
<option key={y} value={y}>
|
|
{y}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<Label>Period</Label>
|
|
<select
|
|
value={period}
|
|
onChange={(e) => setPeriod(parseInt(e.target.value))}
|
|
className="w-full mt-1 rounded-md border border-input bg-background px-3 py-2 text-sm"
|
|
>
|
|
{getPeriodOptions().map((opt) => (
|
|
<option key={opt.value} value={opt.value}>
|
|
{opt.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<Button onClick={fetchDeclaration} disabled={loading}>
|
|
{loading ? 'Laddar...' : 'Hämta'}
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{error && (
|
|
<Card>
|
|
<CardContent className="p-8 text-center text-destructive">
|
|
<AlertCircle className="h-6 w-6 mx-auto mb-2" />
|
|
{error}
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{data && (
|
|
<>
|
|
<VatCompositionChart rutor={data.rutor} />
|
|
|
|
{/* Summary */}
|
|
<Card className="border-2">
|
|
<CardHeader>
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle>Momsdeklaration - {data.period.start} till {data.period.end}</CardTitle>
|
|
<Badge
|
|
className={
|
|
data.rutor.ruta49 > 0
|
|
? 'bg-orange-100 text-orange-800'
|
|
: data.rutor.ruta49 < 0
|
|
? 'bg-success/10 text-success'
|
|
: 'bg-gray-100 text-gray-800'
|
|
}
|
|
>
|
|
{data.rutor.ruta49 > 0
|
|
? `Att betala: ${formatAmount(data.rutor.ruta49)} kr`
|
|
: data.rutor.ruta49 < 0
|
|
? `Att återfå: ${formatAmount(Math.abs(data.rutor.ruta49))} kr`
|
|
: 'Ingen moms'}
|
|
</Badge>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-sm text-muted-foreground mb-4">
|
|
Baserat på {data.invoiceCount} fakturor och {data.transactionCount} transaktioner
|
|
</div>
|
|
|
|
<div className="grid md:grid-cols-2 gap-6">
|
|
{/* Utgående moms */}
|
|
<div>
|
|
<h4 className="font-semibold mb-3">Utgående moms (försäljning)</h4>
|
|
<div className="overflow-x-auto -mx-2 px-2"><table className="w-full text-sm min-w-[400px]">
|
|
<tbody>
|
|
{data.rutor.ruta05 > 0 && (
|
|
<tr className="border-b">
|
|
<td className="py-2">
|
|
<span className="font-mono text-xs bg-muted px-1 rounded mr-2">05</span>
|
|
Momspliktig försäljning
|
|
</td>
|
|
<td className="py-2 text-right">{formatAmount(data.rutor.ruta05)} kr</td>
|
|
</tr>
|
|
)}
|
|
<VatRutaRow
|
|
ruta="10"
|
|
label="Utgående moms 25%"
|
|
amount={data.rutor.ruta10}
|
|
baseAmount={data.breakdown.invoices.base25}
|
|
/>
|
|
<VatRutaRow
|
|
ruta="11"
|
|
label="Utgående moms 12%"
|
|
amount={data.rutor.ruta11}
|
|
baseAmount={data.breakdown.invoices.base12}
|
|
/>
|
|
<VatRutaRow
|
|
ruta="12"
|
|
label="Utgående moms 6%"
|
|
amount={data.rutor.ruta12}
|
|
baseAmount={data.breakdown.invoices.base6}
|
|
/>
|
|
<VatRutaRow
|
|
ruta="39"
|
|
label="Tjänster EU (omvänd skattskyldighet)"
|
|
amount={0}
|
|
baseAmount={data.rutor.ruta39}
|
|
noVat
|
|
/>
|
|
<VatRutaRow
|
|
ruta="40"
|
|
label="Export utanför EU"
|
|
amount={0}
|
|
baseAmount={data.rutor.ruta40}
|
|
noVat
|
|
/>
|
|
</tbody>
|
|
<tfoot>
|
|
<tr className="border-t-2 font-semibold">
|
|
<td className="py-2">Summa utgående</td>
|
|
<td className="py-2 text-right">
|
|
{formatAmount(
|
|
data.rutor.ruta10 + data.rutor.ruta11 + data.rutor.ruta12 +
|
|
data.rutor.ruta30 + data.rutor.ruta31 + data.rutor.ruta32
|
|
)} kr
|
|
</td>
|
|
</tr>
|
|
</tfoot>
|
|
</table></div>
|
|
|
|
{/* Omvänd skattskyldighet (inköp) */}
|
|
{(data.rutor.ruta20 > 0 || data.rutor.ruta21 > 0 || data.rutor.ruta22 > 0 || data.rutor.ruta23 > 0 || data.rutor.ruta24 > 0 ||
|
|
data.rutor.ruta30 > 0 || data.rutor.ruta31 > 0 || data.rutor.ruta32 > 0) && (
|
|
<>
|
|
<h4 className="font-semibold mb-3 mt-6">Omvänd skattskyldighet (inköp)</h4>
|
|
<div className="overflow-x-auto -mx-2 px-2"><table className="w-full text-sm min-w-[400px]">
|
|
<tbody>
|
|
<VatRutaRow ruta="20" label="Inköp av varor från annat EU-land" amount={0} baseAmount={data.rutor.ruta20} noVat />
|
|
<VatRutaRow ruta="21" label="Inköp av tjänster från annat EU-land" amount={0} baseAmount={data.rutor.ruta21} noVat />
|
|
<VatRutaRow ruta="22" label="Inköp av tjänster utanför EU" amount={0} baseAmount={data.rutor.ruta22} noVat />
|
|
<VatRutaRow ruta="23" label="Inköp av varor i Sverige" amount={0} baseAmount={data.rutor.ruta23} noVat />
|
|
<VatRutaRow ruta="24" label="Övriga inköp av tjänster i Sverige" amount={0} baseAmount={data.rutor.ruta24} noVat />
|
|
<VatRutaRow ruta="30" label="Utgående moms 25% (omvänd)" amount={data.rutor.ruta30} baseAmount={0} />
|
|
<VatRutaRow ruta="31" label="Utgående moms 12% (omvänd)" amount={data.rutor.ruta31} baseAmount={0} />
|
|
<VatRutaRow ruta="32" label="Utgående moms 6% (omvänd)" amount={data.rutor.ruta32} baseAmount={0} />
|
|
</tbody>
|
|
</table></div>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* Ingående moms */}
|
|
<div>
|
|
<h4 className="font-semibold mb-3">Ingående moms (avdragsgill)</h4>
|
|
<div className="overflow-x-auto -mx-2 px-2"><table className="w-full text-sm min-w-[400px]">
|
|
<tbody>
|
|
<tr className="border-b">
|
|
<td className="py-2">
|
|
<span className="font-mono text-xs bg-muted px-1 rounded mr-2">48</span>
|
|
Ingående moms att dra av
|
|
</td>
|
|
<td className="py-2 text-right">{formatAmount(data.rutor.ruta48)} kr</td>
|
|
</tr>
|
|
{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>
|
|
)}
|
|
</tbody>
|
|
<tfoot>
|
|
<tr className="border-t-2 font-semibold">
|
|
<td className="py-2">Summa ingående</td>
|
|
<td className="py-2 text-right">{formatAmount(data.rutor.ruta48)} kr</td>
|
|
</tr>
|
|
</tfoot>
|
|
</table></div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Net result */}
|
|
<div className="mt-6 pt-4 border-t-2">
|
|
<div className="flex justify-between items-center">
|
|
<div>
|
|
<span className="font-mono text-xs bg-muted px-1 rounded mr-2">49</span>
|
|
<span className="font-bold text-lg">
|
|
{data.rutor.ruta49 >= 0 ? 'Moms att betala' : 'Moms att återfå'}
|
|
</span>
|
|
</div>
|
|
<span
|
|
className={`text-xl font-bold ${
|
|
data.rutor.ruta49 > 0
|
|
? 'text-orange-600'
|
|
: data.rutor.ruta49 < 0
|
|
? 'text-success'
|
|
: ''
|
|
}`}
|
|
>
|
|
{formatAmount(Math.abs(data.rutor.ruta49))} kr
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</>
|
|
)}
|
|
|
|
{!data && !loading && !error && (
|
|
<Card>
|
|
<CardContent className="p-8 text-center text-muted-foreground">
|
|
Välj period och klicka "Hämta" för att se momsdeklaration.
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function VatRutaRow({
|
|
ruta,
|
|
label,
|
|
amount,
|
|
baseAmount,
|
|
noVat,
|
|
}: {
|
|
ruta: string
|
|
label: string
|
|
amount: number
|
|
baseAmount: number
|
|
noVat?: boolean
|
|
}) {
|
|
// Don't show rows with zero values
|
|
if (baseAmount === 0 && amount === 0) return null
|
|
|
|
return (
|
|
<>
|
|
<tr className="border-b">
|
|
<td className="py-2">
|
|
<span className="font-mono text-xs bg-muted px-1 rounded mr-2">{ruta}</span>
|
|
{label}
|
|
</td>
|
|
<td className="py-2 text-right">{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">{formatAmount(baseAmount)} kr</td>
|
|
</tr>
|
|
)}
|
|
</>
|
|
)
|
|
}
|
|
|
|
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
|
|
}
|
|
reconciliation: {
|
|
supplier_ledger_total: number
|
|
account_2440_balance: number
|
|
difference: number
|
|
is_reconciled: boolean
|
|
} | null
|
|
}
|
|
|
|
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 fetchData = async () => {
|
|
setLoading(true)
|
|
setError(null)
|
|
try {
|
|
const res = await fetch(`/api/reports/supplier-ledger?period_id=${periodId}`)
|
|
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])
|
|
|
|
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">
|
|
{/* 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 font-medium tabular-nums">{formatAmount(ledger.total_outstanding)} kr</p>
|
|
<p className="text-xs text-muted-foreground">{ledger.unpaid_count} fakturor</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 font-medium 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 font-medium 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>
|
|
<tr className="border-b text-left text-muted-foreground">
|
|
<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) => (
|
|
<tr key={entry.supplier_id} className="border-b last:border-0">
|
|
<td className="py-2">{entry.supplier_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>
|
|
))}
|
|
</tbody>
|
|
<tfoot>
|
|
<tr className="font-semibold border-t-2">
|
|
<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">
|
|
{reconciliation.is_reconciled ? (
|
|
<Badge className="bg-success/10 text-success">Avstämd</Badge>
|
|
) : (
|
|
<Badge variant="destructive">Ej avstämd - kontrollera bokföring</Badge>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// --- General Ledger (Huvudbok) ---
|
|
|
|
interface GeneralLedgerData {
|
|
accounts: {
|
|
account_number: string
|
|
account_name: string
|
|
opening_balance: number
|
|
lines: {
|
|
date: string
|
|
voucher_series: string
|
|
voucher_number: number
|
|
description: string
|
|
source_type: string
|
|
debit: number
|
|
credit: number
|
|
balance: number
|
|
}[]
|
|
closing_balance: number
|
|
total_debit: number
|
|
total_credit: number
|
|
}[]
|
|
period: { start: string; end: string }
|
|
}
|
|
|
|
function GeneralLedgerView({ periodId }: { periodId: string }) {
|
|
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 = async () => {
|
|
setLoading(true)
|
|
setError(null)
|
|
try {
|
|
const params = new URLSearchParams({ period_id: periodId })
|
|
if (accountFrom) params.set('account_from', accountFrom)
|
|
if (accountTo) params.set('account_to', accountTo)
|
|
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)
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (periodId) fetchData()
|
|
}, [periodId])
|
|
|
|
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">
|
|
{/* 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>
|
|
<tr className="border-b text-left text-muted-foreground">
|
|
<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">
|
|
{line.voucher_series}{line.voucher_number}
|
|
</td>
|
|
<td className="py-1.5">{line.date}</td>
|
|
<td className="py-1.5 truncate max-w-[200px]">{line.description}</td>
|
|
<td className="py-1.5 text-right">
|
|
{line.debit > 0 ? formatAmount(line.debit) : ''}
|
|
</td>
|
|
<td className="py-1.5 text-right">
|
|
{line.credit > 0 ? formatAmount(line.credit) : ''}
|
|
</td>
|
|
<td className="py-1.5 text-right font-mono">{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 }
|
|
}
|
|
|
|
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">
|
|
{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>
|
|
<tr className="border-b text-left text-muted-foreground">
|
|
<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">
|
|
{entry.voucher_series}{entry.voucher_number}
|
|
</td>
|
|
<td className="py-2">{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">{formatAmount(entry.total_debit)}</td>
|
|
<td className="py-2 text-right">{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
|
|
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
|
|
}
|
|
reconciliation: {
|
|
ar_ledger_total: number
|
|
account_1510_balance: number
|
|
difference: number
|
|
is_reconciled: boolean
|
|
} | null
|
|
}
|
|
|
|
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 fetchData = async () => {
|
|
setLoading(true)
|
|
setError(null)
|
|
try {
|
|
const res = await fetch(`/api/reports/ar-ledger?period_id=${periodId}`)
|
|
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])
|
|
|
|
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">
|
|
{/* 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 font-medium tabular-nums">{formatAmount(ledger.total_outstanding)} kr</p>
|
|
<p className="text-xs text-muted-foreground">{ledger.unpaid_count} fakturor</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 font-medium 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 font-medium 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>
|
|
<tr className="border-b text-left text-muted-foreground">
|
|
<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 && entry.invoices.map((inv) => (
|
|
<tr key={inv.invoice_id} className="bg-muted/30 border-b last:border-0">
|
|
<td></td>
|
|
<td className="py-1 text-xs" colSpan={2}>
|
|
<span className="font-mono">{inv.invoice_number}</span>
|
|
<span className="text-muted-foreground ml-2">{inv.invoice_date}</span>
|
|
<span className="text-muted-foreground ml-2">förfaller {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)}` : ''}
|
|
</td>
|
|
<td></td>
|
|
<td className="py-1 text-right text-xs font-medium">
|
|
{formatAmount(inv.outstanding)} {inv.currency}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</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><AccountNumber number="1510" /> saldo (huvudbok)</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">
|
|
{reconciliation.is_reconciled ? (
|
|
<Badge className="bg-success/10 text-success">Avstämd</Badge>
|
|
) : (
|
|
<Badge variant="destructive">Ej avstämd - kontrollera bokföring</Badge>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|