feat: BAS data overhaul, currency revaluation, expenses, UI polish, and cleanup

- Update BAS account catalog with comprehensive SRU codes and K2 flags
- Add currency revaluation service with tests and API route
- Add expenses page and account deletion API
- Enhance booking templates with new patterns and improved tests
- Improve transaction categorization with template picker and description matching
- Polish dashboard, onboarding, import, and transaction UIs
- Refactor year-end service for multi-step closing
- Move SRU generator to ne-bilaga, remove standalone SRU export
- Remove unused dev docs, mock data, and extension hooks
- Add invoice delivery note sequences migration

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-03-04 14:19:56 +01:00
co-authored by Claude Opus 4.6
parent a9bf24ce0b
commit 66a4027f1e
104 changed files with 4964 additions and 5501 deletions
+12 -12
View File
@@ -161,12 +161,6 @@ export default function AccountCombobox({ value, accounts, onChange }: AccountCo
}, 150)
}
// Find matching account for helper text
const matchedAccount = useMemo(() => {
if (!value || value.length !== 4) return null
return accounts.find((a) => a.account_number === value) || null
}, [value, accounts])
return (
<div ref={containerRef} className="relative">
<Input
@@ -181,12 +175,6 @@ export default function AccountCombobox({ value, accounts, onChange }: AccountCo
autoComplete="off"
/>
{/* Account name helper text */}
{matchedAccount && (
<p className="text-[11px] text-muted-foreground truncate mt-0.5 leading-tight">
{matchedAccount.account_name}
</p>
)}
{/* Dropdown */}
{isOpen && flatList.length > 0 && (
@@ -225,6 +213,18 @@ export default function AccountCombobox({ value, accounts, onChange }: AccountCo
))}
</div>
)}
{/* Empty state */}
{isOpen && search.trim() && flatList.length === 0 && (
<div className="absolute z-50 top-full left-0 mt-1 w-64 rounded-md border border-input bg-card shadow-md p-3">
<p className="text-sm text-muted-foreground">
Hittade inget konto som matchar.
</p>
<p className="text-xs text-muted-foreground mt-1">
Kontot kan behöva aktiveras i din kontoplan.
</p>
</div>
)}
</div>
)
}
@@ -40,18 +40,18 @@ interface ReferenceAccount extends BASReferenceAccount {
// ---------------------------------------------------------------------------
const CLASS_LABELS: Record<number, string> = {
1: 'Tillgangar',
1: 'Tillgångar',
2: 'Eget kapital och skulder',
3: 'Rorelseintatker',
4: 'Varuinkop och material',
5: 'Ovriga externa kostnader',
6: 'Ovriga externa kostnader',
3: 'Rörelseintäkter',
4: 'Varuinköp och material',
5: 'Övriga externa kostnader',
6: 'Övriga externa kostnader',
7: 'Personalkostnader och avskrivningar',
8: 'Finansiella poster och resultat',
}
const TYPE_LABELS: Record<string, string> = {
asset: 'Tillgang',
asset: 'Tillgång',
liability: 'Skuld',
equity: 'EK',
revenue: 'Intakt',
@@ -315,7 +315,7 @@ export default function ChartOfAccountsManager() {
onCheckedChange={setHideK2Excluded}
className="scale-75"
/>
<span className="text-muted-foreground">Dolj K2-undantagna</span>
<span className="text-muted-foreground">Dölj K2-undantagna</span>
</label>
)}
</div>
@@ -324,7 +324,7 @@ export default function ChartOfAccountsManager() {
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Sok konto (nummer eller namn)..."
placeholder="Sök konto (nummer eller namn)..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
@@ -454,7 +454,7 @@ export default function ChartOfAccountsManager() {
{filteredAccounts.length === 0 && (
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
{searchQuery ? 'Inga konton matchar sokningen' : 'Inga konton i kontoplanen'}
{searchQuery ? 'Inga konton matchar sökningen' : 'Inga konton i kontoplanen'}
</CardContent>
</Card>
)}
@@ -554,7 +554,7 @@ export default function ChartOfAccountsManager() {
) : (
<Plus className="mr-1 h-3 w-3" />
)}
Lagg till
Lägg till
</Button>
)}
</td>
@@ -571,7 +571,7 @@ export default function ChartOfAccountsManager() {
{filteredReference.length === 0 && (
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
Inga konton matchar sokningen
Inga konton matchar sökningen
</CardContent>
</Card>
)}
+44 -27
View File
@@ -116,6 +116,19 @@ export default function JournalEntryForm({
if (account) {
updated[index].line_description = account.account_name
}
// Auto-fill balancing amount when both amount fields are empty
if (!updated[index].debit_amount && !updated[index].credit_amount) {
const otherLines = updated.filter((_, i) => i !== index)
const otherDebit = otherLines.reduce((sum, l) => sum + (parseFloat(l.debit_amount) || 0), 0)
const otherCredit = otherLines.reduce((sum, l) => sum + (parseFloat(l.credit_amount) || 0), 0)
const diff = Math.round((otherCredit - otherDebit) * 100) / 100
if (diff > 0) {
updated[index].debit_amount = diff.toFixed(2)
} else if (diff < 0) {
updated[index].credit_amount = Math.abs(diff).toFixed(2)
}
}
}
setLines(updated)
@@ -191,7 +204,7 @@ export default function JournalEntryForm({
if (linkFailCount > 0) {
toast({
title: 'Underlag kunde inte bifogas',
description: `${linkFailCount} fil(er) kunde inte lankas till verifikationen. Forsok igen via bokforingssidan.`,
description: `${linkFailCount} fil(er) kunde inte länkas till verifikationen. Försök igen via bokföringssidan.`,
variant: 'destructive',
})
}
@@ -217,7 +230,7 @@ export default function JournalEntryForm({
const formContent = (
<div className="space-y-4">
<div className="grid grid-cols-3 gap-4">
<div className={`grid gap-4 ${embedded && initialDate ? 'grid-cols-2' : 'grid-cols-3'}`}>
<div>
<Label>Räkenskapsår</Label>
<select
@@ -232,14 +245,16 @@ export default function JournalEntryForm({
))}
</select>
</div>
<div>
<Label>Datum</Label>
<Input
type="date"
value={entryDate}
onChange={(e) => setEntryDate(e.target.value)}
/>
</div>
{!(embedded && initialDate) && (
<div>
<Label>Datum</Label>
<Input
type="date"
value={entryDate}
onChange={(e) => setEntryDate(e.target.value)}
/>
</div>
)}
<div>
<Label>Beskrivning</Label>
<Input
@@ -255,24 +270,24 @@ export default function JournalEntryForm({
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="py-2 w-24">Konto</th>
<th className="py-2">Beskrivning</th>
<th className="py-2 w-32 text-right">Debet</th>
<th className="py-2 w-32 text-right">Kredit</th>
<th className="py-2 w-28">Konto</th>
<th className="py-2 px-1">Beskrivning</th>
<th className="py-2 w-32 px-1 text-right">Debet</th>
<th className="py-2 w-32 px-1 text-right">Kredit</th>
<th className="py-2 w-10"></th>
</tr>
</thead>
<tbody>
{lines.map((line, index) => (
<tr key={index} className="border-b">
<td className="py-1">
<td className="py-1.5">
<AccountCombobox
value={line.account_number}
accounts={accounts}
onChange={(num) => updateLine(index, 'account_number', num)}
/>
</td>
<td className="py-1 px-1">
<td className="py-1.5 px-1">
<Input
value={line.line_description}
onChange={(e) => updateLine(index, 'line_description', e.target.value)}
@@ -280,7 +295,7 @@ export default function JournalEntryForm({
className="h-8"
/>
</td>
<td className="py-1">
<td className="py-1.5 px-1">
<Input
type="number"
value={line.debit_amount}
@@ -291,7 +306,7 @@ export default function JournalEntryForm({
step="0.01"
/>
</td>
<td className="py-1">
<td className="py-1.5 px-1">
<Input
type="number"
value={line.credit_amount}
@@ -302,7 +317,7 @@ export default function JournalEntryForm({
step="0.01"
/>
</td>
<td className="py-1">
<td className="py-1.5">
<Button
variant="ghost"
size="sm"
@@ -318,18 +333,18 @@ export default function JournalEntryForm({
</tbody>
<tfoot>
<tr className="font-semibold">
<td colSpan={2} className="py-2">
<td colSpan={2} className="py-2 px-1">
Summa
</td>
<td
className={`py-2 text-right ${
className={`py-2 px-1 text-right ${
isBalanced ? 'text-green-600' : 'text-red-600'
}`}
>
{totalDebit.toLocaleString('sv-SE', { minimumFractionDigits: 2 })}
</td>
<td
className={`py-2 text-right ${
className={`py-2 px-1 text-right ${
isBalanced ? 'text-green-600' : 'text-red-600'
}`}
>
@@ -390,7 +405,7 @@ export default function JournalEntryForm({
onConfirm={handleConfirm}
isSubmitting={isSubmitting}
title="Granska verifikation"
warningText="En verifikation skapas och kan inte ändras efteråt. Korrigeringar görs genom storno."
warningText={embedded ? '' : 'En verifikation skapas och kan inte ändras efteråt. Korrigeringar görs genom storno.'}
>
<JournalEntryReviewContent
periodName={periods.find((p) => p.id === selectedPeriod)?.name || ''}
@@ -400,6 +415,8 @@ export default function JournalEntryForm({
totalDebit={totalDebit}
totalCredit={totalCredit}
attachmentCount={uploadedFiles.filter((f) => f.status === 'uploaded').length}
showBalanceBadge={!embedded}
hideDate={!!embedded}
/>
</ConfirmationDialog>
@@ -413,16 +430,16 @@ export default function JournalEntryForm({
}}
isSubmitting={false}
title="Underlag saknas"
warningText="Ingen verifikation har bifogats. Enligt bokforingslagen (BFL) kravs underlag for varje bokforingspost."
confirmLabel="Fortsatt anda"
warningText="Ingen verifikation har bifogats. Enligt bokföringslagen (BFL) krävs underlag för varje bokföringspost."
confirmLabel="Fortsätt ändå"
>
<div className="flex items-start gap-3 rounded-lg border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-950/30 p-4">
<AlertTriangle className="h-5 w-5 text-amber-600 dark:text-amber-400 mt-0.5 shrink-0" />
<div className="text-sm text-amber-800 dark:text-amber-300">
<p className="font-medium mb-1">Inget underlag bifogat</p>
<p>
Enligt bokforingslagen (BFL 5 kap. 6-7 §§) ska varje bokforingspost ha en verifikation som
underlag. Du kan bifoga underlag nu eller fortsatta utan.
Enligt bokföringslagen (BFL 5 kap. 6-7 §§) ska varje bokföringspost ha en verifikation som
underlag. Du kan bifoga underlag nu eller fortsätta utan.
</p>
</div>
</div>
@@ -81,39 +81,6 @@ export default function JournalEntryList({ periodId }: Props) {
setExpandedId(expandedId === id ? null : id)
}
const statusLabel = (status: string) => {
switch (status) {
case 'posted':
return <Badge variant="success">Bokförd</Badge>
case 'draft':
return <Badge variant="secondary">Utkast</Badge>
case 'reversed':
return <Badge variant="destructive">Makulerad</Badge>
default:
return <Badge variant="outline">{status}</Badge>
}
}
const sourceLabel = (source: string) => {
const labels: Record<string, string> = {
manual: 'Manuell',
bank_transaction: 'Banktransaktion',
invoice_created: 'Faktura',
invoice_paid: 'Betalning',
credit_note: 'Kreditfaktura',
salary_payment: 'Lön',
opening_balance: 'Ingående balans',
year_end: 'Årsbokslut',
supplier_invoice_registered: 'Leverantörsfaktura',
supplier_invoice_paid: 'Leverantörsbetalning',
supplier_invoice_cash_payment: 'Kontant leverantörsbetalning',
import: 'Import',
storno: 'Storno',
correction: 'Korrigering',
}
return labels[source] || source
}
if (loading) {
return (
<Card>
@@ -199,10 +166,6 @@ export default function JournalEntryList({ periodId }: Props) {
</span>
)
)}
<Badge variant="outline" className="text-xs mr-2">
{sourceLabel(entry.source_type)}
</Badge>
{statusLabel(entry.status)}
</div>
</button>
@@ -19,6 +19,8 @@ interface JournalEntryReviewContentProps {
totalDebit: number
totalCredit: number
attachmentCount?: number
showBalanceBadge?: boolean
hideDate?: boolean
}
function formatAmount(amount: number): string {
@@ -33,6 +35,8 @@ export function JournalEntryReviewContent({
totalDebit,
totalCredit,
attachmentCount,
showBalanceBadge = true,
hideDate = false,
}: JournalEntryReviewContentProps) {
const activeLines = lines.filter(
(l) => l.account_number && (l.debit_amount || l.credit_amount)
@@ -42,15 +46,17 @@ export function JournalEntryReviewContent({
<div className="space-y-4">
{/* Header info */}
<div className="bg-muted rounded-lg p-4 space-y-2">
<div className="grid grid-cols-2 gap-4 text-sm">
<div className={`grid gap-4 text-sm ${hideDate ? 'grid-cols-1' : 'grid-cols-2'}`}>
<div>
<span className="text-muted-foreground">Räkenskapsår</span>
<p className="font-medium">{periodName}</p>
</div>
<div>
<span className="text-muted-foreground">Datum</span>
<p className="font-medium">{entryDate}</p>
</div>
{!hideDate && (
<div>
<span className="text-muted-foreground">Datum</span>
<p className="font-medium">{entryDate}</p>
</div>
)}
</div>
<div className="text-sm">
<span className="text-muted-foreground">Beskrivning</span>
@@ -59,18 +65,22 @@ export function JournalEntryReviewContent({
</div>
{/* Balance status */}
<div className="flex items-center gap-2">
<Badge className="bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400">
<CheckCircle2 className="h-3 w-3 mr-1" />
Debet = Kredit
</Badge>
{attachmentCount != null && attachmentCount > 0 && (
<Badge variant="outline">
<Paperclip className="h-3 w-3 mr-1" />
{attachmentCount} {attachmentCount === 1 ? 'underlag' : 'underlag'}
</Badge>
)}
</div>
{(showBalanceBadge || (attachmentCount != null && attachmentCount > 0)) && (
<div className="flex items-center gap-2">
{showBalanceBadge && (
<Badge className="bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400">
<CheckCircle2 className="h-3 w-3 mr-1" />
Debet = Kredit
</Badge>
)}
{attachmentCount != null && attachmentCount > 0 && (
<Badge variant="outline">
<Paperclip className="h-3 w-3 mr-1" />
{attachmentCount} {attachmentCount === 1 ? 'underlag' : 'underlag'}
</Badge>
)}
</div>
)}
{/* Debit/Credit table */}
<table className="w-full text-sm">
+101 -176
View File
@@ -4,7 +4,6 @@ import { useState, useEffect } from 'react'
import Link from 'next/link'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { cn, formatCurrency } from '@/lib/utils'
import {
calculateEFTax,
@@ -16,18 +15,14 @@ import { UpcomingDeadlinesWidget } from '@/components/deadlines/UpcomingDeadline
import { TaxTodoWidget } from '@/components/deadlines/TaxTodoWidget'
import NewUserChecklist from '@/components/onboarding/NewUserChecklist'
import {
TrendingUp,
TrendingDown,
Receipt,
ArrowLeftRight,
ChevronDown,
ChevronUp,
ArrowRight,
Camera,
Users,
Landmark,
CheckCircle2,
ClipboardList,
FileWarning,
} from 'lucide-react'
import { getExtensionDefinition } from '@/lib/extensions/sectors'
@@ -113,17 +108,14 @@ export default function DashboardContent({ firstName, settings, summary, onboard
<Link key="overdue" href="/invoices?status=unpaid" className="group">
<Card className="h-full border-l-2 border-l-destructive hover:bg-muted/20 transition-colors">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Receipt className="h-4 w-4 text-destructive flex-shrink-0" />
<div>
<p className="font-medium text-sm">Förfallna fakturor</p>
<p className="text-xs text-muted-foreground mt-0.5">
{summary.overdueInvoicesCount} st
</p>
</div>
<div className="flex items-center gap-3">
<Receipt className="h-4 w-4 text-destructive flex-shrink-0" />
<div>
<p className="font-medium text-sm">Förfallna fakturor</p>
<p className="text-xs text-muted-foreground mt-0.5">
{summary.overdueInvoicesCount} st
</p>
</div>
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground/50 group-hover:text-muted-foreground group-hover:translate-x-0.5 transition-all" />
</div>
</CardContent>
</Card>
@@ -136,17 +128,14 @@ export default function DashboardContent({ firstName, settings, summary, onboard
<Link key="unpaid" href="/invoices?status=unpaid" className="group">
<Card className="h-full border-l-2 border-l-warning hover:bg-muted/20 transition-colors">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Receipt className="h-4 w-4 text-warning-foreground flex-shrink-0" />
<div>
<p className="font-medium text-sm">Obetalda fakturor</p>
<p className="text-xs text-muted-foreground mt-0.5">
{summary.unpaidInvoicesCount - summary.overdueInvoicesCount} st · {formatCurrency(summary.unpaidInvoicesTotal)}
</p>
</div>
<div className="flex items-center gap-3">
<Receipt className="h-4 w-4 text-warning-foreground flex-shrink-0" />
<div>
<p className="font-medium text-sm">Obetalda fakturor</p>
<p className="text-xs text-muted-foreground mt-0.5">
{summary.unpaidInvoicesCount - summary.overdueInvoicesCount} st · {formatCurrency(summary.unpaidInvoicesTotal)}
</p>
</div>
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground/50 group-hover:text-muted-foreground group-hover:translate-x-0.5 transition-all" />
</div>
</CardContent>
</Card>
@@ -159,17 +148,14 @@ export default function DashboardContent({ firstName, settings, summary, onboard
<Link key="transactions" href="/transactions" className="group">
<Card className="h-full border-l-2 border-l-warning hover:bg-muted/20 transition-colors">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<ArrowLeftRight className="h-4 w-4 text-warning-foreground flex-shrink-0" />
<div>
<p className="font-medium text-sm">Transaktioner</p>
<p className="text-xs text-muted-foreground mt-0.5">
{summary.uncategorizedCount} obokförda
</p>
</div>
<div className="flex items-center gap-3">
<ArrowLeftRight className="h-4 w-4 text-warning-foreground flex-shrink-0" />
<div>
<p className="font-medium text-sm">Transaktioner</p>
<p className="text-xs text-muted-foreground mt-0.5">
{summary.uncategorizedCount} obokförda
</p>
</div>
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground/50 group-hover:text-muted-foreground group-hover:translate-x-0.5 transition-all" />
</div>
</CardContent>
</Card>
@@ -182,19 +168,16 @@ export default function DashboardContent({ firstName, settings, summary, onboard
<Link key="receipts" href="/receipts" className="group">
<Card className="h-full border-l-2 border-l-primary hover:bg-muted/20 transition-colors">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Camera className="h-4 w-4 text-primary flex-shrink-0" />
<div>
<p className="font-medium text-sm">Kvitton</p>
<p className="text-xs text-muted-foreground mt-0.5">
{summary.receiptQueue.pending_review_count > 0
? `${summary.receiptQueue.pending_review_count} att granska`
: `${summary.receiptQueue.unmatched_receipts_count} omatchade`}
</p>
</div>
<div className="flex items-center gap-3">
<Camera className="h-4 w-4 text-primary flex-shrink-0" />
<div>
<p className="font-medium text-sm">Kvitton</p>
<p className="text-xs text-muted-foreground mt-0.5">
{summary.receiptQueue.pending_review_count > 0
? `${summary.receiptQueue.pending_review_count} att granska`
: `${summary.receiptQueue.unmatched_receipts_count} omatchade`}
</p>
</div>
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground/50 group-hover:text-muted-foreground group-hover:translate-x-0.5 transition-all" />
</div>
</CardContent>
</Card>
@@ -207,17 +190,14 @@ export default function DashboardContent({ firstName, settings, summary, onboard
<Link key="missing-underlag" href="/bookkeeping?missingUnderlag=true" className="group">
<Card className="h-full border-l-2 border-l-warning hover:bg-muted/20 transition-colors">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<FileWarning className="h-4 w-4 text-warning-foreground flex-shrink-0" />
<div>
<p className="font-medium text-sm">Saknade underlag</p>
<p className="text-xs text-muted-foreground mt-0.5">
{summary.missingUnderlagCount} verifikationer utan underlag
</p>
</div>
<div className="flex items-center gap-3">
<FileWarning className="h-4 w-4 text-warning-foreground flex-shrink-0" />
<div>
<p className="font-medium text-sm">Saknade underlag</p>
<p className="text-xs text-muted-foreground mt-0.5">
{summary.missingUnderlagCount} verifikationer utan underlag
</p>
</div>
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground/50 group-hover:text-muted-foreground group-hover:translate-x-0.5 transition-all" />
</div>
</CardContent>
</Card>
@@ -231,17 +211,14 @@ export default function DashboardContent({ firstName, settings, summary, onboard
<Link key="bank-expiry" href="/settings?tab=banking" className="group">
<Card className="h-full border-l-2 border-l-warning hover:bg-muted/20 transition-colors">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Landmark className="h-4 w-4 text-warning-foreground flex-shrink-0" />
<div>
<p className="font-medium text-sm">Banksamtycke löper ut</p>
<p className="text-xs text-muted-foreground mt-0.5">
{conn.bank_name} — {conn.days_left} {conn.days_left === 1 ? 'dag' : 'dagar'} kvar
</p>
</div>
<div className="flex items-center gap-3">
<Landmark className="h-4 w-4 text-warning-foreground flex-shrink-0" />
<div>
<p className="font-medium text-sm">Banksamtycke löper ut</p>
<p className="text-xs text-muted-foreground mt-0.5">
{conn.bank_name} — {conn.days_left} {conn.days_left === 1 ? 'dag' : 'dagar'} kvar
</p>
</div>
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground/50 group-hover:text-muted-foreground group-hover:translate-x-0.5 transition-all" />
</div>
</CardContent>
</Card>
@@ -277,61 +254,33 @@ export default function DashboardContent({ firstName, settings, summary, onboard
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">
{(() => {
const hour = new Date().getHours()
if (hour < 12) return 'Godmorgon'
if (hour < 5) return 'God natt'
if (hour < 10) return 'Godmorgon'
if (hour < 14) return 'Hej'
if (hour < 18) return 'God eftermiddag'
return 'God kväll'
})()}{firstName ? `, ${firstName}` : ''}
</h1>
<p className="text-muted-foreground mt-1">
{summary.overdueInvoicesCount > 0
? `${summary.overdueInvoicesCount} förfallna fakturor kräver åtgärd`
: summary.deadlines.filter(d => !d.is_completed && new Date(d.due_date) <= new Date()).length > 0
? `${summary.deadlines.filter(d => !d.is_completed && new Date(d.due_date) <= new Date()).length} passerade deadlines`
: 'Allt är som det ska'}
{(() => {
if (summary.overdueInvoicesCount > 0)
return `${summary.overdueInvoicesCount} förfallna fakturor kräver åtgärd`
const passedDeadlines = summary.deadlines.filter(d => !d.is_completed && new Date(d.due_date) <= new Date()).length
if (passedDeadlines > 0)
return `${passedDeadlines} passerade deadlines`
if (summary.uncategorizedCount > 0)
return `${summary.uncategorizedCount} obokförda transaktioner`
if (summary.receiptQueue && summary.receiptQueue.pending_review_count > 0)
return `${summary.receiptQueue.pending_review_count} kvitton att granska`
if (summary.missingUnderlagCount > 0)
return `${summary.missingUnderlagCount} verifikationer saknar underlag`
if (summary.unpaidInvoicesCount > 0)
return `${summary.unpaidInvoicesCount} obetalda fakturor`
return 'Allt är som det ska'
})()}
</p>
</header>
{/* Status pills */}
{(() => {
const passedDeadlines = summary.deadlines.filter(d => !d.is_completed && new Date(d.due_date) <= new Date())
const todoItems: { label: string; href: string; count: number; variant: 'destructive' | 'warning' | 'default' }[] = []
if (passedDeadlines.length > 0) {
todoItems.push({ label: 'passerade deadlines', href: '/deadlines', count: passedDeadlines.length, variant: 'destructive' })
}
if (summary.overdueInvoicesCount > 0) {
todoItems.push({ label: 'förfallna fakturor', href: '/invoices?status=unpaid', count: summary.overdueInvoicesCount, variant: 'destructive' })
}
if (summary.uncategorizedCount > 0) {
todoItems.push({ label: 'obokförda', href: '/transactions', count: summary.uncategorizedCount, variant: 'warning' })
}
if (summary.receiptQueue && summary.receiptQueue.pending_review_count > 0) {
todoItems.push({ label: 'kvitton att granska', href: '/receipts', count: summary.receiptQueue.pending_review_count, variant: 'default' })
}
if (summary.missingUnderlagCount > 0) {
todoItems.push({ label: 'saknade underlag', href: '/bookkeeping?missingUnderlag=true', count: summary.missingUnderlagCount, variant: 'warning' })
}
if (todoItems.length === 0) return null
return (
<section className="mb-10">
<div className="flex flex-wrap gap-1.5">
{todoItems.map((item) => (
<Link key={item.href} href={item.href}>
<Badge
variant={item.variant === 'destructive' ? 'destructive' : item.variant === 'warning' ? 'outline' : 'secondary'}
className="px-2.5 py-1 text-xs cursor-pointer hover:opacity-80 transition-opacity"
>
{item.count} {item.label}
</Badge>
</Link>
))}
</div>
</section>
)
})()}
{/* New user checklist */}
{onboardingProgress && (
<section className="mb-10">
@@ -358,10 +307,7 @@ export default function DashboardContent({ firstName, settings, summary, onboard
{/* Card 1: Resultat */}
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2 mb-2">
<TrendingUp className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs text-muted-foreground">Resultat</span>
</div>
<p className="text-xs text-muted-foreground mb-2">Resultat</p>
<p className={cn(
'font-display text-xl font-medium tabular-nums leading-tight',
summary.mtd.net >= 0 ? 'text-success' : 'text-destructive'
@@ -379,10 +325,7 @@ export default function DashboardContent({ firstName, settings, summary, onboard
<Link href="/invoices?status=unpaid">
<Card className="h-full hover:border-primary/50 transition-colors">
<CardContent className="p-4">
<div className="flex items-center gap-2 mb-2">
<Receipt className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs text-muted-foreground">Att få betalt</span>
</div>
<p className="text-xs text-muted-foreground mb-2">Att få betalt</p>
<p className="font-display text-xl font-medium tabular-nums leading-tight">
{summary.unpaidInvoicesCount}
<span className="text-sm ml-0.5 text-muted-foreground font-normal">st</span>
@@ -398,10 +341,7 @@ export default function DashboardContent({ firstName, settings, summary, onboard
{summary.bankBalance !== null ? (
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2 mb-2">
<Landmark className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs text-muted-foreground">Banksaldo</span>
</div>
<p className="text-xs text-muted-foreground mb-2">Banksaldo</p>
<p className="font-display text-xl font-medium tabular-nums leading-tight">
{formatLargeNumber(summary.bankBalance)}
<span className="text-sm ml-0.5 text-muted-foreground font-normal">kr</span>
@@ -412,10 +352,7 @@ export default function DashboardContent({ firstName, settings, summary, onboard
<Link href="/import">
<Card className="h-full hover:border-primary/50 transition-colors">
<CardContent className="p-4">
<div className="flex items-center gap-2 mb-2">
<Landmark className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs text-muted-foreground">Banksaldo</span>
</div>
<p className="text-xs text-muted-foreground mb-2">Banksaldo</p>
<p className="text-sm font-medium text-primary">Koppla bank</p>
<p className="text-[11px] text-muted-foreground mt-1">Importera transaktioner</p>
</CardContent>
@@ -426,10 +363,7 @@ export default function DashboardContent({ firstName, settings, summary, onboard
{/* Card 4: Att göra */}
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2 mb-2">
<ClipboardList className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs text-muted-foreground">Att göra</span>
</div>
<p className="text-xs text-muted-foreground mb-2">Att göra</p>
{todoCount > 0 ? (
<>
<p className="font-display text-xl font-medium tabular-nums leading-tight text-warning-foreground">
@@ -469,17 +403,14 @@ export default function DashboardContent({ firstName, settings, summary, onboard
? 'border-primary/20 bg-primary/[0.03] hover:bg-primary/[0.06]'
: 'border-border/40 hover:bg-muted/30'
)}>
<div className={cn(
'p-2 rounded-lg',
action.accent ? 'bg-primary/8' : 'bg-muted/50'
)}>
<Icon className={cn(
'h-4 w-4',
action.accent ? 'text-primary' : 'text-muted-foreground'
)} />
</div>
<div className="min-w-0">
<p className="text-sm font-medium truncate">{action.label}</p>
<p className="text-sm font-medium truncate flex items-center gap-1.5">
<Icon className={cn(
'h-3.5 w-3.5 flex-shrink-0',
action.accent ? 'text-primary' : 'text-muted-foreground'
)} />
{action.label}
</p>
<p className="text-xs text-muted-foreground truncate hidden md:block">{action.desc}</p>
</div>
</div>
@@ -493,11 +424,11 @@ export default function DashboardContent({ firstName, settings, summary, onboard
return (
<Link key={action.key} href={action.href} className="group">
<div className="flex items-center gap-3 px-4 py-3 rounded-xl border border-border/40 hover:bg-muted/30 transition-colors duration-150">
<div className="p-2 rounded-lg bg-muted/50">
<Icon className="h-4 w-4 text-muted-foreground" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium truncate">{action.label}</p>
<p className="text-sm font-medium truncate flex items-center gap-1.5">
<Icon className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
{action.label}
</p>
<p className="text-xs text-muted-foreground truncate hidden md:block">{action.description}</p>
</div>
</div>
@@ -511,11 +442,11 @@ export default function DashboardContent({ firstName, settings, summary, onboard
className="group text-left"
>
<div className="flex items-center gap-3 px-4 py-3 rounded-xl border border-border/40 hover:bg-muted/30 transition-colors duration-150">
<div className="p-2 rounded-lg bg-muted/50">
<Icon className="h-4 w-4 text-muted-foreground" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium truncate">{action.label}</p>
<p className="text-sm font-medium truncate flex items-center gap-1.5">
<Icon className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
{action.label}
</p>
<p className="text-xs text-muted-foreground truncate hidden md:block">{action.description}</p>
</div>
</div>
@@ -525,21 +456,7 @@ export default function DashboardContent({ firstName, settings, summary, onboard
</div>
</section>
{/* Upcoming deadlines — always visible */}
{summary.deadlines && summary.deadlines.length > 0 && (
<section className="mb-10">
<UpcomingDeadlinesWidget deadlines={summary.deadlines} maxItems={8} />
</section>
)}
{/* Tax todo widget — visible when there are incomplete tax deadlines */}
{summary.deadlines?.some(d => d.deadline_type === 'tax' && !d.is_completed) && (
<section className="mb-10">
<TaxTodoWidget deadlines={summary.deadlines} />
</section>
)}
{/* Alerts section — always visible */}
{/* Alerts section */}
{alertItems.length > 0 && (
<section id="alerts-section" className="mb-10">
<h2 className="font-display text-lg font-medium mb-4">Att hantera</h2>
@@ -558,6 +475,21 @@ export default function DashboardContent({ firstName, settings, summary, onboard
</section>
)}
{/* Upcoming deadlines — always visible */}
{summary.deadlines && summary.deadlines.length > 0 && (
<section className="mb-10">
<UpcomingDeadlinesWidget deadlines={summary.deadlines} maxItems={8} />
</section>
)}
{/* Tax todo widget — visible when there are incomplete tax deadlines */}
{summary.deadlines?.some(d => d.deadline_type === 'tax' && !d.is_completed) && (
<section className="mb-10">
<TaxTodoWidget deadlines={summary.deadlines} />
</section>
)}
{/* Collapsible details section */}
<button
onClick={() => setShowMore(!showMore)}
@@ -607,7 +539,6 @@ export default function DashboardContent({ firstName, settings, summary, onboard
{' '}saknas i resultatet
</p>
</div>
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground/50 group-hover:text-muted-foreground flex-shrink-0 mt-0.5 transition-colors" />
</div>
</Link>
</section>
@@ -619,10 +550,7 @@ export default function DashboardContent({ firstName, settings, summary, onboard
<div className="grid md:grid-cols-2 gap-3">
<Card>
<CardContent className="p-5">
<div className="flex items-center gap-2 mb-3">
<TrendingUp className="h-3.5 w-3.5 text-success" />
<span className="text-sm text-muted-foreground">Intäkter</span>
</div>
<p className="text-sm text-muted-foreground mb-3">Intäkter</p>
<div>
<p className="font-display text-2xl font-medium tabular-nums leading-tight">
{formatLargeNumber(summary.mtd.income)}
@@ -641,10 +569,7 @@ export default function DashboardContent({ firstName, settings, summary, onboard
<Card>
<CardContent className="p-5">
<div className="flex items-center gap-2 mb-3">
<TrendingDown className="h-3.5 w-3.5 text-destructive" />
<span className="text-sm text-muted-foreground">Kostnader</span>
</div>
<p className="text-sm text-muted-foreground mb-3">Kostnader</p>
<div>
<p className="font-display text-2xl font-medium tabular-nums leading-tight">
{formatLargeNumber(summary.mtd.expenses)}
+4 -153
View File
@@ -1,6 +1,6 @@
'use client'
import { useState, useCallback, useEffect } from 'react'
import { useState } from 'react'
import Link from 'next/link'
import { usePathname, useRouter } from 'next/navigation'
import { createClient } from '@/lib/supabase/client'
@@ -23,16 +23,13 @@ import {
ChevronDown,
Building2,
FileInput,
Store,
Wallet,
} from 'lucide-react'
import { getExtensionDefinition } from '@/lib/extensions/sectors'
import { resolveIcon } from '@/lib/extensions/icon-resolver'
import type { EntityType } from '@/types'
interface DashboardNavProps {
companyName: string
entityType: EntityType
enabledExtensions?: { sector_slug: string; extension_slug: string }[]
uncategorizedTransactionCount?: number
}
@@ -51,6 +48,7 @@ const navItems: NavItem[] = [
{ href: '/deadlines', label: 'Deadlines', icon: Calendar, group: 'main' },
{ href: '/invoices', label: 'Fakturor', icon: Receipt, group: 'finans' },
{ href: '/customers', label: 'Kunder', icon: Users, group: 'finans' },
{ href: '/expenses', label: 'Utgifter', icon: Wallet, group: 'finans' },
// Temporarily hidden pending module rework (see feedback #49)
{ href: '/suppliers', label: 'Leverantörer', icon: Building2, group: 'finans', hidden: true },
{ href: '/supplier-invoices', label: 'Leverantörsfakturor', icon: FileInput, group: 'finans', hidden: true },
@@ -68,7 +66,7 @@ const groupLabels: Record<string, string> = {
övrigt: 'Övrigt',
}
export default function DashboardNav({ companyName, entityType, enabledExtensions, uncategorizedTransactionCount = 0 }: DashboardNavProps) {
export default function DashboardNav({ companyName, entityType, uncategorizedTransactionCount = 0 }: DashboardNavProps) {
const pathname = usePathname()
const router = useRouter()
const supabase = createClient()
@@ -77,46 +75,8 @@ export default function DashboardNav({ companyName, entityType, enabledExtension
const isOnOvrigtPage = ['/help', '/settings'].some(p => pathname.startsWith(p))
const [manualOvrigtExpanded, setManualOvrigtExpanded] = useState(false)
const isOvrigtExpanded = isOnOvrigtPage || manualOvrigtExpanded
// Auto-expand Tillägg when on an extension page or marketplace, or when manually toggled (persisted)
const isOnExtensionPage = pathname.startsWith('/e/') || pathname.startsWith('/extensions')
const [manualTillaggExpanded, setManualTillaggExpanded] = useState(false)
const isTillaggExpanded = isOnExtensionPage || manualTillaggExpanded
// Restore persisted state after hydration to avoid SSR mismatch
useEffect(() => {
const stored = localStorage.getItem('tillagg-expanded') === 'true'
if (stored) setManualTillaggExpanded(true)
}, [])
const [liveExtensions, setLiveExtensions] = useState(enabledExtensions ?? [])
const fetchExtensions = useCallback(async () => {
try {
const res = await fetch('/api/extensions/toggles')
if (res.ok) {
const { data } = await res.json()
if (data) setLiveExtensions(data)
}
} catch {
// keep current state on error
}
}, [])
// Fetch extensions on mount if Tillägg starts expanded
useEffect(() => {
if (isTillaggExpanded) fetchExtensions()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const toggleTillagg = () => {
const next = !isTillaggExpanded
setManualTillaggExpanded(next)
localStorage.setItem('tillagg-expanded', String(next))
if (next) fetchExtensions()
}
const openMobileMenu = () => {
setIsMobileMenuOpen(true)
fetchExtensions()
}
const handleLogout = async () => {
@@ -233,68 +193,6 @@ export default function DashboardNav({ companyName, entityType, enabledExtension
</div>
</div>
{/* Tillägg - collapsible, with marketplace link */}
<div className="mb-4">
<button
onClick={toggleTillagg}
className="w-full flex items-center justify-between px-3 mb-1.5 text-[10px] font-semibold text-muted-foreground/70 uppercase tracking-[0.08em] hover:text-muted-foreground transition-colors"
>
<span>Tillägg</span>
<ChevronDown className={cn(
"h-3 w-3 transition-transform duration-200",
isTillaggExpanded && "rotate-180"
)} />
</button>
{isTillaggExpanded && (
<div className="space-y-px animate-fade-in">
{liveExtensions.length > 0 ? liveExtensions.map((toggle) => {
const def = getExtensionDefinition(toggle.sector_slug, toggle.extension_slug)
if (!def) return null
const ExtIcon = resolveIcon(def.icon)
const href = `/e/${toggle.sector_slug}/${toggle.extension_slug}`
const active = isActive(href)
return (
<Link
key={`${toggle.sector_slug}/${toggle.extension_slug}`}
href={href}
className={cn(
'group flex items-center px-3 py-[7px] text-[13px] transition-colors duration-150 rounded-lg',
active
? 'bg-primary/8 text-foreground font-medium'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/40'
)}
>
<ExtIcon className={cn(
"mr-2.5 h-[15px] w-[15px] flex-shrink-0",
active ? "text-primary" : "text-muted-foreground/70 group-hover:text-muted-foreground"
)} />
{def.name}
</Link>
)
}) : (
<p className="px-3 py-2 text-[12px] text-muted-foreground/60">
Inga tillägg aktiverade
</p>
)}
<Link
href="/extensions"
className={cn(
'group flex items-center px-3 py-[7px] text-[13px] transition-colors duration-150 rounded-lg',
isActive('/extensions')
? 'bg-primary/8 text-foreground font-medium'
: 'text-muted-foreground/60 hover:text-muted-foreground hover:bg-muted/40'
)}
>
<Store className={cn(
"mr-2.5 h-[15px] w-[15px] flex-shrink-0",
isActive('/extensions') ? "text-primary" : "text-muted-foreground/50 group-hover:text-muted-foreground"
)} />
Utforska fler...
</Link>
</div>
)}
</div>
{/* Övrigt group - collapsible */}
<div className="mb-4">
<button
@@ -488,53 +386,6 @@ export default function DashboardNav({ companyName, entityType, enabledExtension
})}
</div>
{/* Tillägg */}
<div className="mb-4">
<p className="px-3 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">
Tillägg
</p>
{liveExtensions.length > 0 ? liveExtensions.map((toggle) => {
const def = getExtensionDefinition(toggle.sector_slug, toggle.extension_slug)
if (!def) return null
const ExtIcon = resolveIcon(def.icon)
const href = `/e/${toggle.sector_slug}/${toggle.extension_slug}`
const active = isActive(href)
return (
<Link
key={`${toggle.sector_slug}/${toggle.extension_slug}`}
href={href}
onClick={closeMobileMenu}
className={cn(
'flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors',
active
? 'bg-primary/10 text-primary font-medium'
: 'text-muted-foreground hover:bg-secondary/50 hover:text-foreground'
)}
>
<ExtIcon className="h-5 w-5" />
{def.name}
</Link>
)
}) : (
<p className="px-3 py-2 text-xs text-muted-foreground/60">
Inga tillägg aktiverade
</p>
)}
<Link
href="/extensions"
onClick={closeMobileMenu}
className={cn(
'flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors',
isActive('/extensions')
? 'bg-primary/10 text-primary font-medium'
: 'text-muted-foreground/60 hover:bg-secondary/50 hover:text-foreground'
)}
>
<Store className="h-5 w-5" />
Utforska fler...
</Link>
</div>
{/* Other section */}
<div className="mb-4">
<p className="px-3 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">
+28 -47
View File
@@ -1,17 +1,13 @@
'use client'
import { useState } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Checkbox } from '@/components/ui/checkbox'
import { Label } from '@/components/ui/label'
import {
ArrowLeft,
Loader2,
Play,
FileText,
AlertTriangle,
Link2,
Calendar,
} from 'lucide-react'
@@ -31,11 +27,28 @@ export default function BankFileConfirmStep({
onBack,
isLoading,
}: BankFileConfirmStepProps) {
const [skipDuplicates, setSkipDuplicates] = useState(true)
const { transactions, stats, date_from, date_to, format_name } = parseResult
const { transactions, stats, date_from, date_to } = parseResult
const refsCount = transactions.filter((t) => t.reference).length
if (isLoading) {
return (
<div className="flex flex-col items-center justify-center py-24 space-y-6">
<div className="relative">
<Loader2 className="h-12 w-12 animate-spin text-primary" />
</div>
<div className="text-center space-y-2">
<p className="text-lg font-medium">Importerar transaktioner...</p>
<p className="text-sm text-muted-foreground">
{stats.parsed_rows} transaktioner bearbetas
</p>
</div>
<div className="w-48 h-1 bg-muted rounded-full overflow-hidden">
<div className="h-full bg-primary rounded-full animate-pulse" style={{ width: '60%' }} />
</div>
</div>
)
}
return (
<div className="space-y-6">
{/* Summary */}
@@ -68,65 +81,33 @@ export default function BankFileConfirmStep({
</div>
<div className="p-4 bg-muted/50 rounded-lg">
<div className="flex items-center gap-2 text-green-600 mb-1">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<span className="text-xs">Inkomster</span>
</div>
<p className="text-xl font-bold text-green-600">
<p className="text-xl font-bold">
{formatCurrency(stats.total_income)}
</p>
</div>
<div className="p-4 bg-muted/50 rounded-lg">
<div className="flex items-center gap-2 text-red-600 mb-1">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<span className="text-xs">Utgifter</span>
</div>
<p className="text-xl font-bold text-red-600">
<p className="text-xl font-bold">
{formatCurrency(stats.total_expenses)}
</p>
</div>
</div>
{/* Additional info */}
<div className="flex flex-wrap gap-2">
<Badge variant="secondary">Format: {format_name}</Badge>
{refsCount > 0 && (
{refsCount > 0 && (
<div className="flex flex-wrap gap-2">
<Badge variant="outline" className="text-blue-600 border-blue-300">
<Link2 className="mr-1 h-3 w-3" />
{refsCount} med OCR/referens
</Badge>
)}
</div>
{/* Options */}
<div className="border rounded-lg p-4 space-y-4">
<h3 className="text-sm font-medium">Importinställningar</h3>
<div className="flex items-center space-x-3">
<Checkbox
id="skip-duplicates"
checked={skipDuplicates}
onCheckedChange={(checked) => setSkipDuplicates(checked === true)}
/>
<div>
<Label htmlFor="skip-duplicates" className="text-sm font-medium cursor-pointer">
Hoppa över dubletter
</Label>
<p className="text-xs text-muted-foreground">
Transaktioner som redan finns i systemet importeras inte igen
</p>
</div>
</div>
</div>
{/* Warning note */}
<div className="flex gap-3 p-3 bg-yellow-50 dark:bg-yellow-950/20 border border-yellow-200 dark:border-yellow-800 rounded-lg">
<AlertTriangle className="h-4 w-4 text-yellow-600 flex-shrink-0 mt-0.5" />
<p className="text-sm text-muted-foreground">
Importerade transaktioner visas som &quot;obokförda&quot; på
transaktionssidan. Du kan bokföra dem manuellt efteråt.
</p>
</div>
)}
</CardContent>
</Card>
@@ -138,7 +119,7 @@ export default function BankFileConfirmStep({
</Button>
<Button
onClick={() => onExecute({
skip_duplicates: skipDuplicates,
skip_duplicates: true,
auto_categorize: false,
})}
disabled={isLoading}
+6 -23
View File
@@ -25,18 +25,16 @@ import type { BankFileParseResult } from '@/lib/import/bank-file/types'
interface BankFilePreviewStepProps {
parseResult: BankFileParseResult
existingTransactionCount: number
onContinue: () => void
onBack: () => void
}
export default function BankFilePreviewStep({
parseResult,
existingTransactionCount,
onContinue,
onBack,
}: BankFilePreviewStepProps) {
const { transactions, stats, issues, date_from, date_to, format_name } = parseResult
const { transactions, stats, issues, date_from, date_to } = parseResult
const hasIssues = issues.filter((i) => i.severity === 'error').length > 0
const warnings = issues.filter((i) => i.severity === 'warning')
@@ -73,11 +71,11 @@ export default function BankFilePreviewStep({
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-2 text-green-600 mb-1">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<TrendingUp className="h-4 w-4" />
<span className="text-sm">Inkomster</span>
</div>
<p className="text-lg font-bold text-green-600">
<p className="text-lg font-bold">
{formatCurrency(stats.total_income)}
</p>
</CardContent>
@@ -85,30 +83,17 @@ export default function BankFilePreviewStep({
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-2 text-red-600 mb-1">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<TrendingDown className="h-4 w-4" />
<span className="text-sm">Utgifter</span>
</div>
<p className="text-lg font-bold text-red-600">
<p className="text-lg font-bold">
{formatCurrency(stats.total_expenses)}
</p>
</CardContent>
</Card>
</div>
{/* Format and duplicate info */}
<div className="flex items-center gap-4">
<Badge variant="secondary">
Format: {format_name}
</Badge>
{existingTransactionCount > 0 && (
<Badge variant="outline" className="text-yellow-600 border-yellow-300">
<AlertTriangle className="mr-1 h-3 w-3" />
{existingTransactionCount} befintliga transaktioner i samma period
</Badge>
)}
</div>
{/* Warnings */}
{warnings.length > 0 && (
<Card className="border-yellow-300">
@@ -165,9 +150,7 @@ export default function BankFilePreviewStep({
<TableCell className="font-mono text-sm">{tx.date}</TableCell>
<TableCell className="text-sm">{tx.description}</TableCell>
<TableCell
className={`text-right font-mono text-sm ${
tx.amount >= 0 ? 'text-green-600' : 'text-red-600'
}`}
className="text-right font-mono text-sm"
>
{formatCurrency(tx.amount)}
</TableCell>
+2 -49
View File
@@ -6,10 +6,6 @@ import { Button } from '@/components/ui/button'
import {
CheckCircle,
XCircle,
FileText,
Link2,
Sparkles,
Copy,
ArrowRight,
RotateCcw,
ExternalLink,
@@ -30,12 +26,12 @@ export default function BankFileResultStep({
return (
<div className="space-y-6">
{/* Status header */}
<Card className={isSuccess ? 'border-green-300' : 'border-destructive/50'}>
<Card className={isSuccess ? 'border-border' : 'border-destructive/50'}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
{isSuccess ? (
<>
<CheckCircle className="h-6 w-6 text-green-600" />
<CheckCircle className="h-6 w-6 text-muted-foreground" />
Import genomförd
</>
) : (
@@ -53,49 +49,6 @@ export default function BankFileResultStep({
</CardHeader>
</Card>
{/* Stats */}
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<FileText className="h-4 w-4" />
<span className="text-sm">Importerade</span>
</div>
<p className="text-2xl font-bold text-green-600">{result.imported}</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<Copy className="h-4 w-4" />
<span className="text-sm">Dubletter</span>
</div>
<p className="text-2xl font-bold text-muted-foreground">{result.duplicates}</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<Sparkles className="h-4 w-4" />
<span className="text-sm">Auto-bokförda</span>
</div>
<p className="text-2xl font-bold">{result.auto_categorized}</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-2 text-muted-foreground mb-1">
<Link2 className="h-4 w-4" />
<span className="text-sm">Fakturamatchade</span>
</div>
<p className="text-2xl font-bold">{result.auto_matched_invoices}</p>
</CardContent>
</Card>
</div>
{/* Next steps */}
{isSuccess && (
<Card className="bg-muted/50">
@@ -2,7 +2,6 @@
import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator'
import { getVatSummaryFromItems } from '@/lib/invoices/vat-rules'
import { formatCurrency } from '@/lib/utils'
import type { Customer, Currency } from '@/types'
@@ -48,9 +47,6 @@ export function InvoiceReviewContent({
non_eu_business: 'Utanför EU',
}
// Derive VAT summary from items
const vatSummary = getVatSummaryFromItems(items)
// Calculate per-rate VAT breakdown
const vatByRate = new Map<number, number>()
for (const item of items) {
@@ -75,11 +71,6 @@ export function InvoiceReviewContent({
</Badge>
</div>
{/* VAT treatment */}
<Badge className="text-sm px-3 py-1">
{vatSummary.label}
</Badge>
{/* Dates */}
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
+7 -27
View File
@@ -8,13 +8,8 @@ import { Progress } from '@/components/ui/progress'
import {
Check,
Circle,
Users,
Receipt,
Building2,
Camera,
Sparkles,
X,
ChevronRight,
} from 'lucide-react'
import { cn } from '@/lib/utils'
@@ -23,7 +18,6 @@ interface ChecklistItem {
label: string
description: string
href: string
icon: React.ComponentType<{ className?: string }>
completed: boolean
}
@@ -61,7 +55,6 @@ export default function NewUserChecklist({
label: 'Skapa konto',
description: 'Du har ett konto!',
href: '#',
icon: Check,
completed: true, // Always completed if they're seeing this
},
{
@@ -69,7 +62,6 @@ export default function NewUserChecklist({
label: 'Lägg till din första kund',
description: 'Spara kunduppgifter för enkel fakturering',
href: '/customers/new',
icon: Users,
completed: hasCustomers,
},
{
@@ -77,7 +69,6 @@ export default function NewUserChecklist({
label: 'Skicka din första faktura',
description: 'Skapa en professionell faktura på 60 sekunder',
href: '/invoices/new',
icon: Receipt,
completed: hasInvoices,
},
{
@@ -85,7 +76,6 @@ export default function NewUserChecklist({
label: 'Importera transaktioner',
description: 'Importera kontoutdrag från din bank',
href: '/import',
icon: Building2,
completed: hasBankConnected,
},
{
@@ -93,7 +83,6 @@ export default function NewUserChecklist({
label: 'Skanna ditt första kvitto',
description: 'Fotografera för automatisk bokföring',
href: '/receipts/scan',
icon: Camera,
completed: hasReceipts,
},
]
@@ -150,16 +139,11 @@ export default function NewUserChecklist({
>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-primary/10">
<Sparkles className="h-4 w-4 text-primary" />
</div>
<div>
<CardTitle className="text-base">Kom igång</CardTitle>
<p className="text-sm text-muted-foreground">
{completedCount} av {items.length} steg klara
</p>
</div>
<div>
<CardTitle className="text-base">Kom igång</CardTitle>
<p className="text-sm text-muted-foreground">
{completedCount} av {items.length} steg klara
</p>
</div>
<Button
variant="ghost"
@@ -175,9 +159,7 @@ export default function NewUserChecklist({
</CardHeader>
<CardContent className="pt-0">
<ul className="space-y-1">
{items.map((item) => {
const Icon = item.icon
return (
{items.map((item) => (
<li key={item.id}>
{item.completed ? (
<div className="flex items-center gap-3 p-2 rounded-lg">
@@ -204,12 +186,10 @@ export default function NewUserChecklist({
{item.description}
</p>
</div>
<ChevronRight className="h-4 w-4 text-muted-foreground group-hover:text-primary group-hover:translate-x-0.5 transition-all" />
</Link>
)}
</li>
)
})}
))}
</ul>
</CardContent>
</Card>
+86 -11
View File
@@ -1,6 +1,6 @@
'use client'
import { useState, useMemo } from 'react'
import { useState, useMemo, useEffect } from 'react'
import { useForm, Controller } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
@@ -49,6 +49,7 @@ interface Step3Output {
interface Step3Props {
initialData: Partial<Step3Output>
entityType?: EntityType
orgNumber?: string
onNext: (data: Step3Output) => void
onBack: () => void
isSaving: boolean
@@ -138,6 +139,7 @@ function getABFirstYearEndDates(
export default function Step3TaxRegistration({
initialData,
entityType,
orgNumber,
onNext,
onBack,
isSaving,
@@ -149,6 +151,7 @@ export default function Step3TaxRegistration({
handleSubmit,
watch,
control,
setValue,
formState: { errors },
} = useForm<FormData>({
resolver: zodResolver(schema),
@@ -174,6 +177,17 @@ export default function Step3TaxRegistration({
const fiscalYearEndMonth = watch('fiscal_year_end_month')
const accountingMethod = watch('accounting_method')
// Auto-fill VAT number when vat_registered toggles on
const vatNumber = watch('vat_number')
useEffect(() => {
if (vatRegistered && !vatNumber && orgNumber) {
const cleaned = orgNumber.replace(/[-\s]/g, '')
if (cleaned.length >= 10) {
setValue('vat_number', `SE${cleaned}01`)
}
}
}, [vatRegistered, vatNumber, orgNumber, setValue])
// State for AB first-year end month selector
const [abEndMonth, setAbEndMonth] = useState<number>(
initialData.first_year_end
@@ -361,18 +375,79 @@ export default function Step3TaxRegistration({
{isFirstYear && (
<div className="space-y-4 rounded-lg bg-muted/50 p-4">
<div className="space-y-2">
<Label htmlFor="first_year_start">Startdatum</Label>
<Label>Startdatum</Label>
<Controller
name="first_year_start"
control={control}
render={({ field }) => (
<Input
id="first_year_start"
type="date"
value={field.value || ''}
onChange={(e) => field.onChange(e.target.value)}
/>
)}
render={({ field }) => {
const parsed = field.value ? (() => {
const d = new Date(field.value)
if (isNaN(d.getTime())) return null
return { day: d.getDate(), month: d.getMonth() + 1, year: d.getFullYear() }
})() : null
const selectedDay = parsed?.day ?? 0
const selectedMonth = parsed?.month ?? 0
const selectedYear = parsed?.year ?? 0
const currentYear = new Date().getFullYear()
const years = Array.from({ length: 7 }, (_, i) => currentYear - 5 + i)
const maxDays = selectedMonth && selectedYear
? lastDayOfMonth(selectedYear, selectedMonth)
: 31
const compose = (day: number, month: number, year: number) => {
if (day && month && year) {
const clamped = Math.min(day, lastDayOfMonth(year, month))
field.onChange(`${year}-${String(month).padStart(2, '0')}-${String(clamped).padStart(2, '0')}`)
}
}
return (
<div className="grid grid-cols-3 gap-2">
<Select
value={selectedDay ? selectedDay.toString() : ''}
onValueChange={(v) => compose(parseInt(v), selectedMonth, selectedYear)}
>
<SelectTrigger>
<SelectValue placeholder="Dag" />
</SelectTrigger>
<SelectContent>
{Array.from({ length: maxDays }, (_, i) => i + 1).map((d) => (
<SelectItem key={d} value={d.toString()}>{d}</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={selectedMonth ? selectedMonth.toString() : ''}
onValueChange={(v) => compose(selectedDay, parseInt(v), selectedYear)}
>
<SelectTrigger>
<SelectValue placeholder="Månad" />
</SelectTrigger>
<SelectContent>
{monthNames.map((name, i) => (
<SelectItem key={i + 1} value={(i + 1).toString()}>{name}</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={selectedYear ? selectedYear.toString() : ''}
onValueChange={(v) => compose(selectedDay, selectedMonth, parseInt(v))}
>
<SelectTrigger>
<SelectValue placeholder="År" />
</SelectTrigger>
<SelectContent>
{years.map((y) => (
<SelectItem key={y} value={y.toString()}>{y}</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
}}
/>
<p className="text-xs text-muted-foreground">
Dagen verksamheten startade (bör vara den 1:a i en månad).
@@ -593,7 +668,7 @@ export default function Step3TaxRegistration({
</SelectTrigger>
<SelectContent>
<SelectItem value="monthly">Månad</SelectItem>
<SelectItem value="quarterly">Kvartal (rekommenderas)</SelectItem>
<SelectItem value="quarterly">Kvartal</SelectItem>
<SelectItem value="yearly">År</SelectItem>
</SelectContent>
</Select>
@@ -300,7 +300,7 @@ export function BankReconciliationView() {
</div>
<div className="flex justify-between pt-2 border-t font-semibold">
<span>Differens</span>
<span className={status.is_reconciled ? 'text-green-600' : 'text-red-600'}>
<span>
{formatCurrency(status.difference)}
</span>
</div>
@@ -433,7 +433,7 @@ export function BankReconciliationView() {
<tr key={tx.id} className="border-b last:border-0">
<td className="py-2">{tx.date}</td>
<td className="py-2 truncate max-w-[200px]">{tx.description}</td>
<td className={`py-2 text-right font-mono ${tx.amount >= 0 ? 'text-green-600' : ''}`}>
<td className="py-2 text-right font-mono">
{formatCurrency(tx.amount)}
</td>
<td className="py-2 text-xs text-muted-foreground">{tx.reference || '—'}</td>
@@ -506,7 +506,7 @@ export function BankReconciliationView() {
<td className="py-2 truncate max-w-[300px]">
{line.line_description || line.entry_description}
</td>
<td className={`py-2 text-right font-mono ${amount >= 0 ? 'text-green-600' : ''}`}>
<td className="py-2 text-right font-mono">
{formatCurrency(amount)}
</td>
<td className="py-2 text-xs text-muted-foreground">{line.source_type}</td>
@@ -554,7 +554,7 @@ export function BankReconciliationView() {
<tr key={tx.id} className="border-b last:border-0">
<td className="py-2">{tx.date}</td>
<td className="py-2 truncate max-w-[300px]">{tx.description}</td>
<td className={`py-2 text-right font-mono ${tx.amount >= 0 ? 'text-green-600' : ''}`}>
<td className="py-2 text-right font-mono">
{formatCurrency(tx.amount)}
</td>
<td className="py-2">
-242
View File
@@ -1,242 +0,0 @@
'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'
import { formatCurrency } from '@/lib/utils'
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 &quot;Förhandsgranska&quot; 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">{formatCurrency(balance.amount)}</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">{formatCurrency(acc.amount)}</td>
</tr>
))}
</tbody>
</table>
</td>
</tr>
)}
</>
)
}
@@ -72,20 +72,20 @@ function buildJournalPreview(
const fiktivVat = Math.round(subtotal * vatRate * 100) / 100
lines.push({
account_number: '2645',
description: 'Beraknad ingaende moms',
description: 'Beräknad ingående moms',
debit: fiktivVat,
credit: 0,
})
lines.push({
account_number: '2614',
description: 'Utgaende moms omvand',
description: 'Utgående moms omvänd',
debit: 0,
credit: fiktivVat,
})
// Credit: 2440 at subtotal (no real VAT for reverse charge)
lines.push({
account_number: '2440',
description: 'Leverantorsskulder',
description: 'Leverantörsskulder',
debit: 0,
credit: Math.round(subtotal * 100) / 100,
})
@@ -93,7 +93,7 @@ function buildJournalPreview(
if (totalVat > 0) {
lines.push({
account_number: '2641',
description: 'Ingaende moms',
description: 'Ingående moms',
debit: Math.round(totalVat * 100) / 100,
credit: 0,
})
@@ -101,7 +101,7 @@ function buildJournalPreview(
// Credit: 2440 at total incl. VAT
lines.push({
account_number: '2440',
description: 'Leverantorsskulder',
description: 'Leverantörsskulder',
debit: 0,
credit: Math.round(total * 100) / 100,
})
@@ -64,7 +64,7 @@ export default function BatchCategorySelector({
<div className="flex items-start gap-2 rounded-lg bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800 p-3">
<Paperclip className="h-4 w-4 text-amber-600 dark:text-amber-400 mt-0.5 shrink-0" />
<p className="text-xs text-amber-800 dark:text-amber-300">
Underlag behover bifogas separat for varje transaktion efter bokforing.
Underlag behöver bifogas separat för varje transaktion efter bokföring.
</p>
</div>
@@ -82,26 +82,26 @@ function getExamplePrompts(transaction: TransactionWithInvoice): string[] {
const isExpense = transaction.amount < 0
if (!isExpense) {
return ['Konsultarvode', 'Forsaljning av varor', 'Aterbetalning']
return ['Konsultarvode', 'Försäljning av varor', 'Återbetalning']
}
if (desc.includes('restaurang') || desc.includes('lunch') || desc.includes('middag') || desc.includes('mat')) {
return ['Lunch med kund', 'Personalmiddag', 'Fika till kontoret']
}
if (desc.includes('hotel') || desc.includes('hotell') || desc.includes('boende') || desc.includes('resa')) {
return ['Tjansteresa', 'Hotell konferens', 'Flygbiljett']
return ['Tjänsteresa', 'Hotell konferens', 'Flygbiljett']
}
if (desc.includes('uber') || desc.includes('taxi') || desc.includes('bolt') || desc.includes('sj ')) {
return ['Taxi till kund', 'Tjansteresa', 'Pendling']
return ['Taxi till kund', 'Tjänsteresa', 'Pendling']
}
if (desc.includes('google') || desc.includes('meta') || desc.includes('facebook') || desc.includes('linkedin')) {
return ['Online-annonsering', 'SaaS-prenumeration', 'Marknadsforingskampanj']
return ['Online-annonsering', 'SaaS-prenumeration', 'Marknadsföringskampanj']
}
if (desc.includes('amazon') || desc.includes('aws') || desc.includes('azure') || desc.includes('cloud')) {
return ['Serverhosting', 'SaaS-prenumeration', 'Kontorsmaterial']
}
return ['Kontorsmaterial', 'SaaS-prenumeration', 'Konsulttjanst', 'Reklam']
return ['Kontorsmaterial', 'SaaS-prenumeration', 'Konsulttjänst', 'Reklam']
}
function getVatRateFromTreatment(treatment: string | null): number {
@@ -163,7 +163,7 @@ export default function DescribeTransactionDialog({
if (!response.ok) {
toast({
title: 'Fel',
description: result.error || 'Kunde inte soka mallar',
description: result.error || 'Kunde inte söka mallar',
variant: 'destructive',
})
setIsSearching(false)
@@ -176,7 +176,7 @@ export default function DescribeTransactionDialog({
} catch {
toast({
title: 'Fel',
description: 'Nagot gick fel vid sokning',
description: 'Något gick fel vid sökning',
variant: 'destructive',
})
}
@@ -227,7 +227,7 @@ export default function DescribeTransactionDialog({
if (!response.ok) {
toast({
title: 'Fel',
description: result.error || 'Kunde inte bokfora transaktion',
description: result.error || 'Kunde inte bokföra transaktion',
variant: 'destructive',
})
setIsBooking(false)
@@ -239,14 +239,14 @@ export default function DescribeTransactionDialog({
setIsBooking(false)
onCategorized(transaction.id, result.journal_entry_id || null)
} else {
toast({ title: 'Bokford', description: 'Transaktion bokford och verifikation skapad' })
toast({ title: 'Bokförd', description: 'Transaktion bokförd och verifikation skapad' })
onCategorized(transaction.id, result.journal_entry_id || null)
handleOpenChange(false)
}
} catch {
toast({
title: 'Fel',
description: 'Nagot gick fel vid bokforing',
description: 'Något gick fel vid bokföring',
variant: 'destructive',
})
setIsBooking(false)
@@ -282,7 +282,7 @@ export default function DescribeTransactionDialog({
if (!response.ok) {
toast({
title: 'Fel',
description: result.error || 'Kunde inte bokfora batch',
description: result.error || 'Kunde inte bokföra batch',
variant: 'destructive',
})
setIsBatchApplying(false)
@@ -300,7 +300,7 @@ export default function DescribeTransactionDialog({
} else {
toast({
title: 'Klart',
description: `${applied} transaktioner bokforda`,
description: `${applied} transaktioner bokförda`,
})
}
onBatchApplied?.(applied)
@@ -308,7 +308,7 @@ export default function DescribeTransactionDialog({
} catch {
toast({
title: 'Fel',
description: 'Nagot gick fel vid batchbokforing',
description: 'Något gick fel vid batchbokföring',
variant: 'destructive',
})
setIsBatchApplying(false)
@@ -316,7 +316,7 @@ export default function DescribeTransactionDialog({
}
function handleSkipBatch() {
toast({ title: 'Bokford', description: 'Transaktion bokford och verifikation skapad' })
toast({ title: 'Bokförd', description: 'Transaktion bokförd och verifikation skapad' })
handleOpenChange(false)
}
@@ -337,13 +337,13 @@ export default function DescribeTransactionDialog({
<DialogHeader>
<DialogTitle>
{step === 'describe' && 'Beskriv transaktion'}
{step === 'pick' && 'Valj mall'}
{step === 'batch' && 'Bokfor liknande'}
{step === 'pick' && 'Välj mall'}
{step === 'batch' && 'Bokför liknande'}
</DialogTitle>
<DialogDescription>
{step === 'describe' && 'Beskriv vad transaktionen galler sa hittar vi ratt bokforingsmall'}
{step === 'pick' && 'Valj den mall som stammer bast'}
{step === 'batch' && 'Transaktion bokford!'}
{step === 'describe' && 'Beskriv vad transaktionen gäller så hittar vi rätt bokföringsmall'}
{step === 'pick' && 'Välj den mall som stämmer bäst'}
{step === 'batch' && 'Transaktion bokförd!'}
</DialogDescription>
</DialogHeader>
@@ -351,11 +351,7 @@ export default function DescribeTransactionDialog({
{(step === 'describe' || step === 'pick') && (
<div className="flex items-center gap-3 rounded-lg border p-3">
<div
className={`h-9 w-9 rounded-full flex items-center justify-center flex-shrink-0 ${
isIncome
? 'bg-success/10 text-success'
: 'bg-destructive/10 text-destructive'
}`}
className="h-9 w-9 rounded-full flex items-center justify-center flex-shrink-0 bg-muted text-muted-foreground"
>
{isIncome ? (
<ArrowUpRight className="h-4 w-4" />
@@ -367,7 +363,7 @@ export default function DescribeTransactionDialog({
<p className="font-medium text-sm truncate">{transaction.description}</p>
<p className="text-xs text-muted-foreground">{formatDate(transaction.date)}</p>
</div>
<p className={`font-medium text-sm flex-shrink-0 ${isIncome ? 'text-success' : ''}`}>
<p className="font-medium text-sm flex-shrink-0">
{isIncome ? '+' : ''}
{formatCurrency(transaction.amount, transaction.currency)}
</p>
@@ -378,7 +374,7 @@ export default function DescribeTransactionDialog({
{step === 'describe' && (
<div className="space-y-4">
<Textarea
placeholder="Beskriv vad transaktionen galler, t.ex. 'lunch med kund' eller 'kontorsmaterial'"
placeholder="Beskriv vad transaktionen gäller, t.ex. 'lunch med kund' eller 'kontorsmaterial'"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
@@ -412,7 +408,7 @@ export default function DescribeTransactionDialog({
) : (
<Search className="mr-2 h-4 w-4" />
)}
{isSearching ? 'Soker...' : 'Sok'}
{isSearching ? 'Söker...' : 'Sök'}
</Button>
</div>
)}
@@ -423,7 +419,7 @@ export default function DescribeTransactionDialog({
{describeResult.needs_more_detail && (
<div className="flex items-start gap-2 p-3 rounded-lg bg-amber-500/10 text-amber-700 dark:text-amber-400 text-sm">
<AlertTriangle className="h-4 w-4 flex-shrink-0 mt-0.5" />
<p>Resultaten ar osakra. Forsok beskriv mer detaljerat for battre traffar.</p>
<p>Resultaten är osäkra. Försök beskriv mer detaljerat för bättre träffar.</p>
</div>
)}
@@ -442,7 +438,7 @@ export default function DescribeTransactionDialog({
<div className="flex items-center gap-1.5 mb-1">
<Sparkles className="h-3.5 w-3.5 text-violet-500" />
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300">
AI-forslag
AI-förslag
</Badge>
</div>
<p className="text-sm text-muted-foreground mt-1">
@@ -495,7 +491,7 @@ export default function DescribeTransactionDialog({
{/* Template cards */}
{describeResult.templates.length === 0 && !aiSuggestion ? (
<p className="text-sm text-muted-foreground text-center py-4">
Inga matchande mallar hittades. Forsok med en annan beskrivning.
Inga matchande mallar hittades. Försök med en annan beskrivning.
</p>
) : (
describeResult.templates.map((template) => (
@@ -515,7 +511,7 @@ export default function DescribeTransactionDialog({
<p className="font-medium text-sm">{template.name_sv}</p>
{aiAgreesWithTop && template.template_id === topTemplate.template_id && (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300">
AI bekraftar
AI bekräftar
</Badge>
)}
</div>
@@ -636,7 +632,7 @@ export default function DescribeTransactionDialog({
) : (
<Check className="mr-2 h-4 w-4" />
)}
{isBooking ? 'Bokfor...' : 'Bokfor'}
{isBooking ? 'Bokför...' : 'Bokför'}
</Button>
</div>
</div>
@@ -647,7 +643,7 @@ export default function DescribeTransactionDialog({
<div className="space-y-4">
<div className="flex items-center gap-3 p-4 rounded-lg bg-success/10">
<CheckCircle2 className="h-6 w-6 text-success flex-shrink-0" />
<p className="text-sm font-medium">Transaktionen ar bokford!</p>
<p className="text-sm font-medium">Transaktionen är bokförd!</p>
</div>
{canBatchApply && (
@@ -656,11 +652,11 @@ export default function DescribeTransactionDialog({
<span className="font-medium text-foreground">
{describeResult.batch_candidate_count}
</span>{' '}
obokforda transaktioner fran{' '}
obokförda transaktioner från{' '}
<span className="font-medium text-foreground">
{describeResult.merchant_name}
</span>
. Anvand samma mall?
. Använd samma mall?
</p>
)}
@@ -671,7 +667,7 @@ export default function DescribeTransactionDialog({
onClick={handleSkipBatch}
disabled={isBatchApplying}
>
{canBatchApply ? 'Nej, bara den har' : 'Stang'}
{canBatchApply ? 'Nej, bara den här' : 'Stäng'}
</Button>
{canBatchApply && (
<Button
@@ -683,8 +679,8 @@ export default function DescribeTransactionDialog({
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : null}
{isBatchApplying
? 'Bokfor...'
: `Ja, bokfor alla ${describeResult.batch_candidate_count} st`}
? 'Bokför...'
: `Ja, bokför alla ${describeResult.batch_candidate_count} st`}
</Button>
)}
</div>
+60 -9
View File
@@ -6,8 +6,9 @@ import { Badge } from '@/components/ui/badge'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
import { useToast } from '@/components/ui/use-toast'
import { formatCurrency, formatDate } from '@/lib/utils'
import { ArrowUpRight, ArrowDownRight, Check, Paperclip, ChevronDown, ChevronUp } from 'lucide-react'
import { ArrowUpRight, ArrowDownRight, Check, Paperclip, ChevronDown, ChevronUp, AlertTriangle } from 'lucide-react'
import { getDefaultAccountForCategory } from '@/lib/bookkeeping/category-mapping'
import type { BookingTemplate } from '@/lib/bookkeeping/booking-templates'
import JournalEntryPreview from './JournalEntryPreview'
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
import DocumentUploadZone from '@/components/bookkeeping/DocumentUploadZone'
@@ -26,12 +27,16 @@ interface QuickReviewDialogProps {
defaultAccount: string
defaultVat: VatTreatment | 'none'
entityType?: EntityType
template?: BookingTemplate | null
templateId?: string
onConfirm: (
id: string,
category: TransactionCategory,
vatTreatment: VatTreatment | undefined,
accountOverride: string | undefined
accountOverride: string | undefined,
templateId?: string
) => Promise<string | null>
onChangeTemplate?: () => void
}
export default function QuickReviewDialog({
@@ -43,7 +48,10 @@ export default function QuickReviewDialog({
defaultAccount,
defaultVat,
entityType,
template,
templateId,
onConfirm,
onChangeTemplate,
}: QuickReviewDialogProps) {
const { toast } = useToast()
const [accountOverride, setAccountOverride] = useState(defaultAccount)
@@ -96,7 +104,7 @@ export default function QuickReviewDialog({
? accountOverride
: undefined
const journalEntryId = await onConfirm(transaction.id, category, resolvedVat, override)
const journalEntryId = await onConfirm(transaction.id, category, resolvedVat, override, templateId)
// Link uploaded documents to the journal entry
if (journalEntryId && uploadedFiles.length > 0) {
@@ -116,7 +124,7 @@ export default function QuickReviewDialog({
if (linkFailCount > 0) {
toast({
title: 'Underlag kunde inte bifogas',
description: `${linkFailCount} fil(er) kunde inte lankas till verifikationen.`,
description: `${linkFailCount} fil(er) kunde inte länkas till verifikationen.`,
variant: 'destructive',
})
}
@@ -171,14 +179,57 @@ export default function QuickReviewDialog({
</p>
</div>
{/* Category (read-only) */}
{/* Template or Category */}
<div>
<label className="text-sm font-medium text-muted-foreground">Kategori</label>
<div className="mt-1">
<Badge variant="outline" className="text-sm py-1 px-3">{categoryLabel}</Badge>
<label className="text-sm font-medium text-muted-foreground">
{template ? 'Mall' : 'Kategori'}
</label>
<div className="mt-1 flex items-center gap-2">
<Badge variant="outline" className="text-sm py-1 px-3">
{template ? template.name_sv : categoryLabel}
</Badge>
{onChangeTemplate && (
<button
type="button"
className="text-xs text-primary hover:underline"
onClick={onChangeTemplate}
>
Byt mall
</button>
)}
</div>
</div>
{/* Template special rules */}
{template?.special_rules_sv && (
<div className="rounded-lg border border-amber-300/50 bg-amber-50/50 dark:bg-amber-950/20 px-3 py-2">
<p className="text-xs text-amber-800 dark:text-amber-300 leading-snug">
{template.special_rules_sv}
</p>
</div>
)}
{/* Deductibility note */}
{template?.deductibility_note_sv && (
<div className="rounded-lg border border-blue-300/50 bg-blue-50/50 dark:bg-blue-950/20 px-3 py-2">
<p className="text-xs text-blue-800 dark:text-blue-300 leading-snug">
{template.deductibility_note_sv}
</p>
</div>
)}
{/* Reverse charge warning */}
{template?.requires_vat_registration_data && (
<div className="rounded-lg border border-amber-400/50 bg-amber-50/50 dark:bg-amber-950/20 px-3 py-2">
<div className="flex items-start gap-2">
<AlertTriangle className="h-3.5 w-3.5 text-amber-600 flex-shrink-0 mt-0.5" />
<p className="text-xs text-amber-800 dark:text-amber-300 leading-snug">
Omvänd skattskyldighet kräver leverantörens momsregistreringsnummer och land.
</p>
</div>
</div>
)}
{/* Journal entry preview */}
<JournalEntryPreview
amount={transaction.amount}
@@ -207,7 +258,7 @@ export default function QuickReviewDialog({
<div className="mt-1">
{isLiabilityAccount ? (
<p className="text-sm text-muted-foreground">
Ingen moms for skuld-/eget kapital-konton
Ingen moms för skuld-/eget kapital-konton
</p>
) : showVatDropdown ? (
<VatTreatmentSelect
@@ -10,7 +10,8 @@ import VatTreatmentSelect from './VatTreatmentSelect'
import { formatCurrency, formatDate } from '@/lib/utils'
import { checkExpenseWarnings } from '@/lib/tax/expense-warnings'
import { getDefaultAccountForCategory, getDefaultVatTreatmentForCategory } from '@/lib/bookkeeping/category-mapping'
import { getTemplateById } from '@/lib/bookkeeping/booking-templates'
import { getTemplateById, type BookingTemplate } from '@/lib/bookkeeping/booking-templates'
import TemplatePicker from './TemplatePicker'
import JournalEntryPreview from './JournalEntryPreview'
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
import DocumentUploadZone from '@/components/bookkeeping/DocumentUploadZone'
@@ -47,7 +48,6 @@ export default function SwipeCategorizationView({
entityType,
}: SwipeCategorizationViewProps) {
const { toast } = useToast()
const [showAllCategories, setShowAllCategories] = useState(false)
const [currentIndex, setCurrentIndex] = useState(0)
const [showCategorySelect, setShowCategorySelect] = useState(false)
const [isProcessing, setIsProcessing] = useState(false)
@@ -134,6 +134,18 @@ export default function SwipeCategorizationView({
setError(null)
}, [])
const handlePickerTemplateSelect = useCallback((template: BookingTemplate) => {
setPendingCategory(template.fallback_category)
setAccountOverride(template.debit_account)
setVatTreatment(template.vat_treatment ?? 'none')
setPendingTemplateId(template.id)
setPendingInboxItemId(null)
setShowVatDropdown(false)
setShowCategorySelect(false)
setShowReviewStep(true)
setError(null)
}, [])
const handleTemplateSelect = useCallback((templateId: string, inboxItemId?: string) => {
const template = getTemplateById(templateId)
if (!template) return
@@ -226,7 +238,7 @@ export default function SwipeCategorizationView({
if (linkFailCount > 0) {
toast({
title: 'Underlag kunde inte bifogas',
description: `${linkFailCount} fil(er) kunde inte lankas till verifikationen.`,
description: `${linkFailCount} fil(er) kunde inte länkas till verifikationen.`,
variant: 'destructive',
})
}
@@ -301,8 +313,8 @@ export default function SwipeCategorizationView({
}
if (showCategorySelect) {
const categories =
currentTransaction.amount > 0 ? incomeCategories : expenseCategories
const direction = currentTransaction.amount > 0 ? 'income' : 'expense'
const txSuggestions = templateSuggestions?.[currentTransaction.id]
return (
<div className="fixed inset-0 bg-background z-50 flex flex-col">
@@ -310,46 +322,39 @@ export default function SwipeCategorizationView({
<Button variant="ghost" size="icon" onClick={() => setShowCategorySelect(false)}>
<ArrowLeft className="h-5 w-5" />
</Button>
<h1 className="font-semibold">Välj kategori</h1>
<h1 className="font-semibold">Välj mall</h1>
<div className="w-10" />
</div>
<div className="flex-1 overflow-auto p-4">
<Card className="mb-4">
<CardContent className="pt-4">
<p className="font-medium">{currentTransaction.description}</p>
<p className="text-2xl font-bold mt-2">
{formatCurrency(Math.abs(currentTransaction.amount), currentTransaction.currency)}
</p>
</CardContent>
</Card>
<Card className="mx-4 mt-3">
<CardContent className="pt-4">
<p className="font-medium">{currentTransaction.description}</p>
<p className="text-2xl font-bold mt-2">
{formatCurrency(Math.abs(currentTransaction.amount), currentTransaction.currency)}
</p>
</CardContent>
</Card>
{error && (
<div className="p-3 rounded-lg bg-destructive/10 text-destructive text-sm mb-4">
{error}
</div>
)}
<div className="space-y-2">
{categories.map((cat) => (
<Button
key={cat.value}
variant="outline"
className="w-full justify-start h-auto py-3"
onClick={() => handleCategorySelect(cat.value)}
disabled={isProcessing}
>
<div className="flex items-baseline gap-2">
<span>{cat.label}</span>
{cat.account && <span className="text-xs text-muted-foreground">{cat.account}</span>}
</div>
</Button>
))}
{error && (
<div className="mx-4 mt-2 p-3 rounded-lg bg-destructive/10 text-destructive text-sm">
{error}
</div>
)}
<div className="flex-1 overflow-hidden mt-2">
<TemplatePicker
direction={direction}
entityType={entityType}
suggestedTemplates={txSuggestions}
onSelect={handlePickerTemplateSelect}
selectedTemplateId={pendingTemplateId ?? undefined}
/>
</div>
<div className="p-4 border-t">
<Button
variant="ghost"
className="w-full mt-4 text-muted-foreground"
className="w-full text-muted-foreground"
onClick={handleSkip}
>
<SkipForward className="mr-2 h-4 w-4" />
@@ -364,6 +369,7 @@ export default function SwipeCategorizationView({
const categoryLabel = [...expenseCategories, ...incomeCategories].find(
(c) => c.value === pendingCategory
)?.label || pendingCategory
const selectedTemplate = pendingTemplateId ? getTemplateById(pendingTemplateId) : null
// Auto-clear VAT when a class 2 (liability/equity) account is selected
const isLiabilityAccount = accountOverride.startsWith('2')
@@ -398,14 +404,58 @@ export default function SwipeCategorizationView({
</CardContent>
</Card>
{/* Selected category */}
{/* Selected template or category */}
<div>
<label className="text-sm font-medium text-muted-foreground">Kategori</label>
<div className="mt-1">
<Badge variant="outline" className="text-sm py-1 px-3">{categoryLabel}</Badge>
<label className="text-sm font-medium text-muted-foreground">
{selectedTemplate ? 'Mall' : 'Kategori'}
</label>
<div className="mt-1 flex items-center gap-2">
<Badge variant="outline" className="text-sm py-1 px-3">
{selectedTemplate ? selectedTemplate.name_sv : categoryLabel}
</Badge>
<button
type="button"
className="text-xs text-primary hover:underline"
onClick={() => {
setShowReviewStep(false)
setShowCategorySelect(true)
}}
>
Byt mall
</button>
</div>
</div>
{/* Template special rules warning */}
{selectedTemplate?.special_rules_sv && (
<div className="rounded-lg border border-amber-300/50 bg-amber-50/50 dark:bg-amber-950/20 px-3 py-2.5">
<p className="text-xs text-amber-800 dark:text-amber-300 leading-snug">
{selectedTemplate.special_rules_sv}
</p>
</div>
)}
{/* Deductibility note */}
{selectedTemplate?.deductibility_note_sv && (
<div className="rounded-lg border border-blue-300/50 bg-blue-50/50 dark:bg-blue-950/20 px-3 py-2.5">
<p className="text-xs text-blue-800 dark:text-blue-300 leading-snug">
{selectedTemplate.deductibility_note_sv}
</p>
</div>
)}
{/* Reverse charge VAT registration warning */}
{selectedTemplate?.requires_vat_registration_data && (
<div className="rounded-lg border border-amber-400/50 bg-amber-50/50 dark:bg-amber-950/20 px-3 py-2.5">
<div className="flex items-start gap-2">
<AlertTriangle className="h-4 w-4 text-amber-600 flex-shrink-0 mt-0.5" />
<p className="text-xs text-amber-800 dark:text-amber-300 leading-snug">
Omvänd skattskyldighet kräver leverantörens momsregistreringsnummer och land.
</p>
</div>
</div>
)}
{/* Journal entry preview */}
<JournalEntryPreview
amount={currentTransaction.amount}
@@ -434,7 +484,7 @@ export default function SwipeCategorizationView({
<div className="mt-1">
{isLiabilityAccount ? (
<p className="text-sm text-muted-foreground">
Ingen moms for skuld-/eget kapital-konton
Ingen moms för skuld-/eget kapital-konton
</p>
) : showVatDropdown ? (
<VatTreatmentSelect
@@ -518,7 +568,7 @@ export default function SwipeCategorizationView({
disabled={isProcessing || !accountOverride}
>
<Check className="mr-2 h-4 w-4" />
{isProcessing ? 'Bokfor...' : 'Bokfor'}
{isProcessing ? 'Bokför...' : 'Bokför'}
</Button>
<Button
variant="ghost"
+329
View File
@@ -0,0 +1,329 @@
'use client'
import { useState, useMemo } from 'react'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Search, ChevronDown, ChevronUp, AlertTriangle, Info } from 'lucide-react'
import {
getCommonTemplates,
getAdvancedTemplates,
searchTemplates,
type BookingTemplate,
type TemplateGroup,
} from '@/lib/bookkeeping/booking-templates'
import type { EntityType } from '@/types'
import type { SuggestedTemplate } from '@/lib/transactions/category-suggestions'
const GROUP_ORDER: TemplateGroup[] = [
'premises', 'vehicle', 'it_software', 'office_supplies', 'marketing',
'travel', 'representation', 'insurance', 'professional_services',
'bank_finance', 'telecom', 'education', 'personnel', 'revenue',
'financial', 'private_transfers', 'equipment',
]
const GROUP_LABELS: Record<TemplateGroup, string> = {
premises: 'Lokalkostnader',
vehicle: 'Fordon',
it_software: 'IT & Programvara',
office_supplies: 'Kontorsmaterial',
marketing: 'Marknadsföring',
travel: 'Resor & Transport',
representation: 'Representation',
insurance: 'Försäkringar',
professional_services: 'Professionella tjänster',
bank_finance: 'Bank & Finans',
telecom: 'Telekom & Internet',
education: 'Utbildning',
personnel: 'Personal',
revenue: 'Intäkter',
financial: 'Finansiella poster',
private_transfers: 'Privata transaktioner',
equipment: 'Inventarier & Utrustning',
}
function getVatLabel(template: BookingTemplate): string | null {
if (!template.vat_treatment) return null
switch (template.vat_treatment) {
case 'standard_25': return '25% moms'
case 'reduced_12': return '12% moms'
case 'reduced_6': return '6% moms'
case 'reverse_charge': return 'Omvänd moms'
case 'export': return 'Momsfri (export)'
case 'exempt': return 'Momsfri'
default: return null
}
}
function groupTemplates(templates: BookingTemplate[]): Map<TemplateGroup, BookingTemplate[]> {
const grouped = new Map<TemplateGroup, BookingTemplate[]>()
for (const t of templates) {
const list = grouped.get(t.group) || []
list.push(t)
grouped.set(t.group, list)
}
return grouped
}
interface TemplateCardProps {
template: BookingTemplate
selected: boolean
onClick: () => void
compact?: boolean
}
function TemplateCard({ template, selected, onClick, compact }: TemplateCardProps) {
const vatLabel = getVatLabel(template)
return (
<button
type="button"
onClick={onClick}
className={`w-full text-left rounded-lg border px-3 py-2.5 transition-colors hover:bg-muted/50 ${
selected
? 'border-primary bg-primary/5 ring-1 ring-primary'
: 'border-border'
}`}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<p className={`font-medium ${compact ? 'text-sm' : 'text-sm'} leading-tight`}>
{template.name_sv}
</p>
<div className="flex items-center gap-2 mt-1 flex-wrap">
<span className="text-xs font-mono text-muted-foreground">
D: {template.debit_account} &middot; K: {template.credit_account}
</span>
{vatLabel && (
<Badge
variant="secondary"
className={`text-[10px] px-1.5 py-0 ${
template.vat_treatment === 'reverse_charge'
? 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300'
: ''
}`}
>
{vatLabel}
</Badge>
)}
{template.requires_vat_registration_data && (
<Badge variant="outline" className="text-[10px] px-1.5 py-0 border-amber-400 text-amber-600 dark:text-amber-400 gap-0.5">
<AlertTriangle className="h-2.5 w-2.5" />
Kräver momsreg.nr
</Badge>
)}
</div>
</div>
{template.requires_review && (
<Info className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0 mt-0.5" />
)}
</div>
{template.special_rules_sv && !compact && (
<p className="text-[11px] text-muted-foreground mt-1.5 leading-snug">
{template.special_rules_sv}
</p>
)}
</button>
)
}
interface TemplatePickerProps {
direction: 'expense' | 'income'
entityType?: EntityType
suggestedTemplates?: SuggestedTemplate[]
recentTemplateIds?: string[]
onSelect: (template: BookingTemplate) => void
selectedTemplateId?: string
}
export default function TemplatePicker({
direction,
entityType,
suggestedTemplates,
onSelect,
selectedTemplateId,
}: TemplatePickerProps) {
const [searchQuery, setSearchQuery] = useState('')
const [showAdvanced, setShowAdvanced] = useState(false)
// Map direction to template direction filter (transfers show in both)
const templateDirection = direction === 'income' ? 'income' : 'expense'
const commonTemplates = useMemo(
() => getCommonTemplates(entityType, templateDirection),
[entityType, templateDirection]
)
const advancedTemplates = useMemo(
() => getAdvancedTemplates(entityType, templateDirection),
[entityType, templateDirection]
)
// Also include transfer templates in both directions
const commonTransfers = useMemo(
() => getCommonTemplates(entityType, 'transfer'),
[entityType]
)
const advancedTransfers = useMemo(
() => getAdvancedTemplates(entityType, 'transfer'),
[entityType]
)
const allCommon = useMemo(
() => [...commonTemplates, ...commonTransfers],
[commonTemplates, commonTransfers]
)
const allAdvanced = useMemo(
() => [...advancedTemplates, ...advancedTransfers],
[advancedTemplates, advancedTransfers]
)
// Search results
const searchResults = useMemo(() => {
if (!searchQuery.trim()) return null
return searchTemplates(searchQuery, entityType).filter((t) => {
if (t.direction === templateDirection || t.direction === 'transfer') return true
return false
})
}, [searchQuery, entityType, templateDirection])
// Group templates by group for display
const commonGrouped = useMemo(() => groupTemplates(allCommon), [allCommon])
const advancedGrouped = useMemo(() => groupTemplates(allAdvanced), [allAdvanced])
const handleSelect = (template: BookingTemplate) => {
onSelect(template)
}
// Suggested templates section
const hasSuggestions = suggestedTemplates && suggestedTemplates.length > 0
return (
<div className="flex flex-col h-full">
{/* Search bar */}
<div className="relative px-4 pt-3 pb-2">
<Search className="absolute left-7 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground mt-0.5" />
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Sök mall..."
className="pl-9 h-9"
/>
</div>
{/* Scrollable content */}
<div className="flex-1 overflow-auto px-4 pb-4 space-y-4">
{/* Search results */}
{searchResults !== null ? (
<div>
<p className="text-xs font-medium text-muted-foreground mb-2">
{searchResults.length === 0 ? 'Inga resultat' : `${searchResults.length} resultat`}
</p>
<div className="space-y-1.5">
{searchResults.map((t) => (
<TemplateCard
key={t.id}
template={t}
selected={selectedTemplateId === t.id}
onClick={() => handleSelect(t)}
/>
))}
</div>
</div>
) : (
<>
{/* Suggested templates */}
{hasSuggestions && (
<div>
<p className="text-xs font-medium text-muted-foreground mb-2">Föreslagna</p>
<div className="space-y-1.5">
{suggestedTemplates!.slice(0, 5).map((s) => {
// Find the full template object
const fullTemplate = allCommon.find((t) => t.id === s.template_id) ||
allAdvanced.find((t) => t.id === s.template_id)
if (!fullTemplate) return null
return (
<TemplateCard
key={s.template_id}
template={fullTemplate}
selected={selectedTemplateId === s.template_id}
onClick={() => handleSelect(fullTemplate)}
compact
/>
)
})}
</div>
</div>
)}
{/* Common templates grouped */}
<div>
<p className="text-xs font-medium text-muted-foreground mb-2">Vanliga mallar</p>
<div className="space-y-3">
{GROUP_ORDER.filter((g) => commonGrouped.has(g)).map((group) => (
<div key={group}>
<p className="text-[11px] font-medium text-muted-foreground/70 uppercase tracking-wider mb-1">
{GROUP_LABELS[group]}
</p>
<div className="space-y-1.5">
{commonGrouped.get(group)!.map((t) => (
<TemplateCard
key={t.id}
template={t}
selected={selectedTemplateId === t.id}
onClick={() => handleSelect(t)}
compact
/>
))}
</div>
</div>
))}
</div>
</div>
{/* Advanced templates (collapsible) */}
{allAdvanced.length > 0 && (
<div>
<Button
variant="ghost"
size="sm"
className="w-full justify-between text-xs text-muted-foreground h-8"
onClick={() => setShowAdvanced(!showAdvanced)}
>
Fler mallar ({allAdvanced.length})
{showAdvanced ? (
<ChevronUp className="h-3.5 w-3.5" />
) : (
<ChevronDown className="h-3.5 w-3.5" />
)}
</Button>
{showAdvanced && (
<div className="space-y-3 mt-2">
{GROUP_ORDER.filter((g) => advancedGrouped.has(g)).map((group) => (
<div key={group}>
<p className="text-[11px] font-medium text-muted-foreground/70 uppercase tracking-wider mb-1">
{GROUP_LABELS[group]}
</p>
<div className="space-y-1.5">
{advancedGrouped.get(group)!.map((t) => (
<TemplateCard
key={t.id}
template={t}
selected={selectedTemplateId === t.id}
onClick={() => handleSelect(t)}
compact
/>
))}
</div>
</div>
))}
</div>
)}
</div>
)}
</>
)}
</div>
</div>
)
}
@@ -72,7 +72,7 @@ export default function TransactionBookingDialog({
if (linkFailCount > 0) {
toast({
title: 'Underlag kunde inte bifogas',
description: `${linkFailCount} fil(er) kunde inte lankas till verifikationen. Forsok igen via bokforingssidan.`,
description: `${linkFailCount} fil(er) kunde inte länkas till verifikationen. Försök igen via bokföringssidan.`,
variant: 'destructive',
})
}
@@ -93,9 +93,9 @@ export default function TransactionBookingDialog({
}}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Bokfor transaktion</DialogTitle>
<DialogTitle>Bokför transaktion</DialogTitle>
<DialogDescription>
Skapa en verifikation for transaktionen
Skapa en verifikation för transaktionen
</DialogDescription>
</DialogHeader>
@@ -83,11 +83,7 @@ export default function TransactionHistoryList({
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div
className={`h-10 w-10 rounded-full flex items-center justify-center ${
transaction.amount > 0
? 'bg-success/10 text-success'
: 'bg-destructive/10 text-destructive'
}`}
className="h-10 w-10 rounded-full flex items-center justify-center bg-muted text-muted-foreground"
>
{transaction.amount > 0 ? (
<ArrowUpRight className="h-5 w-5" />
@@ -173,11 +169,7 @@ export default function TransactionHistoryList({
</Button>
)}
<div className="text-right">
<p
className={`font-medium ${
transaction.amount > 0 ? 'text-success' : ''
}`}
>
<p className="font-medium">
{transaction.amount > 0 ? '+' : ''}
{formatCurrency(transaction.amount, transaction.currency)}
</p>
@@ -9,6 +9,7 @@ import { formatCurrency, formatDate } from '@/lib/utils'
import { ArrowUpRight, ArrowDownRight, FileText, Loader2, MessageSquareText, Paperclip } from 'lucide-react'
import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@/components/ui/info-tooltip'
import { formatAccountWithName } from '@/lib/bookkeeping/client-account-names'
import { getTemplateById } from '@/lib/bookkeeping/booking-templates'
import type { TransactionWithInvoice, CategorizeHandler } from './transaction-types'
import type { SuggestedCategory, SuggestedTemplate } from '@/lib/transactions/category-suggestions'
@@ -26,6 +27,7 @@ interface TransactionInboxCardProps {
onOpenCategoryDialog: (transaction: TransactionWithInvoice) => void
onOpenDescribe?: (transaction: TransactionWithInvoice) => void
onOpenQuickReview?: (transaction: TransactionWithInvoice, suggestion: SuggestedCategory) => void
onOpenTemplateReview?: (transaction: TransactionWithInvoice, templateId: string) => void
onToggleSelect: (id: string) => void
onAnimationComplete?: (id: string) => void
}
@@ -44,6 +46,7 @@ export default function TransactionInboxCard({
onOpenCategoryDialog,
onOpenDescribe,
onOpenQuickReview,
onOpenTemplateReview,
onToggleSelect,
onAnimationComplete,
}: TransactionInboxCardProps) {
@@ -174,6 +177,36 @@ export default function TransactionInboxCard({
)}
Matcha Leverantörsfaktura {transaction.potential_supplier_invoice!.supplier_invoice_number}
</Button>
) : templateSuggestions && templateSuggestions.length > 0 ? (
<>
{templateSuggestions.slice(0, 2).map((ts, idx) => {
const tmpl = getTemplateById(ts.template_id)
return (
<Button
key={ts.template_id}
size="sm"
variant={idx === 0 ? 'default' : 'outline'}
className="h-8 text-xs"
onClick={() => {
if (onOpenTemplateReview && tmpl) {
onOpenTemplateReview(transaction, ts.template_id)
} else if (topSuggestion) {
handleSuggestionClick(topSuggestion)
}
}}
disabled={isProcessing || isDisabled}
>
{isProcessing && idx === 0 ? (
<Loader2 className="mr-1.5 h-3 w-3 animate-spin" />
) : null}
{ts.name_sv}
<span className="ml-1 opacity-70 font-normal">
({ts.debit_account})
</span>
</Button>
)
})}
</>
) : topSuggestion ? (
<Button
size="sm"
@@ -199,24 +232,6 @@ export default function TransactionInboxCard({
</Button>
) : null}
{/* Secondary suggestions (up to 1 more) */}
{!hasInvoiceMatch && suggestions && suggestions.length > 1 && (
<Button
size="sm"
variant="outline"
className="h-8 text-xs"
onClick={() => handleSuggestionClick(suggestions[1])}
disabled={isProcessing || isDisabled}
>
{suggestions[1].label}
{suggestions[1].account && (
<span className="ml-1 text-muted-foreground font-normal">
({formatAccountWithName(suggestions[1].account)})
</span>
)}
</Button>
)}
{/* Describe transaction */}
{onOpenDescribe && (
<Button
@@ -231,15 +246,17 @@ export default function TransactionInboxCard({
</Button>
)}
{/* Open category dialog */}
{/* Open category dialog / template picker */}
<Button
size="sm"
variant={!hasInvoiceMatch && !topSuggestion ? 'default' : 'outline'}
variant={!hasInvoiceMatch && !hasSupplierInvoiceMatch && !topSuggestion && (!templateSuggestions || templateSuggestions.length === 0) ? 'default' : 'outline'}
className="h-8 text-xs"
onClick={() => onOpenCategoryDialog(transaction)}
disabled={isProcessing || isDisabled}
>
{!hasInvoiceMatch && !topSuggestion ? 'Bokför' : 'Bokför manuellt...'}
{!hasInvoiceMatch && !hasSupplierInvoiceMatch && !topSuggestion && (!templateSuggestions || templateSuggestions.length === 0)
? 'Välj mall...'
: 'Välj mall...'}
</Button>
</div>
)}
+6 -4
View File
@@ -55,10 +55,12 @@ export function ConfirmationDialog({
</div>
<div className="border-t px-6 py-4 space-y-4">
<div className="flex items-start gap-2 rounded-lg bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800 p-3">
<AlertTriangle className="h-4 w-4 text-amber-600 dark:text-amber-400 mt-0.5 shrink-0" />
<p className="text-sm text-amber-800 dark:text-amber-300">{warningText}</p>
</div>
{warningText && (
<div className="flex items-start gap-2 rounded-lg bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800 p-3">
<AlertTriangle className="h-4 w-4 text-amber-600 dark:text-amber-400 mt-0.5 shrink-0" />
<p className="text-sm text-amber-800 dark:text-amber-300">{warningText}</p>
</div>
)}
<DialogFooter>
<Button
+1 -1
View File
@@ -67,7 +67,7 @@ export function EmptyState({
</Link>
)}
{onAction && actionLabel && (
<Button onClick={onAction} className="bg-accent hover:bg-accent/90 text-accent-foreground">
<Button onClick={onAction}>
<Plus className="mr-2 h-4 w-4" />
{actionLabel}
</Button>