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:
co-authored by
Claude Opus 4.6
parent
a9bf24ce0b
commit
66a4027f1e
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 "obokförda" 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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 "Förhandsgranska" för att se SRU-uppgifter för valt räkenskapsår.
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SRUBalanceRow({
|
||||
balance,
|
||||
}: {
|
||||
balance: SRUExportResult['balances'][number]
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<tr
|
||||
className="border-b cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => balance.accounts.length > 0 && setExpanded(!expanded)}
|
||||
>
|
||||
<td className="py-2 font-mono">{balance.sruCode}</td>
|
||||
<td className="py-2">
|
||||
{balance.description}
|
||||
{balance.accounts.length > 0 && (
|
||||
<span className="text-xs text-muted-foreground ml-2">
|
||||
({balance.accounts.length} konton)
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 text-right">{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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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} · 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>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user