feat: consolidate booking templates from 100 to 48 and improve codebase
Reduce booking template library to eliminate duplicate suggestions when users describe transactions. Templates with identical accounting treatment (same account + VAT) are merged, keywords consolidated, and the entire subscriptions group is eliminated. Also includes prior work on reports, extensions, and transaction improvements. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
9d684d7e9f
commit
6956a757f3
@@ -0,0 +1,297 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Download, AlertCircle } from 'lucide-react'
|
||||
import { AccountNumber } from '@/components/ui/account-number'
|
||||
import type { NEDeclaration } from '@/lib/reports/ne-bilaga/types'
|
||||
|
||||
function formatAmount(amount: number): string {
|
||||
return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
}
|
||||
|
||||
export function NEDeclarationView({ periodId }: { periodId: string }) {
|
||||
const [data, setData] = useState<NEDeclaration | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const fetchDeclaration = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(`/api/reports/ne-bilaga?period_id=${periodId}`)
|
||||
const result = await res.json()
|
||||
if (result.error) {
|
||||
setError(result.error)
|
||||
} else {
|
||||
setData(result.data)
|
||||
}
|
||||
} catch {
|
||||
setError('Kunde inte hämta NE-bilaga')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const downloadSRU = () => {
|
||||
window.open(`/api/reports/ne-bilaga?period_id=${periodId}&format=sru`, '_blank')
|
||||
}
|
||||
|
||||
// NE ruta labels
|
||||
const rutaLabels: Record<string, string> = {
|
||||
R1: 'Försäljning med moms (25%)',
|
||||
R2: 'Momsfria intäkter',
|
||||
R3: 'Bil/bostadsförmån',
|
||||
R4: 'Ränteintäkter',
|
||||
R5: 'Varuinköp',
|
||||
R6: 'Övriga kostnader',
|
||||
R7: 'Lönekostnader',
|
||||
R8: 'Räntekostnader',
|
||||
R9: 'Avskrivningar fastighet',
|
||||
R10: 'Avskrivningar övriga tillgångar',
|
||||
R11: 'Årets resultat',
|
||||
}
|
||||
|
||||
// Categorize rutor
|
||||
const revenueRutor = ['R1', 'R2', 'R3', 'R4'] as const
|
||||
const expenseRutor = ['R5', 'R6', 'R7', 'R8', 'R9', 'R10'] as const
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Info card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">NE-bilaga (Enskild firma)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
NE-bilagan används för att deklarera resultat från enskild näringsverksamhet.
|
||||
Uppgifterna hämtas från bokföringen för valt räkenskapsår.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={fetchDeclaration} disabled={loading}>
|
||||
{loading ? 'Laddar...' : 'Hämta NE-bilaga'}
|
||||
</Button>
|
||||
{data && (
|
||||
<Button variant="outline" onClick={downloadSRU}>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Ladda ner SRU-fil
|
||||
</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 && (
|
||||
<>
|
||||
{/* Warnings */}
|
||||
{data.warnings.length > 0 && (
|
||||
<Card className="border-orange-200 bg-orange-50">
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="h-5 w-5 text-orange-600 mt-0.5" />
|
||||
<div>
|
||||
{data.warnings.map((warning, i) => (
|
||||
<p key={i} className="text-sm text-orange-800">{warning}</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Company info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>
|
||||
{data.companyInfo.companyName}
|
||||
</CardTitle>
|
||||
<Badge className="bg-blue-100 text-blue-800">
|
||||
{data.fiscalYear.name}
|
||||
</Badge>
|
||||
</div>
|
||||
{data.companyInfo.orgNumber && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Org.nr: {data.companyInfo.orgNumber}
|
||||
</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
{/* Revenue section */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Intäkter</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<table className="w-full text-sm">
|
||||
<tbody>
|
||||
{revenueRutor.map((ruta) => {
|
||||
const value = data.rutor[ruta]
|
||||
const breakdown = data.breakdown[ruta]
|
||||
return (
|
||||
<NEDeclarationRow
|
||||
key={ruta}
|
||||
ruta={ruta}
|
||||
label={rutaLabels[ruta]}
|
||||
amount={value}
|
||||
accounts={breakdown?.accounts || []}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="border-t-2 font-semibold">
|
||||
<td className="py-2">Summa intäkter</td>
|
||||
<td className="py-2 text-right">
|
||||
{formatAmount(
|
||||
data.rutor.R1 + data.rutor.R2 + data.rutor.R3 + data.rutor.R4
|
||||
)} kr
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Expenses section */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Kostnader</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<table className="w-full text-sm">
|
||||
<tbody>
|
||||
{expenseRutor.map((ruta) => {
|
||||
const value = data.rutor[ruta]
|
||||
const breakdown = data.breakdown[ruta]
|
||||
return (
|
||||
<NEDeclarationRow
|
||||
key={ruta}
|
||||
ruta={ruta}
|
||||
label={rutaLabels[ruta]}
|
||||
amount={value}
|
||||
accounts={breakdown?.accounts || []}
|
||||
isExpense
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="border-t-2 font-semibold">
|
||||
<td className="py-2">Summa kostnader</td>
|
||||
<td className="py-2 text-right">
|
||||
-{formatAmount(
|
||||
data.rutor.R5 + data.rutor.R6 + data.rutor.R7 +
|
||||
data.rutor.R8 + data.rutor.R9 + data.rutor.R10
|
||||
)} kr
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Result */}
|
||||
<Card className="border-2">
|
||||
<CardContent className="py-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<span className="font-mono text-xs bg-muted px-1 rounded mr-2">R11</span>
|
||||
<span className="font-bold text-xl">Årets resultat</span>
|
||||
</div>
|
||||
<span
|
||||
className={`text-2xl font-bold ${
|
||||
data.rutor.R11 >= 0 ? 'text-green-600' : 'text-red-600'
|
||||
}`}
|
||||
>
|
||||
{formatAmount(data.rutor.R11)} kr
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!data && !loading && !error && (
|
||||
<Card>
|
||||
<CardContent className="p-8 text-center text-muted-foreground">
|
||||
Klicka "Hämta NE-bilaga" för att generera deklarationsunderlaget.
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NEDeclarationRow({
|
||||
ruta,
|
||||
label,
|
||||
amount,
|
||||
accounts,
|
||||
isExpense,
|
||||
}: {
|
||||
ruta: string
|
||||
label: string
|
||||
amount: number
|
||||
accounts: Array<{ accountNumber: string; accountName: string; amount: number }>
|
||||
isExpense?: boolean
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
// Don't show rows with zero values
|
||||
if (amount === 0 && accounts.length === 0) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<tr
|
||||
className="border-b cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => accounts.length > 0 && setExpanded(!expanded)}
|
||||
>
|
||||
<td className="py-2">
|
||||
<span className="font-mono text-xs bg-muted px-1 rounded mr-2">{ruta}</span>
|
||||
{label}
|
||||
{accounts.length > 0 && (
|
||||
<span className="text-xs text-muted-foreground ml-2">
|
||||
({accounts.length} konton)
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 text-right">
|
||||
{isExpense && amount > 0 ? '-' : ''}{formatAmount(Math.abs(amount))} kr
|
||||
</td>
|
||||
</tr>
|
||||
{expanded && accounts.length > 0 && (
|
||||
<tr>
|
||||
<td colSpan={2} className="py-2 pl-8 bg-muted/30">
|
||||
<table className="w-full text-xs">
|
||||
<tbody>
|
||||
{accounts.map((acc) => (
|
||||
<tr key={acc.accountNumber}>
|
||||
<td className="py-1"><AccountNumber number={acc.accountNumber} name={acc.accountName} size="sm" /></td>
|
||||
<td className="py-1">{acc.accountName}</td>
|
||||
<td className="py-1 text-right">
|
||||
{isExpense && acc.amount > 0 ? '-' : ''}{formatAmount(Math.abs(acc.amount))} kr
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Download, AlertCircle } from 'lucide-react'
|
||||
import { AccountNumber } from '@/components/ui/account-number'
|
||||
import type { SRUExportResult } from '@/lib/reports/sru-export/types'
|
||||
import type { SRUCoverageStats } from '@/lib/reports/sru-export/sru-engine'
|
||||
|
||||
function formatAmount(amount: number): string {
|
||||
return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
}
|
||||
|
||||
export function SRUExportView({ periodId }: { periodId: string }) {
|
||||
const [data, setData] = useState<SRUExportResult | null>(null)
|
||||
const [coverage, setCoverage] = useState<SRUCoverageStats | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetchCoverage()
|
||||
}, [])
|
||||
|
||||
async function fetchCoverage() {
|
||||
try {
|
||||
const res = await fetch('/api/reports/sru-export/coverage')
|
||||
const result = await res.json()
|
||||
if (result.data) {
|
||||
setCoverage(result.data)
|
||||
}
|
||||
} catch {
|
||||
// Coverage is optional, ignore errors
|
||||
}
|
||||
}
|
||||
|
||||
const fetchExport = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(`/api/reports/sru-export?period_id=${periodId}&format=json`)
|
||||
const result = await res.json()
|
||||
if (result.error) {
|
||||
setError(result.error)
|
||||
} else {
|
||||
setData(result.data)
|
||||
}
|
||||
} catch {
|
||||
setError('Kunde inte hämta SRU-export')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const downloadSRU = () => {
|
||||
window.open(`/api/reports/sru-export?period_id=${periodId}&format=sru`, '_blank')
|
||||
}
|
||||
|
||||
const formLabel = data?.formType === 'INK2' ? 'INK2 (Aktiebolag)' : 'NE (Enskild firma)'
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Info card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">SRU-export</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Generera SRU-fil (Standardiserat Räkenskapsutdrag) för elektronisk inlämning
|
||||
till Skatteverket. Blanketttyp bestäms automatiskt utifrån företagsform.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={fetchExport} disabled={loading}>
|
||||
{loading ? 'Laddar...' : 'Förhandsgranska'}
|
||||
</Button>
|
||||
{data && (
|
||||
<Button variant="outline" onClick={downloadSRU}>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Ladda ner SRU-fil
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Coverage warning */}
|
||||
{coverage && coverage.accountsWithoutSRU > 0 && (
|
||||
<Card className="border-orange-200 bg-orange-50">
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="h-5 w-5 text-orange-600 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm text-orange-800">
|
||||
{coverage.accountsWithoutSRU} av {coverage.totalAccounts} konton saknar SRU-kod
|
||||
({coverage.coveragePercent}% täckning).
|
||||
Konton utan SRU-kod inkluderas inte i exporten.
|
||||
</p>
|
||||
{coverage.missingAccounts.length <= 5 && (
|
||||
<ul className="text-xs text-orange-700 mt-1 space-y-0.5">
|
||||
{coverage.missingAccounts.map((a) => (
|
||||
<li key={a.accountNumber}>
|
||||
{a.accountNumber} — {a.accountName}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</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 && (
|
||||
<>
|
||||
{/* Warnings */}
|
||||
{data.warnings.length > 0 && (
|
||||
<Card className="border-orange-200 bg-orange-50">
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="h-5 w-5 text-orange-600 mt-0.5" />
|
||||
<div>
|
||||
{data.warnings.map((warning, i) => (
|
||||
<p key={i} className="text-sm text-orange-800">{warning}</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Company + form info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>{data.companyName || 'Okänt företag'}</CardTitle>
|
||||
<div className="flex gap-2">
|
||||
<Badge className="bg-blue-100 text-blue-800">{formLabel}</Badge>
|
||||
<Badge className="bg-blue-100 text-blue-800">{data.fiscalYear.name}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
{data.orgNumber && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Org.nr: {data.orgNumber}
|
||||
</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
{/* SRU balances table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">SRU-poster</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{data.balances.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Inga poster med belopp att exportera.
|
||||
</p>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="py-2 w-20">SRU-kod</th>
|
||||
<th className="py-2">Beskrivning</th>
|
||||
<th className="py-2 w-32 text-right">Belopp</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.balances.map((b) => (
|
||||
<SRUBalanceRow key={b.sruCode} balance={b} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!data && !loading && !error && (
|
||||
<Card>
|
||||
<CardContent className="p-8 text-center text-muted-foreground">
|
||||
Klicka "Förhandsgranska" för att se SRU-uppgifter för valt räkenskapsår.
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SRUBalanceRow({
|
||||
balance,
|
||||
}: {
|
||||
balance: SRUExportResult['balances'][number]
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<tr
|
||||
className="border-b cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => balance.accounts.length > 0 && setExpanded(!expanded)}
|
||||
>
|
||||
<td className="py-2 font-mono">{balance.sruCode}</td>
|
||||
<td className="py-2">
|
||||
{balance.description}
|
||||
{balance.accounts.length > 0 && (
|
||||
<span className="text-xs text-muted-foreground ml-2">
|
||||
({balance.accounts.length} konton)
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 text-right">{formatAmount(balance.amount)} kr</td>
|
||||
</tr>
|
||||
{expanded && balance.accounts.length > 0 && (
|
||||
<tr>
|
||||
<td colSpan={3} className="py-2 pl-8 bg-muted/30">
|
||||
<table className="w-full text-xs">
|
||||
<tbody>
|
||||
{balance.accounts.map((acc) => (
|
||||
<tr key={acc.accountNumber}>
|
||||
<td className="py-1 w-16"><AccountNumber number={acc.accountNumber} name={acc.accountName} size="sm" /></td>
|
||||
<td className="py-1">{acc.accountName}</td>
|
||||
<td className="py-1 text-right">{formatAmount(acc.amount)} kr</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -163,7 +163,7 @@ export default function TransactionInboxCard({
|
||||
) : null}
|
||||
{topSuggestion.label}
|
||||
{topSuggestion.account && (
|
||||
<span className="ml-1 text-muted-foreground font-normal">
|
||||
<span className="ml-1 opacity-70 font-normal">
|
||||
({formatAccountWithName(topSuggestion.account)})
|
||||
</span>
|
||||
)}
|
||||
@@ -212,30 +212,6 @@ export default function TransactionInboxCard({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Private button */}
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-8 text-xs text-muted-foreground"
|
||||
onClick={() => onMarkPrivate(transaction.id)}
|
||||
disabled={isProcessing || isDisabled}
|
||||
>
|
||||
Privat
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-[260px]">
|
||||
<p className="text-sm leading-relaxed">
|
||||
{entityType === 'aktiebolag'
|
||||
? 'Privat utgift med företagets kort \u2014 bokförs som skuld till ägaren (konto 2893)'
|
||||
: 'Privat uttag \u2014 bokförs mot konto 2013 (Övriga egna uttag)'}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
{/* Describe transaction */}
|
||||
{onOpenDescribe && (
|
||||
<Button
|
||||
|
||||
Reference in New Issue
Block a user