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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-03-04 14:19:56 +01:00
co-authored by Claude Opus 4.6
parent a9bf24ce0b
commit 66a4027f1e
104 changed files with 4964 additions and 5501 deletions
+60 -53
View File
@@ -5,9 +5,8 @@ import { createClient } from '@/lib/supabase/client'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { useToast } from '@/components/ui/use-toast'
import { Loader2, Mail, Sparkles } from 'lucide-react'
import { Loader2, Mail, ArrowLeft } from 'lucide-react'
import { getErrorMessage } from '@/lib/errors/get-error-message'
export default function LoginPage() {
@@ -61,47 +60,56 @@ export default function LoginPage() {
if (isEmailSent) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-primary/5 via-background to-primary/10 p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="mx-auto mb-4 h-12 w-12 rounded-full bg-primary/10 flex items-center justify-center">
<Mail className="h-6 w-6 text-primary" />
<div className="min-h-screen flex flex-col items-center justify-center bg-gradient-to-b from-background to-primary/[0.03] p-4">
<div className="w-full max-w-sm animate-slide-up space-y-8">
<div className="flex justify-center">
<div className="h-14 w-14 rounded-2xl bg-primary/8 flex items-center justify-center">
<Mail className="h-7 w-7 text-primary" />
</div>
<CardTitle className="text-2xl">Kolla din e-post</CardTitle>
<CardDescription>
Vi har skickat en inloggningslänk till <strong>{email}</strong>
</CardDescription>
</CardHeader>
<CardContent className="text-center">
<p className="text-sm text-muted-foreground mb-4">
Klicka på länken i e-posten för att logga in. Länken är giltig i 1 timme.
</div>
<div className="text-center space-y-2">
<h1 className="text-2xl font-medium tracking-tight">Kolla din e-post</h1>
<p className="text-muted-foreground text-sm leading-relaxed">
Vi har skickat en inloggningslänk till{' '}
<span className="font-medium text-foreground">{email}</span>
</p>
<Button
variant="ghost"
onClick={() => setIsEmailSent(false)}
>
Använd en annan e-post
</Button>
</CardContent>
</Card>
</div>
<div className="rounded-xl border bg-card p-4">
<p className="text-sm text-muted-foreground text-center leading-relaxed">
Klicka på länken i e-posten för att logga in.
Länken är giltig i 1 timme.
</p>
</div>
<Button
variant="ghost"
className="w-full text-muted-foreground"
onClick={() => setIsEmailSent(false)}
>
<ArrowLeft className="mr-2 h-4 w-4" />
Använd en annan e-post
</Button>
</div>
</div>
)
}
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-primary/5 via-background to-primary/10 p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="mx-auto mb-4 h-12 w-12 rounded-full bg-primary/10 flex items-center justify-center">
<Sparkles className="h-6 w-6 text-primary" />
</div>
<CardTitle className="text-2xl">ERP Base</CardTitle>
<CardDescription>
<div className="min-h-screen flex flex-col items-center justify-center bg-gradient-to-b from-background to-primary/[0.03] p-4">
<div className="w-full max-w-sm animate-slide-up">
<div className="text-center mb-10">
<h1 className="font-display text-[2.75rem] leading-none font-medium tracking-tight text-foreground">
Gnubok
</h1>
<p className="text-muted-foreground text-sm mt-3">
Logga in med din e-post för att hantera din ekonomi
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleLogin} className="space-y-4">
</p>
</div>
<div className="rounded-xl border bg-card p-6" style={{ boxShadow: 'var(--shadow-md)' }}>
<form onSubmit={handleLogin} className="space-y-5">
<div className="space-y-2">
<Label htmlFor="email">E-postadress</Label>
<Input
@@ -114,11 +122,12 @@ export default function LoginPage() {
onChange={(e) => setEmail(e.target.value)}
required
disabled={isLoading}
className="h-11"
/>
</div>
<Button
type="submit"
className="w-full"
className="w-full h-11"
disabled={isLoading}
>
{isLoading ? (
@@ -127,26 +136,24 @@ export default function LoginPage() {
Skickar...
</>
) : (
<>
<Mail className="mr-2 h-4 w-4" />
Skicka inloggningslänk
</>
'Skicka inloggningslänk'
)}
</Button>
</form>
<p className="mt-4 text-center text-sm text-muted-foreground">
Genom att logga in godkänner du våra{' '}
<a href="#" className="underline hover:text-primary">
villkor
</a>{' '}
och{' '}
<a href="#" className="underline hover:text-primary">
integritetspolicy
</a>
.
</p>
</CardContent>
</Card>
</div>
<p className="mt-6 text-center text-xs text-muted-foreground leading-relaxed">
Genom att logga in godkänner du våra{' '}
<a href="#" className="underline underline-offset-2 hover:text-foreground transition-colors">
villkor
</a>{' '}
och{' '}
<a href="#" className="underline underline-offset-2 hover:text-foreground transition-colors">
integritetspolicy
</a>
.
</p>
</div>
</div>
)
}
+13 -681
View File
@@ -1,174 +1,13 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import Link from 'next/link'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { SuccessAnimation } from '@/components/ui/success-animation'
import { useToast } from '@/components/ui/use-toast'
import {
CheckCircle2,
AlertCircle,
AlertTriangle,
ArrowLeft,
ArrowRight,
Loader2,
Lock,
BookOpen,
ChevronDown,
ChevronUp,
} from 'lucide-react'
import { AccountNumber } from '@/components/ui/account-number'
import type {
FiscalPeriod,
YearEndValidation,
YearEndPreview,
YearEndResult,
} from '@/types'
function formatAmount(amount: number): string {
return amount.toLocaleString('sv-SE', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})
}
const STEP_LABELS = ['Välj period', 'Validering', 'Förhandsgranskning', 'Genomför']
import { ArrowLeft, Lock } from 'lucide-react'
export default function YearEndPage() {
const { toast } = useToast()
const [step, setStep] = useState(0)
const [periods, setPeriods] = useState<FiscalPeriod[]>([])
const [selectedPeriodId, setSelectedPeriodId] = useState('')
const [validation, setValidation] = useState<YearEndValidation | null>(null)
const [preview, setPreview] = useState<YearEndPreview | null>(null)
const [result, setResult] = useState<YearEndResult | null>(null)
const [loading, setLoading] = useState(false)
const [loadingPeriods, setLoadingPeriods] = useState(true)
const [executing, setExecuting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [showConfirmDialog, setShowConfirmDialog] = useState(false)
const [showLinesDetail, setShowLinesDetail] = useState(false)
const [showSuccess, setShowSuccess] = useState(false)
const selectedPeriod = periods.find((p) => p.id === selectedPeriodId)
useEffect(() => {
fetchPeriods()
}, [])
async function fetchPeriods() {
try {
const res = await fetch('/api/bookkeeping/fiscal-periods')
const { data } = await res.json()
const allPeriods: FiscalPeriod[] = data || []
setPeriods(allPeriods)
// Pre-select first open period
const openPeriod = allPeriods.find((p) => !p.is_closed)
if (openPeriod) {
setSelectedPeriodId(openPeriod.id)
}
} catch {
toast({ title: 'Fel', description: 'Kunde inte hämta räkenskapsår', variant: 'destructive' })
} finally {
setLoadingPeriods(false)
}
}
const fetchValidationAndPreview = useCallback(async () => {
if (!selectedPeriodId) return
setLoading(true)
setError(null)
setValidation(null)
setPreview(null)
try {
const res = await fetch(`/api/bookkeeping/fiscal-periods/${selectedPeriodId}/year-end`)
const json = await res.json()
if (!res.ok) {
setError(json.error || 'Kunde inte validera perioden')
return
}
setValidation(json.data.validation)
setPreview(json.data.preview)
} catch {
setError('Nätverksfel vid validering')
} finally {
setLoading(false)
}
}, [selectedPeriodId])
async function executeYearEnd() {
setShowConfirmDialog(false)
setExecuting(true)
setError(null)
try {
const res = await fetch(`/api/bookkeeping/fiscal-periods/${selectedPeriodId}/year-end`, {
method: 'POST',
})
const json = await res.json()
if (!res.ok) {
setError(json.error || 'Årsbokslut misslyckades')
toast({ title: 'Fel', description: json.error || 'Årsbokslut misslyckades', variant: 'destructive' })
return
}
setResult(json.data)
setShowSuccess(true)
} catch {
setError('Nätverksfel vid genomförande')
toast({ title: 'Fel', description: 'Nätverksfel vid genomförande', variant: 'destructive' })
} finally {
setExecuting(false)
}
}
function goToStep(nextStep: number) {
if (nextStep === 1 && !validation) {
fetchValidationAndPreview()
}
setStep(nextStep)
}
function getPeriodStatus(period: FiscalPeriod) {
if (period.is_closed) return { label: 'Stängd', variant: 'secondary' as const }
if (period.locked_at) return { label: 'Låst', variant: 'outline' as const }
return { label: 'Öppen', variant: 'default' as const }
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Årsbokslut</h1>
<p className="text-muted-foreground">
Stäng räkenskapsåret och generera ingående balanser
</p>
</div>
<h1 className="text-3xl font-bold tracking-tight">Årsbokslut</h1>
<Button variant="outline" asChild>
<Link href="/bookkeeping">
<ArrowLeft className="mr-2 h-4 w-4" />
@@ -177,524 +16,17 @@ export default function YearEndPage() {
</Button>
</div>
{/* Step indicator */}
<div className="flex items-center gap-2">
{STEP_LABELS.map((label, i) => (
<div key={label} className="flex items-center gap-2">
<div className="flex items-center gap-1.5">
<div
className={`flex h-7 w-7 items-center justify-center rounded-full text-xs font-medium ${
i < step
? 'bg-primary text-primary-foreground'
: i === step
? 'bg-primary text-primary-foreground ring-2 ring-primary/30'
: 'bg-muted text-muted-foreground'
}`}
>
{i < step ? <CheckCircle2 className="h-4 w-4" /> : i + 1}
</div>
<span
className={`text-sm hidden sm:inline ${
i === step ? 'font-medium' : 'text-muted-foreground'
}`}
>
{label}
</span>
</div>
{i < STEP_LABELS.length - 1 && (
<div className={`h-px w-8 ${i < step ? 'bg-primary' : 'bg-border'}`} />
)}
<Card>
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-muted mb-4">
<Lock className="h-7 w-7 text-muted-foreground" />
</div>
))}
</div>
{/* Error banner */}
{error && (
<Card className="border-destructive">
<CardContent className="flex items-center gap-3 py-4">
<AlertCircle className="h-5 w-5 text-destructive flex-shrink-0" />
<p className="text-sm text-destructive">{error}</p>
</CardContent>
</Card>
)}
{/* Step 0: Period Selection */}
{step === 0 && (
<Card>
<CardHeader>
<CardTitle>Välj räkenskapsår att stänga</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{loadingPeriods ? (
<div className="space-y-3">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</div>
) : periods.length === 0 ? (
<p className="text-muted-foreground text-sm">
Inga räkenskapsår hittades. Skapa ett räkenskapsår först.
</p>
) : (
<>
<div className="space-y-2">
{periods.map((period) => {
const status = getPeriodStatus(period)
const isSelected = period.id === selectedPeriodId
return (
<button
key={period.id}
onClick={() => setSelectedPeriodId(period.id)}
disabled={period.is_closed}
className={`w-full flex items-center justify-between rounded-lg border p-4 text-left transition-colors ${
isSelected
? 'border-primary bg-primary/5'
: period.is_closed
? 'border-border bg-muted/50 opacity-60 cursor-not-allowed'
: 'border-border hover:border-primary/50 hover:bg-muted/30'
}`}
>
<div>
<p className="font-medium">{period.name}</p>
<p className="text-sm text-muted-foreground">
{period.period_start} – {period.period_end}
</p>
</div>
<Badge variant={status.variant}>{status.label}</Badge>
</button>
)
})}
</div>
<div className="flex justify-end">
<Button
onClick={() => goToStep(1)}
disabled={!selectedPeriodId || (selectedPeriod?.is_closed ?? false)}
>
Nästa
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</>
)}
</CardContent>
</Card>
)}
{/* Step 1: Validation */}
{step === 1 && (
<Card>
<CardHeader>
<CardTitle>Validering — {selectedPeriod?.name}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{loading ? (
<div className="space-y-3">
<Skeleton className="h-6 w-3/4" />
<Skeleton className="h-6 w-1/2" />
<Skeleton className="h-6 w-2/3" />
</div>
) : validation ? (
<>
{/* Ready indicator */}
<div
className={`flex items-center gap-3 rounded-lg p-4 ${
validation.ready
? 'bg-green-50 dark:bg-green-950/20'
: 'bg-red-50 dark:bg-red-950/20'
}`}
>
{validation.ready ? (
<CheckCircle2 className="h-6 w-6 text-green-600 dark:text-green-400" />
) : (
<AlertCircle className="h-6 w-6 text-red-600 dark:text-red-400" />
)}
<div>
<p className="font-medium">
{validation.ready
? 'Perioden är redo för årsbokslut'
: 'Perioden kan inte stängas ännu'}
</p>
{!validation.ready && (
<p className="text-sm text-muted-foreground">
Åtgärda felen nedan innan du kan fortsätta
</p>
)}
</div>
</div>
{/* Errors */}
{validation.errors.length > 0 && (
<div className="space-y-2">
<p className="text-sm font-medium text-red-600 dark:text-red-400">Fel som måste åtgärdas</p>
{validation.errors.map((err, i) => (
<div key={i} className="flex items-start gap-2 text-sm">
<AlertCircle className="h-4 w-4 text-red-500 mt-0.5 flex-shrink-0" />
<span>{err}</span>
</div>
))}
</div>
)}
{/* Warnings */}
{validation.warnings.length > 0 && (
<div className="space-y-2">
<p className="text-sm font-medium text-amber-600 dark:text-amber-400">Varningar</p>
{validation.warnings.map((warn, i) => (
<div key={i} className="flex items-start gap-2 text-sm">
<AlertTriangle className="h-4 w-4 text-amber-500 mt-0.5 flex-shrink-0" />
<span>{warn}</span>
</div>
))}
</div>
)}
{/* Details */}
<div className="grid grid-cols-2 gap-4 text-sm">
<div className="rounded-lg border p-3">
<p className="text-muted-foreground">Utkast kvar</p>
<p className="text-lg font-medium">{validation.draftCount}</p>
</div>
<div className="rounded-lg border p-3">
<p className="text-muted-foreground">Saldobalans</p>
<p className="text-lg font-medium">
{validation.trialBalanceBalanced ? 'Balanserad' : 'Obalanserad'}
</p>
</div>
</div>
{/* Voucher gaps */}
{validation.voucherGaps.length > 0 && (
<div className="rounded-lg border p-3">
<p className="text-sm font-medium mb-2">Verifikationsnummerluckor</p>
<div className="flex flex-wrap gap-2">
{validation.voucherGaps.map((gap, i) => (
<Badge key={i} variant="outline">
{gap.gap_start}–{gap.gap_end}
</Badge>
))}
</div>
</div>
)}
</>
) : null}
<div className="flex justify-between">
<Button variant="outline" onClick={() => setStep(0)}>
<ArrowLeft className="mr-2 h-4 w-4" />
Tillbaka
</Button>
<div className="flex gap-2">
<Button variant="outline" onClick={fetchValidationAndPreview} disabled={loading}>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Validera igen
</Button>
<Button
onClick={() => goToStep(2)}
disabled={!validation?.ready}
>
Nästa
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</div>
</CardContent>
</Card>
)}
{/* Step 2: Preview */}
{step === 2 && preview && (
<div className="space-y-4">
{/* Net result highlight */}
<Card>
<CardContent className="py-6">
<div className="text-center">
<p className="text-sm text-muted-foreground mb-1">Årets resultat</p>
<p
className={`text-4xl font-bold tracking-tight ${
preview.netResult >= 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'
}`}
>
{formatAmount(preview.netResult)} kr
</p>
<p className="text-sm text-muted-foreground mt-2">
Bokförs på {preview.closingAccount} — {preview.closingAccountName}
</p>
</div>
</CardContent>
</Card>
{/* Result account summary */}
<Card>
<CardHeader>
<CardTitle>Resultatkonton som nollställs</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Konto</TableHead>
<TableHead>Namn</TableHead>
<TableHead className="text-right">Belopp</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{preview.resultAccountSummary.map((account) => (
<TableRow key={account.account_number}>
<TableCell><AccountNumber number={account.account_number} name={account.account_name} /></TableCell>
<TableCell>{account.account_name}</TableCell>
<TableCell className="text-right font-mono">
{formatAmount(account.amount)} kr
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
{/* Closing journal lines (expandable) */}
<Card>
<CardHeader>
<button
onClick={() => setShowLinesDetail(!showLinesDetail)}
className="flex items-center justify-between w-full"
>
<CardTitle>Bokslutsverifikation ({preview.closingLines.length} rader)</CardTitle>
{showLinesDetail ? (
<ChevronUp className="h-5 w-5 text-muted-foreground" />
) : (
<ChevronDown className="h-5 w-5 text-muted-foreground" />
)}
</button>
</CardHeader>
{showLinesDetail && (
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Konto</TableHead>
<TableHead>Beskrivning</TableHead>
<TableHead className="text-right">Debet</TableHead>
<TableHead className="text-right">Kredit</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{preview.closingLines.map((line, i) => (
<TableRow key={i}>
<TableCell><AccountNumber number={line.account_number} /></TableCell>
<TableCell>{line.line_description}</TableCell>
<TableCell className="text-right font-mono">
{line.debit_amount > 0 ? formatAmount(line.debit_amount) : ''}
</TableCell>
<TableCell className="text-right font-mono">
{line.credit_amount > 0 ? formatAmount(line.credit_amount) : ''}
</TableCell>
</TableRow>
))}
{/* Totals row */}
<TableRow className="font-medium border-t-2">
<TableCell colSpan={2}>Summa</TableCell>
<TableCell className="text-right font-mono">
{formatAmount(
preview.closingLines.reduce((sum, l) => sum + l.debit_amount, 0)
)}
</TableCell>
<TableCell className="text-right font-mono">
{formatAmount(
preview.closingLines.reduce((sum, l) => sum + l.credit_amount, 0)
)}
</TableCell>
</TableRow>
</TableBody>
</Table>
</CardContent>
)}
</Card>
{/* Navigation */}
<div className="flex justify-between">
<Button variant="outline" onClick={() => setStep(1)}>
<ArrowLeft className="mr-2 h-4 w-4" />
Tillbaka
</Button>
<Button onClick={() => goToStep(3)}>
Nästa
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</div>
)}
{/* Step 3: Execute */}
{step === 3 && !result && (
<Card>
<CardHeader>
<CardTitle>Genomför årsbokslut</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="rounded-lg border p-4 space-y-3">
<p className="text-sm text-muted-foreground">
Följande åtgärder kommer att genomföras:
</p>
<ul className="space-y-2 text-sm">
<li className="flex items-start gap-2">
<BookOpen className="h-4 w-4 mt-0.5 text-muted-foreground flex-shrink-0" />
Bokslutsverifikation skapas med {preview?.closingLines.length} rader
</li>
<li className="flex items-start gap-2">
<Lock className="h-4 w-4 mt-0.5 text-muted-foreground flex-shrink-0" />
Perioden {selectedPeriod?.name} låses och stängs permanent
</li>
<li className="flex items-start gap-2">
<ArrowRight className="h-4 w-4 mt-0.5 text-muted-foreground flex-shrink-0" />
Nytt räkenskapsår skapas med ingående balanser
</li>
</ul>
{preview && (
<div className="pt-2 border-t">
<p className="text-sm">
Årets resultat:{' '}
<span className="font-medium">
{formatAmount(preview.netResult)} kr
</span>{' '}
→ {preview.closingAccount} ({preview.closingAccountName})
</p>
</div>
)}
</div>
<div className="rounded-lg bg-amber-50 dark:bg-amber-950/20 border border-amber-200 dark:border-amber-800 p-4">
<div className="flex items-start gap-3">
<AlertTriangle className="h-5 w-5 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" />
<div>
<p className="text-sm font-medium text-amber-800 dark:text-amber-200">
Denna åtgärd kan inte ångras
</p>
<p className="text-sm text-amber-700 dark:text-amber-300 mt-1">
Perioden stängs permanent enligt Bokföringslagen. Säkerställ att alla bokföringar
är korrekta innan du fortsätter.
</p>
</div>
</div>
</div>
<div className="flex justify-between">
<Button variant="outline" onClick={() => setStep(2)}>
<ArrowLeft className="mr-2 h-4 w-4" />
Tillbaka
</Button>
<Button
variant="destructive"
onClick={() => setShowConfirmDialog(true)}
disabled={executing}
>
{executing && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Genomför årsbokslut
</Button>
</div>
</CardContent>
</Card>
)}
{/* Step 3: Success state */}
{step === 3 && result && (
<Card>
<CardContent className="py-8">
<div className="text-center space-y-4">
<div className="flex justify-center">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-green-100 dark:bg-green-950/30">
<CheckCircle2 className="h-8 w-8 text-green-600 dark:text-green-400" />
</div>
</div>
<div>
<h2 className="text-xl font-semibold">Årsbokslutet är genomfört</h2>
<p className="text-muted-foreground mt-1">
{selectedPeriod?.name} har stängts och ett nytt räkenskapsår har skapats.
</p>
</div>
<div className="grid gap-3 max-w-md mx-auto text-left">
<div className="flex items-center justify-between rounded-lg border p-3 text-sm">
<span className="text-muted-foreground">Bokslutsverifikation</span>
<Link
href="/bookkeeping"
className="text-primary hover:underline font-medium"
>
Visa
</Link>
</div>
<div className="flex items-center justify-between rounded-lg border p-3 text-sm">
<span className="text-muted-foreground">Period stängd</span>
<Badge variant="secondary">
<Lock className="mr-1 h-3 w-3" />
Stängd
</Badge>
</div>
<div className="flex items-center justify-between rounded-lg border p-3 text-sm">
<span className="text-muted-foreground">Nytt räkenskapsår</span>
<span className="font-medium">{result.nextPeriod.name}</span>
</div>
<div className="flex items-center justify-between rounded-lg border p-3 text-sm">
<span className="text-muted-foreground">Ingående balanser</span>
<Badge variant="default">
<CheckCircle2 className="mr-1 h-3 w-3" />
Skapade
</Badge>
</div>
</div>
<div className="pt-4">
<Button asChild>
<Link href="/bookkeeping">
<ArrowLeft className="mr-2 h-4 w-4" />
Tillbaka till bokföring
</Link>
</Button>
</div>
</div>
</CardContent>
</Card>
)}
{/* Confirmation dialog */}
<Dialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Bekräfta årsbokslut</DialogTitle>
<DialogDescription>
Är du säker på att du vill stänga <strong>{selectedPeriod?.name}</strong>?
Denna åtgärd kan inte ångras. Perioden kommer att stängas permanent.
</DialogDescription>
</DialogHeader>
{preview && (
<div className="rounded-lg border p-3 text-sm">
<p>
Årets resultat:{' '}
<span className="font-medium">{formatAmount(preview.netResult)} kr</span>
</p>
<p className="text-muted-foreground">
Bokförs på {preview.closingAccount} — {preview.closingAccountName}
</p>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setShowConfirmDialog(false)}>
Avbryt
</Button>
<Button variant="destructive" onClick={executeYearEnd}>
Stäng perioden
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Success animation overlay */}
<SuccessAnimation
show={showSuccess}
title="Årsbokslut genomfört!"
description={`${selectedPeriod?.name} har stängts`}
variant="celebration"
/>
<h2 className="text-xl font-semibold mb-2">Kommer snart</h2>
<p className="text-muted-foreground max-w-md">
Årsbokslut är under utveckling och kommer att finnas tillgängligt i en kommande version.
</p>
</CardContent>
</Card>
</div>
)
}
+7 -7
View File
@@ -28,9 +28,9 @@ import type { Customer, CustomerType, CreateCustomerInput } from '@/types'
const customerTypeLabels: Record<CustomerType, string> = {
individual: 'Privatperson',
swedish_business: 'Svenskt foretag',
eu_business: 'EU-foretag',
non_eu_business: 'Utanfor EU',
swedish_business: 'Svenskt företag',
eu_business: 'EU-företag',
non_eu_business: 'Utanför EU',
}
const customerTypeIcons: Record<CustomerType, React.ElementType> = {
@@ -263,7 +263,7 @@ export default function CustomerDetailPage({
{/* Business details */}
<Card>
<CardHeader>
<CardTitle className="text-base">Foretagsuppgifter</CardTitle>
<CardTitle className="text-base">Företagsuppgifter</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{customer.org_number && (
@@ -286,7 +286,7 @@ export default function CustomerDetailPage({
{customer.default_payment_terms || 30} dagar
</div>
{!customer.org_number && !customer.vat_number && (
<p className="text-sm text-muted-foreground">Inga foretagsuppgifter</p>
<p className="text-sm text-muted-foreground">Inga företagsuppgifter</p>
)}
</CardContent>
</Card>
@@ -294,7 +294,7 @@ export default function CustomerDetailPage({
{/* Summary */}
<Card>
<CardHeader>
<CardTitle className="text-base">Oversikt</CardTitle>
<CardTitle className="text-base">Översikt</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center gap-2 text-sm">
@@ -346,7 +346,7 @@ export default function CustomerDetailPage({
{formatCurrency(invoice.total, invoice.currency)}
</span>
<Badge variant={invoice.payment_status === 'paid' ? 'success' : 'secondary'}>
{invoice.payment_status === 'paid' ? 'Betald' : invoice.payment_status === 'overdue' ? 'Forsenad' : 'Obestallt'}
{invoice.payment_status === 'paid' ? 'Betald' : invoice.payment_status === 'overdue' ? 'Försenad' : 'Obetald'}
</Badge>
</div>
</Link>
+450
View File
@@ -0,0 +1,450 @@
'use client'
import { useState, useEffect } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { useToast } from '@/components/ui/use-toast'
import { ArrowLeft, CheckCircle, CreditCard, FileText, Trash2 } from 'lucide-react'
import Link from 'next/link'
import { AccountNumber } from '@/components/ui/account-number'
import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog'
import { formatCurrency } from '@/lib/utils'
import type { SupplierInvoice, SupplierInvoiceItem, SupplierInvoicePayment, EntityType } from '@/types'
const statusConfig: Record<string, { label: string; color: string }> = {
registered: { label: 'Obetald', color: 'bg-blue-100 text-blue-800' },
approved: { label: 'Obetald', color: 'bg-yellow-100 text-yellow-800' },
paid: { label: 'Betald', color: 'bg-green-100 text-green-800' },
partially_paid: { label: 'Delbetald', color: 'bg-orange-100 text-orange-800' },
overdue: { label: 'Förfallen', color: 'bg-red-100 text-red-800' },
disputed: { label: 'Tvist', color: 'bg-purple-100 text-purple-800' },
credited: { label: 'Krediterad', color: 'bg-gray-100 text-gray-800' },
}
function formatAmount(amount: number): string {
return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
export default function ExpenseDetailPage() {
const params = useParams()
const router = useRouter()
const { toast } = useToast()
const [invoice, setInvoice] = useState<SupplierInvoice | null>(null)
const [entityType, setEntityType] = useState<EntityType>('enskild_firma')
const [isLoading, setIsLoading] = useState(true)
const [isPayDialogOpen, setIsPayDialogOpen] = useState(false)
const [payAmount, setPayAmount] = useState('')
const [isProcessing, setIsProcessing] = useState(false)
const { dialogProps: confirmDialogProps, confirm: confirmAction } = useDestructiveConfirm()
useEffect(() => {
fetchInvoice()
fetchEntityType()
}, [params.id])
async function fetchInvoice() {
setIsLoading(true)
const res = await fetch(`/api/supplier-invoices/${params.id}`)
const { data, error } = await res.json()
if (error) {
toast({ title: 'Fel', description: error, variant: 'destructive' })
} else {
setInvoice(data)
setPayAmount(String(data.remaining_amount))
}
setIsLoading(false)
}
async function fetchEntityType() {
try {
const res = await fetch('/api/settings')
const { data } = await res.json()
if (data?.entity_type) {
setEntityType(data.entity_type)
}
} catch {
// Default to enskild_firma
}
}
async function handleApprove() {
setIsProcessing(true)
const res = await fetch(`/api/supplier-invoices/${params.id}/approve`, { method: 'POST' })
const result = await res.json()
if (!res.ok) {
toast({ title: 'Fel', description: result.error, variant: 'destructive' })
} else {
toast({ title: 'Godkänd', description: 'Utgiften har godkänts' })
fetchInvoice()
}
setIsProcessing(false)
}
async function handleMarkPaid() {
setIsProcessing(true)
const res = await fetch(`/api/supplier-invoices/${params.id}/mark-paid`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ amount: parseFloat(payAmount) }),
})
const result = await res.json()
if (!res.ok) {
toast({ title: 'Fel', description: result.error, variant: 'destructive' })
} else {
toast({
title: result.status === 'paid' ? 'Betald' : 'Delbetalning registrerad',
description: `${formatAmount(parseFloat(payAmount))} kr registrerat`,
})
setIsPayDialogOpen(false)
fetchInvoice()
}
setIsProcessing(false)
}
async function handleCredit() {
const ok = await confirmAction({
title: 'Registrera kreditfaktura',
description: 'En kreditfaktura skapas som reverserar den ursprungliga fakturan. Denna åtgärd kan inte ångras.',
confirmLabel: 'Registrera kreditfaktura',
variant: 'warning',
})
if (!ok) return
setIsProcessing(true)
const res = await fetch(`/api/supplier-invoices/${params.id}/credit`, { method: 'POST' })
const result = await res.json()
if (!res.ok) {
toast({ title: 'Fel', description: result.error, variant: 'destructive' })
} else {
toast({ title: 'Kreditfaktura registrerad' })
fetchInvoice()
}
setIsProcessing(false)
}
async function handleDelete() {
const ok = await confirmAction({
title: 'Ta bort utgift',
description: 'Utgiften och tillhörande data tas bort permanent. Denna åtgärd kan inte ångras.',
confirmLabel: 'Ta bort',
variant: 'destructive',
})
if (!ok) return
const res = await fetch(`/api/supplier-invoices/${params.id}`, { method: 'DELETE' })
const result = await res.json()
if (!res.ok) {
toast({ title: 'Fel', description: result.error, variant: 'destructive' })
} else {
toast({ title: 'Borttagen' })
router.push('/expenses')
}
}
if (isLoading) {
return (
<div className="space-y-6 max-w-4xl">
<div className="h-8 bg-muted rounded w-48 animate-pulse" />
<Card className="animate-pulse"><CardContent className="h-48" /></Card>
</div>
)
}
if (!invoice) {
return (
<div className="text-center py-12">
<p className="text-muted-foreground">Utgiften hittades inte</p>
<Button variant="outline" className="mt-4" onClick={() => router.push('/expenses')}>
Tillbaka
</Button>
</div>
)
}
const items = (invoice.items || []) as SupplierInvoiceItem[]
const payments = (invoice.payments || []) as SupplierInvoicePayment[]
const status = statusConfig[invoice.status] || { label: invoice.status, color: '' }
return (
<div className="space-y-6 max-w-4xl">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => router.push('/expenses')}>
<ArrowLeft className="h-5 w-5" />
</Button>
<div>
<div className="flex items-center gap-3">
<h1 className="text-3xl font-bold tracking-tight">
Utgift #{invoice.arrival_number}
</h1>
<Badge className={status.color}>
{status.label}
</Badge>
</div>
<p className="text-muted-foreground">
{invoice.supplier?.name} · Faktura {invoice.supplier_invoice_number}
</p>
</div>
</div>
{/* Context-aware actions */}
<div className="flex gap-2">
{invoice.status === 'registered' && (
<>
<Button onClick={handleApprove} disabled={isProcessing}>
<CheckCircle className="mr-2 h-4 w-4" />
Godkänn
</Button>
<Button variant="destructive" size="icon" onClick={handleDelete} disabled={isProcessing}>
<Trash2 className="h-4 w-4" />
</Button>
</>
)}
{['approved', 'overdue'].includes(invoice.status) && (
<>
<Button onClick={() => setIsPayDialogOpen(true)} disabled={isProcessing}>
<CreditCard className="mr-2 h-4 w-4" />
Markera betald
</Button>
<Button variant="outline" onClick={handleCredit} disabled={isProcessing}>
<FileText className="mr-2 h-4 w-4" />
Kreditfaktura
</Button>
</>
)}
{invoice.status === 'partially_paid' && (
<Button onClick={() => setIsPayDialogOpen(true)} disabled={isProcessing}>
<CreditCard className="mr-2 h-4 w-4" />
Registrera betalning
</Button>
)}
</div>
</div>
{/* Card 1: Fakturadetaljer */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Fakturadetaljer</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* Info grid */}
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 text-sm">
<div>
<span className="text-muted-foreground">Leverantör</span>
<p className="font-medium">
{invoice.supplier ? (
<Link href={`/suppliers/${invoice.supplier.id}`} className="text-primary hover:underline">
{invoice.supplier.name}
</Link>
) : '-'}
</p>
{invoice.supplier?.org_number && (
<p className="text-xs text-muted-foreground">Org.nr: {invoice.supplier.org_number}</p>
)}
</div>
<div>
<span className="text-muted-foreground">Fakturanummer</span>
<p className="font-medium">{invoice.supplier_invoice_number}</p>
</div>
<div>
<span className="text-muted-foreground">Ankomstnummer</span>
<p className="font-medium font-mono">{invoice.arrival_number}</p>
</div>
<div>
<span className="text-muted-foreground">Fakturadatum</span>
<p className="font-medium">{invoice.invoice_date}</p>
</div>
<div>
<span className="text-muted-foreground">Förfallodatum</span>
<p className="font-medium">{invoice.due_date}</p>
</div>
{invoice.delivery_date && (
<div>
<span className="text-muted-foreground">Leveransdatum</span>
<p className="font-medium">{invoice.delivery_date}</p>
</div>
)}
{invoice.payment_reference && (
<div>
<span className="text-muted-foreground">OCR/referens</span>
<p className="font-medium font-mono">{invoice.payment_reference}</p>
</div>
)}
</div>
{invoice.reverse_charge && (
<Badge className="bg-purple-100 text-purple-800">Omvänd skattskyldighet</Badge>
)}
{/* Line items */}
{items.length > 0 && (
<div className="border-t pt-4">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-2">Beskrivning</th>
<th className="pb-2 w-20">Konto</th>
<th className="pb-2 w-16 text-right">Moms%</th>
<th className="pb-2 w-28 text-right">Belopp</th>
<th className="pb-2 w-24 text-right">Moms</th>
</tr>
</thead>
<tbody>
{items.map((item) => (
<tr key={item.id} className="border-b last:border-0">
<td className="py-2">{item.description}</td>
<td className="py-2"><AccountNumber number={item.account_number} /></td>
<td className="py-2 text-right">{Math.round(item.vat_rate * 100)}%</td>
<td className="py-2 text-right font-mono">{formatAmount(item.line_total)}</td>
<td className="py-2 text-right font-mono">{formatAmount(item.vat_amount)}</td>
</tr>
))}
</tbody>
</table>
{/* Amounts summary */}
<div className="mt-4 pt-4 border-t space-y-1 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Netto (exkl. moms)</span>
<span className="font-mono">{formatAmount(invoice.subtotal)} {invoice.currency}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Moms</span>
<span className="font-mono">{formatAmount(invoice.vat_amount)} {invoice.currency}</span>
</div>
<div className="flex justify-between font-bold text-base pt-2 border-t">
<span>Totalt</span>
<span className="font-mono">{formatAmount(invoice.total)} {invoice.currency}</span>
</div>
<div className="flex justify-between pt-2">
<span className="text-muted-foreground">Betalt</span>
<span className="font-mono text-green-600">{formatAmount(invoice.paid_amount)} {invoice.currency}</span>
</div>
<div className="flex justify-between font-semibold">
<span>Kvar att betala</span>
<span className="font-mono">{formatAmount(invoice.remaining_amount)} {invoice.currency}</span>
</div>
</div>
</div>
)}
{/* Notes inline */}
{invoice.notes && (
<div className="border-t pt-4">
<p className="text-sm text-muted-foreground">{invoice.notes}</p>
</div>
)}
</CardContent>
</Card>
{/* Card 2: Betalningar & bokföring (only if data exists) */}
{(payments.length > 0 || invoice.registration_journal_entry_id) && (
<Card>
<CardHeader>
<CardTitle className="text-lg">Betalningar & bokföring</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* Payment history */}
{payments.length > 0 && (
<div>
<p className="text-sm font-medium mb-2">Betalningshistorik</p>
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-2">Datum</th>
<th className="pb-2 text-right">Belopp</th>
<th className="pb-2">Verifikation</th>
<th className="pb-2">Anteckning</th>
</tr>
</thead>
<tbody>
{payments.map((p) => (
<tr key={p.id} className="border-b last:border-0">
<td className="py-2">{p.payment_date}</td>
<td className="py-2 text-right font-mono">{formatAmount(p.amount)} {p.currency}</td>
<td className="py-2">
{p.journal_entry_id ? (
<Link href={`/bookkeeping?entry=${p.journal_entry_id}`} className="text-primary hover:underline font-mono text-xs">
{p.journal_entry_id.substring(0, 8)}...
</Link>
) : '-'}
</td>
<td className="py-2 text-muted-foreground">{p.notes || '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Journal entry links */}
<div className="text-sm space-y-2">
<p className="font-medium">Verifikationer</p>
{invoice.registration_journal_entry_id ? (
<div className="flex justify-between">
<span className="text-muted-foreground">Registreringsverifikation</span>
<Link
href={`/bookkeeping?entry=${invoice.registration_journal_entry_id}`}
className="text-primary hover:underline font-mono"
>
{invoice.registration_journal_entry_id.substring(0, 8)}...
</Link>
</div>
) : (
<p className="text-muted-foreground">Ingen registreringsverifikation</p>
)}
{invoice.payment_journal_entry_id && (
<div className="flex justify-between">
<span className="text-muted-foreground">Betalningsverifikation</span>
<Link
href={`/bookkeeping?entry=${invoice.payment_journal_entry_id}`}
className="text-primary hover:underline font-mono"
>
{invoice.payment_journal_entry_id.substring(0, 8)}...
</Link>
</div>
)}
</div>
</CardContent>
</Card>
)}
<DestructiveConfirmDialog {...confirmDialogProps} />
{/* Pay Dialog */}
<Dialog open={isPayDialogOpen} onOpenChange={setIsPayDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Markera som betald</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Belopp att betala</Label>
<Input
type="number"
step="0.01"
value={payAmount}
onChange={(e) => setPayAmount(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Kvar att betala: {formatAmount(invoice.remaining_amount)} {invoice.currency}
</p>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setIsPayDialogOpen(false)}>
Avbryt
</Button>
<Button onClick={handleMarkPaid} disabled={isProcessing}>
{isProcessing ? 'Bearbetar...' : 'Registrera betalning'}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
)
}
+746
View File
@@ -0,0 +1,746 @@
'use client'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { useForm, Controller, useFieldArray } from 'react-hook-form'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Checkbox } from '@/components/ui/checkbox'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { useToast } from '@/components/ui/use-toast'
import { ConfirmationDialog } from '@/components/ui/confirmation-dialog'
import { SupplierInvoiceReviewContent } from '@/components/suppliers/SupplierInvoiceReviewContent'
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
import { getAccountDescription } from '@/lib/bookkeeping/account-descriptions'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { useUnsavedChanges } from '@/lib/hooks/use-unsaved-changes'
import { ArrowLeft, Plus, Trash2, ChevronDown, Loader2 } from 'lucide-react'
import type { Supplier, BASAccount, VatTreatment, EntityType } from '@/types'
interface LineItem {
description: string
amount: number
account_number: string
vat_rate: number
}
interface FormData {
supplier_id: string
supplier_invoice_number: string
invoice_date: string
due_date: string
delivery_date: string
currency: string
exchange_rate: string
reverse_charge: boolean
payment_reference: string
notes: string
items: LineItem[]
}
interface NewSupplierForm {
name: string
supplier_type: string
org_number: string
bankgiro: string
plusgiro: string
default_expense_account: string
}
function formatAmount(amount: number): string {
return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
function inferVatTreatment(items: LineItem[], reverseCharge: boolean): VatTreatment {
if (reverseCharge) return 'reverse_charge'
const rates = new Set(items.map((i) => i.vat_rate))
if (rates.size === 1) {
const rate = rates.values().next().value!
if (rate === 0.25) return 'standard_25'
if (rate === 0.12) return 'reduced_12'
if (rate === 0.06) return 'reduced_6'
if (rate === 0) return 'exempt'
}
return 'standard_25'
}
export default function NewExpensePage() {
const router = useRouter()
const { toast } = useToast()
const [suppliers, setSuppliers] = useState<Supplier[]>([])
const [accounts, setAccounts] = useState<BASAccount[]>([])
const [entityType, setEntityType] = useState<EntityType>('enskild_firma')
const [isSubmitting, setIsSubmitting] = useState(false)
const [showReview, setShowReview] = useState(false)
const [pendingData, setPendingData] = useState<FormData | null>(null)
const [showNewSupplier, setShowNewSupplier] = useState(false)
const [isCreatingSupplier, setIsCreatingSupplier] = useState(false)
const [advancedOpen, setAdvancedOpen] = useState(false)
const [newSupplier, setNewSupplier] = useState<NewSupplierForm>({
name: '',
supplier_type: 'swedish_business',
org_number: '',
bankgiro: '',
plusgiro: '',
default_expense_account: '',
})
const { register, control, handleSubmit, watch, setValue, formState: { isDirty } } = useForm<FormData>({
defaultValues: {
supplier_id: '',
supplier_invoice_number: '',
invoice_date: new Date().toISOString().split('T')[0],
due_date: '',
delivery_date: '',
currency: 'SEK',
exchange_rate: '',
reverse_charge: false,
payment_reference: '',
notes: '',
items: [{ description: '', amount: 0, account_number: '5010', vat_rate: 0.25 }],
},
})
useUnsavedChanges(isDirty)
const { fields, append, remove } = useFieldArray({ control, name: 'items' })
const watchedItems = watch('items')
const watchedSupplierId = watch('supplier_id')
const watchedCurrency = watch('currency')
const isEF = entityType === 'enskild_firma'
useEffect(() => {
fetchSuppliers()
fetchAccounts()
fetchEntityType()
}, [])
// Auto-fill due date and defaults when supplier is selected
useEffect(() => {
if (watchedSupplierId) {
const supplier = suppliers.find((s) => s.id === watchedSupplierId)
if (supplier) {
const invoiceDate = watch('invoice_date')
if (invoiceDate) {
const due = new Date(invoiceDate)
due.setDate(due.getDate() + supplier.default_payment_terms)
setValue('due_date', due.toISOString().split('T')[0])
}
if (supplier.default_expense_account && fields.length > 0) {
setValue('items.0.account_number', supplier.default_expense_account)
}
if (supplier.default_currency) {
setValue('currency', supplier.default_currency)
}
if (supplier.supplier_type === 'eu_business') {
setValue('reverse_charge', true)
}
}
}
}, [watchedSupplierId, suppliers])
async function fetchSuppliers() {
const res = await fetch('/api/suppliers')
const { data } = await res.json()
setSuppliers(data || [])
}
async function fetchAccounts() {
const res = await fetch('/api/bookkeeping/accounts')
const { data } = await res.json()
setAccounts(data || [])
}
async function fetchEntityType() {
try {
const res = await fetch('/api/settings')
const { data } = await res.json()
if (data?.entity_type) {
setEntityType(data.entity_type)
}
} catch {
// Default to enskild_firma
}
}
function handleAccountChange(index: number, accountNumber: string) {
setValue(`items.${index}.account_number`, accountNumber)
const currentDesc = watch(`items.${index}.description`)
if (!currentDesc && accountNumber.length === 4) {
const desc = getAccountDescription(accountNumber)
if (desc) {
setValue(`items.${index}.description`, desc.name)
}
}
}
// Calculate totals
const itemTotals = (watchedItems || []).map((item) => {
const lineTotal = Math.round((item.amount || 0) * 100) / 100
const vatAmount = Math.round(lineTotal * (item.vat_rate || 0) * 100) / 100
return { lineTotal, vatAmount }
})
const subtotal = itemTotals.reduce((sum, t) => sum + t.lineTotal, 0)
const totalVat = itemTotals.reduce((sum, t) => sum + t.vatAmount, 0)
const total = Math.round((subtotal + totalVat) * 100) / 100
async function handleCreateSupplier() {
if (!newSupplier.name.trim()) {
toast({ title: 'Fel', description: 'Ange leverantörsnamn', variant: 'destructive' })
return
}
setIsCreatingSupplier(true)
const payload: Record<string, unknown> = {
name: newSupplier.name,
supplier_type: newSupplier.supplier_type,
}
if (newSupplier.org_number) payload.org_number = newSupplier.org_number
if (newSupplier.bankgiro) payload.bankgiro = newSupplier.bankgiro
if (newSupplier.plusgiro) payload.plusgiro = newSupplier.plusgiro
if (newSupplier.default_expense_account) payload.default_expense_account = newSupplier.default_expense_account
const res = await fetch('/api/suppliers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
const result = await res.json()
if (!res.ok) {
toast({ title: 'Kunde inte skapa leverantör', description: result.error, variant: 'destructive' })
} else {
const created = result.data as Supplier
setSuppliers((prev) => [...prev, created].sort((a, b) => a.name.localeCompare(b.name)))
setValue('supplier_id', created.id)
setShowNewSupplier(false)
setNewSupplier({ name: '', supplier_type: 'swedish_business', org_number: '', bankgiro: '', plusgiro: '', default_expense_account: '' })
toast({ title: 'Leverantör skapad', description: created.name })
}
setIsCreatingSupplier(false)
}
function onSubmit(data: FormData) {
if (!data.supplier_id) {
toast({ title: 'Fel', description: 'Välj en leverantör', variant: 'destructive' })
return
}
if (!data.supplier_invoice_number) {
toast({ title: 'Fel', description: 'Ange fakturanummer', variant: 'destructive' })
return
}
if (isEF) {
// EF: submit directly (auto-approve after create)
setPendingData(data)
handleDirectSubmit(data)
} else {
// AB: show review dialog first
setPendingData(data)
setShowReview(true)
}
}
function buildPayload(data: FormData) {
const vatTreatment = inferVatTreatment(data.items, data.reverse_charge)
return {
supplier_id: data.supplier_id,
supplier_invoice_number: data.supplier_invoice_number,
invoice_date: data.invoice_date,
due_date: data.due_date,
delivery_date: data.delivery_date || undefined,
currency: data.currency,
exchange_rate: data.exchange_rate ? parseFloat(data.exchange_rate) : undefined,
vat_treatment: vatTreatment,
reverse_charge: data.reverse_charge,
payment_reference: data.payment_reference || undefined,
notes: data.notes || undefined,
items: data.items.map((item) => ({
description: item.description,
amount: item.amount,
account_number: item.account_number,
vat_rate: item.vat_rate,
})),
}
}
// EF flow: create + auto-approve
async function handleDirectSubmit(data: FormData) {
setIsSubmitting(true)
const res = await fetch('/api/supplier-invoices', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(buildPayload(data)),
})
const result = await res.json()
if (!res.ok) {
toast({ title: 'Kunde inte registrera utgift', description: getErrorMessage(result, { context: 'supplier_invoice', statusCode: res.status }), variant: 'destructive' })
setIsSubmitting(false)
return
}
// Auto-approve for EF
const approveRes = await fetch(`/api/supplier-invoices/${result.data.id}/approve`, { method: 'POST' })
if (!approveRes.ok) {
toast({
title: 'Varning',
description: 'Utgiften skapades men kunde inte godkännas automatiskt',
variant: 'destructive',
})
router.push(`/expenses/${result.data.id}`)
} else {
toast({ title: 'Utgift registrerad', description: `Ankomstnummer: ${result.data.arrival_number}` })
router.push('/expenses')
}
setIsSubmitting(false)
}
// AB flow: create after review dialog confirmation
async function handleConfirm() {
if (!pendingData) return
setIsSubmitting(true)
const res = await fetch('/api/supplier-invoices', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(buildPayload(pendingData)),
})
const result = await res.json()
if (!res.ok) {
toast({ title: 'Kunde inte registrera utgift', description: getErrorMessage(result, { context: 'supplier_invoice', statusCode: res.status }), variant: 'destructive' })
} else {
toast({ title: 'Utgift registrerad', description: `Ankomstnummer: ${result.data.arrival_number}` })
setShowReview(false)
router.push(`/expenses/${result.data.id}`)
}
setIsSubmitting(false)
}
return (
<div className="space-y-6 max-w-4xl">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => router.push('/expenses')}>
<ArrowLeft className="h-5 w-5" />
</Button>
<div>
<h1 className="text-3xl font-bold tracking-tight">Ny utgift</h1>
<p className="text-muted-foreground">
Registrera en inkommande faktura
</p>
</div>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
{/* Section 1: Faktura */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Faktura</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Leverantör *</Label>
<Controller
name="supplier_id"
control={control}
render={({ field }) => (
<Select
value={field.value}
onValueChange={(v) => {
if (v === '__new__') {
setShowNewSupplier(true)
} else {
field.onChange(v)
}
}}
>
<SelectTrigger>
<SelectValue placeholder="Välj leverantör" />
</SelectTrigger>
<SelectContent>
{suppliers.map((s) => (
<SelectItem key={s.id} value={s.id}>{s.name}</SelectItem>
))}
<SelectItem value="__new__" className="text-primary font-medium">
+ Lägg till ny leverantör...
</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
<div className="space-y-2">
<Label>Leverantörens fakturanummer *</Label>
<Input
placeholder="Fakturanr från leverantören"
{...register('supplier_invoice_number')}
/>
</div>
</div>
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2">
<Label>Fakturadatum *</Label>
<Input type="date" {...register('invoice_date')} />
</div>
<div className="space-y-2">
<Label>Förfallodatum *</Label>
<Input type="date" {...register('due_date')} />
</div>
<div className="space-y-2">
<Label>OCR / Betalningsreferens</Label>
<Input
placeholder="OCR-nummer"
{...register('payment_reference')}
/>
</div>
</div>
</CardContent>
</Card>
{/* Section 2: Kontering */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-lg">Kontering</CardTitle>
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
append({ description: '', amount: 0, account_number: '', vat_rate: 0.25 })
}
>
<Plus className="mr-2 h-4 w-4" />
Lägg till rad
</Button>
</CardHeader>
<CardContent>
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-2 w-28">Konto</th>
<th className="pb-2">Beskrivning</th>
<th className="pb-2 w-32">Belopp (exkl.)</th>
<th className="pb-2 w-24">Momssats</th>
<th className="pb-2 w-24 text-right">Moms</th>
<th className="pb-2 w-8"></th>
</tr>
</thead>
<tbody>
{fields.map((field, index) => (
<tr key={field.id} className="border-b last:border-0 align-top">
<td className="py-2 pr-2">
<Controller
name={`items.${index}.account_number`}
control={control}
render={({ field: f }) => (
<AccountCombobox
value={f.value}
accounts={accounts}
onChange={(val) => handleAccountChange(index, val)}
/>
)}
/>
</td>
<td className="py-2 pr-2">
<Input
placeholder="Beskrivning"
{...register(`items.${index}.description`)}
/>
</td>
<td className="py-2 pr-2">
<Input
type="number"
step="0.01"
placeholder="0,00"
{...register(`items.${index}.amount`, { valueAsNumber: true })}
/>
</td>
<td className="py-2 pr-2">
<Controller
name={`items.${index}.vat_rate`}
control={control}
render={({ field: f }) => (
<Select
value={String(f.value)}
onValueChange={(v) => f.onChange(parseFloat(v))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="0.25">25%</SelectItem>
<SelectItem value="0.12">12%</SelectItem>
<SelectItem value="0.06">6%</SelectItem>
<SelectItem value="0">0%</SelectItem>
</SelectContent>
</Select>
)}
/>
</td>
<td className="py-2 pr-2 text-right font-mono pt-4">
{formatAmount(itemTotals[index]?.vatAmount || 0)}
</td>
<td className="py-2 pt-3">
{fields.length > 1 && (
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => remove(index)}
>
<Trash2 className="h-4 w-4 text-muted-foreground" />
</Button>
)}
</td>
</tr>
))}
</tbody>
</table>
{/* Totals */}
<div className="mt-4 pt-4 border-t space-y-2 text-right">
<div className="flex justify-end gap-8">
<span className="text-muted-foreground">Netto (exkl. moms)</span>
<span className="font-mono w-32">{formatAmount(subtotal)} kr</span>
</div>
<div className="flex justify-end gap-8">
<span className="text-muted-foreground">Moms</span>
<span className="font-mono w-32">{formatAmount(totalVat)} kr</span>
</div>
<div className="flex justify-end gap-8 font-bold text-lg">
<span>Totalt</span>
<span className="font-mono w-32">{formatAmount(total)} kr</span>
</div>
</div>
</CardContent>
</Card>
{/* Section 3: Övrigt (collapsible) */}
<Card>
<CardHeader
className="cursor-pointer hover:bg-muted/30 transition-colors"
onClick={() => setAdvancedOpen(!advancedOpen)}
>
<div className="flex items-center justify-between">
<CardTitle className="text-lg">Övrigt</CardTitle>
<ChevronDown className={`h-4 w-4 text-muted-foreground transition-transform ${advancedOpen ? 'rotate-180' : ''}`} />
</div>
</CardHeader>
{advancedOpen && (
<CardContent className="space-y-4 pt-0">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Valuta</Label>
<Controller
name="currency"
control={control}
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="SEK">SEK</SelectItem>
<SelectItem value="EUR">EUR</SelectItem>
<SelectItem value="USD">USD</SelectItem>
<SelectItem value="GBP">GBP</SelectItem>
<SelectItem value="NOK">NOK</SelectItem>
<SelectItem value="DKK">DKK</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
{watchedCurrency !== 'SEK' && (
<div className="space-y-2">
<Label>Växelkurs</Label>
<Input
type="number"
step="0.0001"
placeholder="1.0000"
{...register('exchange_rate')}
/>
</div>
)}
</div>
<div className="space-y-2">
<Label>Leveransdatum (ML krav)</Label>
<Input type="date" {...register('delivery_date')} />
</div>
<div className="flex items-center gap-2">
<Controller
name="reverse_charge"
control={control}
render={({ field }) => (
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
)}
/>
<Label>Omvänd skattskyldighet (reverse charge)</Label>
</div>
<div className="space-y-2">
<Label>Anteckningar</Label>
<Textarea
placeholder="Interna anteckningar om denna utgift..."
{...register('notes')}
/>
</div>
</CardContent>
)}
</Card>
{/* Submit */}
<div className="flex justify-end gap-4">
<Button type="button" variant="outline" onClick={() => router.push('/expenses')}>
Avbryt
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Registrerar...
</>
) : isEF ? (
'Registrera utgift'
) : (
'Granska & registrera'
)}
</Button>
</div>
</form>
{/* Review dialog (AB only) */}
{pendingData && !isEF && (() => {
const selectedSupplier = suppliers.find((s) => s.id === pendingData.supplier_id)
if (!selectedSupplier) return null
return (
<ConfirmationDialog
open={showReview}
onOpenChange={setShowReview}
onConfirm={handleConfirm}
isSubmitting={isSubmitting}
title="Granska utgift"
warningText="Utgiften registreras och en verifikation bokförs. Verifikationen kan inte redigeras direkt, men kan korrigeras via en ändringsverifikation."
confirmLabel="Bekräfta & registrera"
>
<SupplierInvoiceReviewContent
supplier={selectedSupplier}
invoiceNumber={pendingData.supplier_invoice_number}
invoiceDate={pendingData.invoice_date}
dueDate={pendingData.due_date}
deliveryDate={pendingData.delivery_date || undefined}
currency={pendingData.currency}
exchangeRate={pendingData.exchange_rate || undefined}
reverseCharge={pendingData.reverse_charge}
paymentReference={pendingData.payment_reference || undefined}
items={pendingData.items}
subtotal={subtotal}
totalVat={totalVat}
total={total}
/>
</ConfirmationDialog>
)
})()}
{/* New Supplier Dialog */}
<Dialog open={showNewSupplier} onOpenChange={setShowNewSupplier}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Ny leverantör</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Namn *</Label>
<Input
placeholder="Leverantörens namn"
value={newSupplier.name}
onChange={(e) => setNewSupplier((p) => ({ ...p, name: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label>Typ</Label>
<Select
value={newSupplier.supplier_type}
onValueChange={(v) => setNewSupplier((p) => ({ ...p, supplier_type: v }))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="swedish_business">Svenskt företag</SelectItem>
<SelectItem value="eu_business">EU-företag</SelectItem>
<SelectItem value="non_eu_business">Utomeuropeiskt</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Organisationsnummer</Label>
<Input
placeholder="XXXXXX-XXXX"
value={newSupplier.org_number}
onChange={(e) => setNewSupplier((p) => ({ ...p, org_number: e.target.value }))}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Bankgiro</Label>
<Input
placeholder="XXX-XXXX"
value={newSupplier.bankgiro}
onChange={(e) => setNewSupplier((p) => ({ ...p, bankgiro: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label>Plusgiro</Label>
<Input
placeholder="XXXXXX-X"
value={newSupplier.plusgiro}
onChange={(e) => setNewSupplier((p) => ({ ...p, plusgiro: e.target.value }))}
/>
</div>
</div>
<div className="space-y-2">
<Label>Standardkonto (kostnad)</Label>
<Input
placeholder="t.ex. 5010"
value={newSupplier.default_expense_account}
onChange={(e) => setNewSupplier((p) => ({ ...p, default_expense_account: e.target.value }))}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowNewSupplier(false)}>
Avbryt
</Button>
<Button onClick={handleCreateSupplier} disabled={isCreatingSupplier}>
{isCreatingSupplier ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Skapar...
</>
) : (
'Skapa leverantör'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
+288
View File
@@ -0,0 +1,288 @@
'use client'
import { useState, useEffect } from 'react'
import Link from 'next/link'
import { createClient } from '@/lib/supabase/client'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { PageHeader } from '@/components/ui/page-header'
import { EmptyState } from '@/components/ui/empty-state'
import { useToast } from '@/components/ui/use-toast'
import { formatCurrency } from '@/lib/utils'
import { Plus, Search, Wallet, Clock, AlertCircle } from 'lucide-react'
import type { SupplierInvoice } from '@/types'
type ExpenseInvoice = SupplierInvoice & { supplier?: { id: string; name: string } }
const UNPAID_STATUSES = ['registered', 'approved', 'overdue', 'partially_paid']
const PAID_STATUSES = ['paid', 'credited']
function getSimplifiedStatus(status: string): { label: string; variant: 'default' | 'secondary' | 'destructive'; borderColor: string } {
switch (status) {
case 'registered':
case 'approved':
return { label: 'Obetald', variant: 'default', borderColor: 'border-l-warning' }
case 'overdue':
return { label: 'Förfallen', variant: 'destructive', borderColor: 'border-l-destructive' }
case 'partially_paid':
return { label: 'Delbetald', variant: 'default', borderColor: 'border-l-orange-400' }
case 'paid':
return { label: 'Betald', variant: 'secondary', borderColor: 'border-l-success' }
case 'credited':
return { label: 'Krediterad', variant: 'secondary', borderColor: 'border-l-muted-foreground/30' }
default:
return { label: status, variant: 'secondary', borderColor: 'border-l-muted-foreground/30' }
}
}
function getRelativeTimeLabel(dueDateStr: string, status: string): { text: string; color: string } | null {
if (PAID_STATUSES.includes(status)) return null
const today = new Date()
today.setHours(0, 0, 0, 0)
const dueDate = new Date(dueDateStr)
dueDate.setHours(0, 0, 0, 0)
const diffDays = Math.round((dueDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24))
if (diffDays < 0) {
return { text: `${Math.abs(diffDays)} dagar försenad`, color: 'text-destructive' }
} else if (diffDays === 0) {
return { text: 'Förfaller idag', color: 'text-warning-foreground' }
} else if (diffDays <= 3) {
return { text: `${diffDays} dagar kvar`, color: 'text-warning-foreground' }
} else if (diffDays <= 7) {
return { text: `${diffDays} dagar kvar`, color: 'text-muted-foreground' }
}
return null
}
export default function ExpensesPage() {
const [invoices, setInvoices] = useState<ExpenseInvoice[]>([])
const [isLoading, setIsLoading] = useState(true)
const [searchTerm, setSearchTerm] = useState('')
const [activeTab, setActiveTab] = useState('unpaid')
const { toast } = useToast()
const supabase = createClient()
useEffect(() => {
fetchExpenses()
}, [])
async function fetchExpenses() {
setIsLoading(true)
const { data, error } = await supabase
.from('supplier_invoices')
.select('*, supplier:suppliers(id, name)')
.order('due_date', { ascending: true })
if (error) {
toast({ title: 'Fel', description: 'Kunde inte hämta utgifter', variant: 'destructive' })
} else {
setInvoices(data || [])
}
setIsLoading(false)
}
const filteredInvoices = invoices.filter((inv) => {
const matchesSearch =
(inv.supplier?.name || '').toLowerCase().includes(searchTerm.toLowerCase()) ||
inv.supplier_invoice_number.toLowerCase().includes(searchTerm.toLowerCase())
const matchesTab =
activeTab === 'unpaid'
? UNPAID_STATUSES.includes(inv.status)
: PAID_STATUSES.includes(inv.status)
return matchesSearch && matchesTab
})
const unpaidInvoices = invoices.filter((i) => UNPAID_STATUSES.includes(i.status))
const overdueInvoices = invoices.filter((i) => i.status === 'overdue')
const stats = {
unpaidAmount: unpaidInvoices.reduce((sum, i) => sum + i.remaining_amount, 0),
unpaidCount: unpaidInvoices.length,
overdueAmount: overdueInvoices.reduce((sum, i) => sum + i.remaining_amount, 0),
overdueCount: overdueInvoices.length,
}
return (
<div className="space-y-6">
<PageHeader
title="Utgifter"
description="Registrera och hantera dina utgifter"
action={
<Link href="/expenses/new">
<Button>
<Plus className="mr-2 h-4 w-4" />
Ny utgift
</Button>
</Link>
}
/>
{/* Stats */}
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-4">
<div className="h-12 w-12 rounded-lg bg-muted flex items-center justify-center">
<Clock className="h-6 w-6 text-muted-foreground" />
</div>
<div>
<p className="text-sm text-muted-foreground">Att betala</p>
<p className="text-2xl font-bold tabular-nums">{formatCurrency(stats.unpaidAmount)}</p>
<p className="text-xs text-muted-foreground">{stats.unpaidCount} utgifter</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-4">
<div className="h-12 w-12 rounded-lg bg-muted flex items-center justify-center">
<AlertCircle className={`h-6 w-6 ${stats.overdueCount > 0 ? 'text-destructive' : 'text-muted-foreground'}`} />
</div>
<div>
{stats.overdueCount > 0 ? (
<>
<p className="text-sm text-muted-foreground">Förfallet</p>
<p className="text-2xl font-bold tabular-nums text-destructive">{formatCurrency(stats.overdueAmount)}</p>
<p className="text-xs text-muted-foreground">{stats.overdueCount} utgifter</p>
</>
) : (
<>
<p className="text-sm text-muted-foreground">Totalt antal</p>
<p className="text-2xl font-bold tabular-nums">{invoices.length}</p>
</>
)}
</div>
</div>
</CardContent>
</Card>
</div>
{/* Search and tabs */}
<div className="flex flex-col sm:flex-row gap-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Sök på leverantör eller fakturanummer..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList>
<TabsTrigger value="unpaid">Att betala</TabsTrigger>
<TabsTrigger value="paid">Betalda</TabsTrigger>
</TabsList>
</Tabs>
</div>
{/* Expense list */}
{isLoading ? (
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<Card key={i} className="animate-pulse">
<CardContent className="py-4">
<div className="flex items-center justify-between">
<div className="space-y-2">
<div className="h-5 bg-muted rounded w-32" />
<div className="h-4 bg-muted rounded w-48" />
</div>
<div className="h-8 bg-muted rounded w-24" />
</div>
</CardContent>
</Card>
))}
</div>
) : filteredInvoices.length === 0 ? (
<Card>
<CardContent>
{searchTerm ? (
<div className="flex flex-col items-center justify-center py-12">
<Wallet className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium">Inga träffar</h3>
<p className="text-muted-foreground text-center mt-1">
Inga utgifter matchar &quot;{searchTerm}&quot;
</p>
</div>
) : invoices.length === 0 ? (
<EmptyState
icon={Wallet}
title="Inga utgifter ännu"
description="Registrera din första utgift. Vi bokför automatiskt och håller koll på betalningar."
actionLabel="Ny utgift"
actionHref="/expenses/new"
/>
) : (
<div className="flex flex-col items-center justify-center py-12">
<Wallet className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium">Inga utgifter i denna kategori</h3>
<p className="text-muted-foreground text-center mt-1">
Prova att byta flik för att se fler utgifter
</p>
</div>
)}
</CardContent>
</Card>
) : (
<div className="space-y-2">
{filteredInvoices.map((inv) => {
const status = getSimplifiedStatus(inv.status)
const relativeTime = inv.due_date ? getRelativeTimeLabel(inv.due_date, inv.status) : null
return (
<Link key={inv.id} href={`/expenses/${inv.id}`}>
<Card className={`hover:border-primary/50 transition-colors cursor-pointer border-l-4 ${status.borderColor} ${inv.status === 'overdue' ? 'ring-1 ring-destructive/20' : ''}`}>
<CardContent className="py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="h-10 w-10 rounded-lg bg-muted flex items-center justify-center">
<Wallet className="h-5 w-5 text-muted-foreground" />
</div>
<div>
<div className="flex items-center gap-2">
<p className="font-medium">{inv.supplier?.name || 'Okänd leverantör'}</p>
<Badge variant={status.variant as 'default' | 'secondary' | 'destructive'}>
{status.label}
</Badge>
</div>
<div className="flex items-center gap-2">
<p className="text-sm text-muted-foreground">
{inv.supplier_invoice_number} · Förfaller {inv.due_date}
</p>
{relativeTime && (
<span className={`text-xs font-medium ${relativeTime.color}`}>
{relativeTime.text}
</span>
)}
</div>
</div>
</div>
<div className="text-right">
<p className="font-medium tabular-nums">
{formatCurrency(inv.total, inv.currency)}
</p>
{inv.remaining_amount !== inv.total && inv.remaining_amount > 0 && (
<p className="text-sm text-muted-foreground tabular-nums">
Kvar: {formatCurrency(inv.remaining_amount, inv.currency)}
</p>
)}
</div>
</div>
</CardContent>
</Card>
</Link>
)
})}
</div>
)}
</div>
)
}
+58 -27
View File
@@ -3,9 +3,9 @@
import { useState, useCallback } from 'react'
import { Card, CardContent } from '@/components/ui/card'
import { Progress } from '@/components/ui/progress'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Button } from '@/components/ui/button'
import { useToast } from '@/components/ui/use-toast'
import { ArrowLeftRight, FileText } from 'lucide-react'
import { ArrowLeftRight, FileText, ArrowLeft } from 'lucide-react'
// Bank file import components
import BankFileUploadStep from '@/components/import/BankFileUploadStep'
@@ -64,7 +64,6 @@ function BankFileImportWizard() {
const [detectedFormatName, setDetectedFormatName] = useState<string | null>(null)
const [fileHash, setFileHash] = useState<string>('')
const [filename, setFilename] = useState<string>('')
const [existingTxCount, setExistingTxCount] = useState(0)
const [rawFileContent, setRawFileContent] = useState<string>('')
// Column mapping for generic CSV
@@ -110,8 +109,6 @@ function BankFileImportWizard() {
setDetectedFormatName(data.data.detected_format_name)
setFileHash(data.data.file_hash)
setFilename(data.data.filename)
setExistingTxCount(data.data.existing_transaction_count)
// Store headers for generic CSV mapping
if (data.data.headers) {
setCsvHeaders(data.data.headers)
@@ -200,7 +197,6 @@ function BankFileImportWizard() {
setDetectedFormatName(null)
setFileHash('')
setFilename('')
setExistingTxCount(0)
setIngestResult(null)
setBankError(null)
setCsvHeaders([])
@@ -245,7 +241,6 @@ function BankFileImportWizard() {
{bankStep === 'preview' && parseResult && (
<BankFilePreviewStep
parseResult={parseResult}
existingTransactionCount={existingTxCount}
onContinue={() => {
if (parseResult.format === 'generic_csv') {
setBankStep('column_mapping')
@@ -541,10 +536,14 @@ function SIEImportWizard() {
}
// ============================================================
// Import Page with Tabs
// Import Page with Selection Cards
// ============================================================
type ImportMode = null | 'bank' | 'sie'
export default function ImportPage() {
const [mode, setMode] = useState<ImportMode>(null)
return (
<div className="space-y-6">
{/* Header */}
@@ -555,27 +554,59 @@ export default function ImportPage() {
</p>
</div>
{/* Tabbed layout */}
<Tabs defaultValue="bank" className="space-y-6">
<TabsList>
<TabsTrigger value="bank">
<ArrowLeftRight className="mr-2 h-4 w-4" />
Banktransaktioner
</TabsTrigger>
<TabsTrigger value="sie">
<FileText className="mr-2 h-4 w-4" />
Bokföringsdata (SIE)
</TabsTrigger>
</TabsList>
{mode === null && (
<div className="grid gap-4 md:grid-cols-2">
<Card
className="cursor-pointer hover:border-primary/50 transition-colors"
onClick={() => setMode('bank')}
>
<CardContent className="pt-6 pb-6 flex flex-col items-center text-center space-y-3">
<div className="h-12 w-12 rounded-full bg-muted flex items-center justify-center">
<ArrowLeftRight className="h-6 w-6 text-muted-foreground" />
</div>
<div>
<h3 className="text-lg font-semibold">Banktransaktioner</h3>
<p className="text-sm text-muted-foreground mt-1">
Importera kontoutdrag från din bank. Stöder CSV, OFX och de flesta svenska banker.
</p>
</div>
<p className="text-xs text-muted-foreground">
CSV, OFX, SEB, Swedbank, Handelsbanken, Nordea m.fl.
</p>
</CardContent>
</Card>
<TabsContent value="bank">
<BankFileImportWizard />
</TabsContent>
<Card
className="cursor-pointer hover:border-primary/50 transition-colors"
onClick={() => setMode('sie')}
>
<CardContent className="pt-6 pb-6 flex flex-col items-center text-center space-y-3">
<div className="h-12 w-12 rounded-full bg-muted flex items-center justify-center">
<FileText className="h-6 w-6 text-muted-foreground" />
</div>
<div>
<h3 className="text-lg font-semibold">Bokföringsdata (SIE)</h3>
<p className="text-sm text-muted-foreground mt-1">
Importera verifikationer och kontoplan från ett annat bokföringsprogram via SIE-filer.
</p>
</div>
<p className="text-xs text-muted-foreground">
SIE4-format (.se, .si)
</p>
</CardContent>
</Card>
</div>
)}
<TabsContent value="sie">
<SIEImportWizard />
</TabsContent>
</Tabs>
{mode !== null && (
<Button variant="ghost" size="sm" onClick={() => setMode(null)}>
<ArrowLeft className="mr-2 h-4 w-4" />
Tillbaka till val
</Button>
)}
{mode === 'bank' && <BankFileImportWizard />}
{mode === 'sie' && <SIEImportWizard />}
</div>
)
}
+1 -13
View File
@@ -16,7 +16,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
import { Separator } from '@/components/ui/separator'
import { useToast } from '@/components/ui/use-toast'
import { formatCurrency } from '@/lib/utils'
import { getVatRules, getAvailableVatRates, getVatSummaryFromItems } from '@/lib/invoices/vat-rules'
import { getVatRules, getAvailableVatRates } from '@/lib/invoices/vat-rules'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'
import { Loader2, Plus, Trash2, ArrowLeft, Send, Eye } from 'lucide-react'
import { ConfirmationDialog } from '@/components/ui/confirmation-dialog'
@@ -413,18 +413,6 @@ export default function NewInvoicePage() {
<p className="text-sm text-destructive mt-2">{errors.customer_id.message}</p>
)}
{selectedCustomer && vatRules && (
<div className="mt-4 p-3 bg-muted rounded-lg">
<p className="text-sm">
<strong>Momsbehandling:</strong> {getVatSummaryFromItems(watchItems).label}
</p>
{vatRules.reverseChargeText && (
<p className="text-xs text-muted-foreground mt-1">
Omvänd skattskyldighet tillämpas
</p>
)}
</div>
)}
</CardContent>
</Card>
+5 -13
View File
@@ -29,18 +29,11 @@ export default async function DashboardLayout({
const entityType = (settings.entity_type as EntityType) || 'enskild_firma'
const [{ data: enabledToggles }, { count: uncategorizedCount }] = await Promise.all([
supabase
.from('extension_toggles')
.select('sector_slug, extension_slug')
.eq('user_id', user.id)
.eq('enabled', true),
supabase
.from('transactions')
.select('*', { count: 'exact', head: true })
.eq('user_id', user.id)
.is('is_business', null),
])
const { count: uncategorizedCount } = await supabase
.from('transactions')
.select('*', { count: 'exact', head: true })
.eq('user_id', user.id)
.is('is_business', null)
return (
<div className="min-h-screen bg-background">
@@ -54,7 +47,6 @@ export default async function DashboardLayout({
<DashboardNav
companyName={settings.company_name || 'Min verksamhet'}
entityType={entityType}
enabledExtensions={enabledToggles || []}
uncategorizedTransactionCount={uncategorizedCount ?? 0}
/>
<main id="main-content" className="pb-20 md:pb-0 md:pl-[232px]" role="main">
+1 -19
View File
@@ -6,10 +6,9 @@ import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { Download, FileText, FileDown, TrendingUp, Scale, AlertCircle, Receipt, Briefcase, Building2, BookOpen, List, Users, ChevronDown, ChevronRight, ArrowLeftRight } from 'lucide-react'
import { Download, AlertCircle, ChevronDown, ChevronRight } from 'lucide-react'
import { AccountNumber } from '@/components/ui/account-number'
import { NEDeclarationView } from '@/components/reports/NEDeclarationView'
import { SRUExportView } from '@/components/reports/SRUExportView'
import { BankReconciliationView } from '@/components/reports/BankReconciliationView'
import { TrialBalanceChart } from '@/components/reports/TrialBalanceChart'
import { VatCompositionChart } from '@/components/reports/VatCompositionChart'
@@ -111,15 +110,12 @@ export default function ReportsPage() {
</p>
<TabsList className="flex flex-col h-auto w-full gap-0.5 p-1">
<TabsTrigger value="trial-balance" className="w-full justify-start">
<Scale className="h-4 w-4 mr-1.5" />
Saldobalans
</TabsTrigger>
<TabsTrigger value="income-statement" className="w-full justify-start">
<TrendingUp className="h-4 w-4 mr-1.5" />
Resultaträkning
</TabsTrigger>
<TabsTrigger value="balance-sheet" className="w-full justify-start">
<FileText className="h-4 w-4 mr-1.5" />
Balansräkning
</TabsTrigger>
</TabsList>
@@ -132,19 +128,13 @@ export default function ReportsPage() {
</p>
<TabsList className="flex flex-col h-auto w-full gap-0.5 p-1">
<TabsTrigger value="vat-declaration" className="w-full justify-start">
<Receipt className="h-4 w-4 mr-1.5" />
Momsdeklaration
</TabsTrigger>
{isEnskildFirma && (
<TabsTrigger value="ne-declaration" className="w-full justify-start">
<Briefcase className="h-4 w-4 mr-1.5" />
NE-bilaga
</TabsTrigger>
)}
<TabsTrigger value="sru-export" className="w-full justify-start">
<FileDown className="h-4 w-4 mr-1.5" />
SRU-export
</TabsTrigger>
</TabsList>
</div>
@@ -155,19 +145,15 @@ export default function ReportsPage() {
</p>
<TabsList className="flex flex-col h-auto w-full gap-0.5 p-1">
<TabsTrigger value="huvudbok" className="w-full justify-start">
<BookOpen className="h-4 w-4 mr-1.5" />
Huvudbok
</TabsTrigger>
<TabsTrigger value="grundbok" className="w-full justify-start">
<List className="h-4 w-4 mr-1.5" />
Grundbok
</TabsTrigger>
<TabsTrigger value="kundreskontra" className="w-full justify-start">
<Users className="h-4 w-4 mr-1.5" />
Kundreskontra
</TabsTrigger>
<TabsTrigger value="supplier-ledger" className="w-full justify-start">
<Building2 className="h-4 w-4 mr-1.5" />
Leverantörsreskontra
</TabsTrigger>
</TabsList>
@@ -180,7 +166,6 @@ export default function ReportsPage() {
</p>
<TabsList className="flex flex-col h-auto w-full gap-0.5 p-1">
<TabsTrigger value="bank-reconciliation" className="w-full justify-start">
<ArrowLeftRight className="h-4 w-4 mr-1.5" />
Bankavstämning
</TabsTrigger>
</TabsList>
@@ -204,9 +189,6 @@ export default function ReportsPage() {
<NEDeclarationView periodId={selectedPeriod} />
</TabsContent>
)}
<TabsContent value="sru-export">
<SRUExportView periodId={selectedPeriod} />
</TabsContent>
<TabsContent value="huvudbok">
<GeneralLedgerView periodId={selectedPeriod} />
</TabsContent>
+170 -43
View File
@@ -11,19 +11,24 @@ import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { useToast } from '@/components/ui/use-toast'
import { Separator } from '@/components/ui/separator'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Loader2,
Building,
CreditCard,
User,
LogOut,
Bell,
Calendar,
Sun,
Moon,
Monitor,
Palette,
ExternalLink,
AlertTriangle,
} from 'lucide-react'
import { useTheme } from 'next-themes'
import type { CompanySettings } from '@/types'
@@ -44,6 +49,10 @@ export default function SettingsPage() {
const [settings, setSettings] = useState<CompanySettings | null>(null)
const [hasBankingExtension, setHasBankingExtension] = useState(false)
const [hasNotificationExtension, setHasNotificationExtension] = useState(false)
const [hasCalendarExtension, setHasCalendarExtension] = useState(false)
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [deleteConfirmText, setDeleteConfirmText] = useState('')
const [isDeleting, setIsDeleting] = useState(false)
const { theme, setTheme } = useTheme()
const [mounted, setMounted] = useState(false)
@@ -118,9 +127,10 @@ export default function SettingsPage() {
setSettings(settingsData)
// Check extension toggles in parallel
const [bankingToggleRes, notificationToggleRes] = await Promise.all([
const [bankingToggleRes, notificationToggleRes, calendarToggleRes] = await Promise.all([
fetch('/api/extensions/toggles/general/enable-banking').catch(() => null),
fetch('/api/extensions/toggles/general/push-notifications').catch(() => null),
fetch('/api/extensions/toggles/general/calendar').catch(() => null),
])
if (bankingToggleRes?.ok) {
@@ -142,6 +152,11 @@ export default function SettingsPage() {
setHasNotificationExtension(data?.enabled || false)
}
if (calendarToggleRes?.ok) {
const { data } = await calendarToggleRes.json()
setHasCalendarExtension(data?.enabled || false)
}
setIsLoading(false)
}
@@ -204,6 +219,33 @@ export default function SettingsPage() {
router.push('/login')
}
async function handleDeleteAccount() {
if (deleteConfirmText !== 'RADERA') return
setIsDeleting(true)
try {
const response = await fetch('/api/account/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ confirm: 'RADERA' }),
})
if (!response.ok) {
const result = await response.json()
throw new Error(result.error || 'Kunde inte radera kontot')
}
router.push('/login')
} catch (error) {
toast({
title: 'Fel',
description: error instanceof Error ? error.message : 'Kunde inte radera kontot',
variant: 'destructive',
})
setIsDeleting(false)
}
}
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
@@ -224,27 +266,25 @@ export default function SettingsPage() {
<Tabs defaultValue={initialTab} className="space-y-6">
<TabsList className="flex-wrap h-auto gap-1">
<TabsTrigger value="company">
<Building className="mr-2 h-4 w-4" />
Företag
</TabsTrigger>
<TabsTrigger value="banking">
<CreditCard className="mr-2 h-4 w-4" />
Bank (PSD2)
</TabsTrigger>
<TabsTrigger value="notifications">
<Bell className="mr-2 h-4 w-4" />
Aviseringar
</TabsTrigger>
<TabsTrigger value="calendar">
<Calendar className="mr-2 h-4 w-4" />
Kalender
</TabsTrigger>
{hasNotificationExtension && (
<TabsTrigger value="notifications">
Aviseringar
</TabsTrigger>
)}
{hasCalendarExtension && (
<TabsTrigger value="calendar">
Kalender
</TabsTrigger>
)}
<TabsTrigger value="appearance">
<Palette className="mr-2 h-4 w-4" />
Utseende
</TabsTrigger>
<TabsTrigger value="account">
<User className="mr-2 h-4 w-4" />
Konto
</TabsTrigger>
</TabsList>
@@ -268,7 +308,11 @@ export default function SettingsPage() {
id="company_name"
name="company_name"
defaultValue={settings?.company_name || ''}
disabled={settings?.onboarding_complete === true}
/>
{settings?.onboarding_complete && (
<p className="text-xs text-muted-foreground">Kan inte ändras efter att kontot skapats</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="org_number">Organisationsnummer</Label>
@@ -276,7 +320,11 @@ export default function SettingsPage() {
id="org_number"
name="org_number"
defaultValue={settings?.org_number || ''}
disabled={settings?.onboarding_complete === true}
/>
{settings?.onboarding_complete && (
<p className="text-xs text-muted-foreground">Kan inte ändras efter att kontot skapats</p>
)}
</div>
</div>
@@ -485,32 +533,20 @@ export default function SettingsPage() {
</TabsContent>
{/* Notification settings — loaded dynamically from extension */}
<TabsContent value="notifications">
{hasNotificationExtension && NotificationPanel ? (
<NotificationPanel />
) : (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<Bell className="h-10 w-10 text-muted-foreground/40 mb-4" />
<p className="font-medium mb-1">Push-notiser är inte aktiverade</p>
<p className="text-sm text-muted-foreground mb-4 max-w-md">
Aktivera tillägget Push-notiser för att få aviseringar om förfallna fakturor, deadlines och andra händelser.
</p>
<Button variant="outline" asChild>
<Link href="/extensions">
<ExternalLink className="mr-2 h-4 w-4" />
Gå till Tillägg
</Link>
</Button>
</CardContent>
</Card>
)}
</TabsContent>
{hasNotificationExtension && (
<TabsContent value="notifications">
{NotificationPanel ? (
<NotificationPanel />
) : null}
</TabsContent>
)}
{/* Calendar feed settings */}
<TabsContent value="calendar">
<CalendarFeedSettings />
</TabsContent>
{hasCalendarExtension && (
<TabsContent value="calendar">
<CalendarFeedSettings />
</TabsContent>
)}
{/* Appearance settings */}
<TabsContent value="appearance">
@@ -610,7 +646,7 @@ export default function SettingsPage() {
</TabsContent>
{/* Account settings */}
<TabsContent value="account">
<TabsContent value="account" className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Kontoinställningar</CardTitle>
@@ -630,8 +666,99 @@ export default function SettingsPage() {
</div>
</CardContent>
</Card>
<Card className="border-destructive/50">
<CardHeader>
<CardTitle className="text-destructive">Radera konto</CardTitle>
<CardDescription>
Permanent borttagning av ditt konto och all data
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4">
<div className="flex gap-3">
<AlertTriangle className="h-5 w-5 text-destructive shrink-0 mt-0.5" />
<div className="space-y-2 text-sm">
<p className="font-medium text-destructive">Varning: Denna åtgärd kan inte ångras</p>
<p className="text-muted-foreground">
All din data raderas permanent — bokföring, fakturor, verifikationer, dokument och inställningar.
</p>
<p className="text-muted-foreground">
Enligt bokföringslagen (BFL 7 kap. 2§) ska räkenskapsinformation bevaras i 7 år. Du ansvarar själv för att exportera och arkivera din bokföringsdata innan du raderar kontot.
</p>
</div>
</div>
</div>
<div className="flex items-center justify-between">
<Button variant="outline" asChild>
<Link href="/reports?type=sie">
<ExternalLink className="mr-2 h-4 w-4" />
Exportera bokföringsdata (SIE)
</Link>
</Button>
<Button
variant="destructive"
onClick={() => setShowDeleteDialog(true)}
>
Radera mitt konto
</Button>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
{/* Delete account confirmation dialog */}
<Dialog open={showDeleteDialog} onOpenChange={(open) => {
setShowDeleteDialog(open)
if (!open) setDeleteConfirmText('')
}}>
<DialogContent>
<DialogHeader>
<DialogTitle>Radera konto permanent</DialogTitle>
<DialogDescription>
All din data raderas permanent. Skriv <strong>RADERA</strong> nedan för att bekräfta.
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="delete-confirm">Bekräfta genom att skriva RADERA</Label>
<Input
id="delete-confirm"
value={deleteConfirmText}
onChange={(e) => setDeleteConfirmText(e.target.value)}
placeholder="RADERA"
autoComplete="off"
/>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setShowDeleteDialog(false)
setDeleteConfirmText('')
}}
disabled={isDeleting}
>
Avbryt
</Button>
<Button
variant="destructive"
onClick={handleDeleteAccount}
disabled={deleteConfirmText !== 'RADERA' || isDeleting}
>
{isDeleting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Raderar...
</>
) : (
'Radera permanent'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
+81 -8
View File
@@ -20,10 +20,12 @@ import InvoiceMatchDialog from '@/components/transactions/InvoiceMatchDialog'
import TransactionBookingDialog from '@/components/transactions/TransactionBookingDialog'
import QuickReviewDialog from '@/components/transactions/QuickReviewDialog'
import DescribeTransactionDialog from '@/components/transactions/DescribeTransactionDialog'
import TemplatePicker from '@/components/transactions/TemplatePicker'
import { EXPENSE_CATEGORIES, INCOME_CATEGORIES } from '@/components/transactions/transaction-types'
import { getDefaultAccountForCategory, getDefaultVatTreatmentForCategory } from '@/lib/bookkeeping/category-mapping'
import { getTemplateById, type BookingTemplate } from '@/lib/bookkeeping/booking-templates'
import type { TransactionWithInvoice, ViewMode, CategorizeHandler } from '@/components/transactions/transaction-types'
import type { TransactionCategory, CreateTransactionInput, Invoice, Customer, VatTreatment, InvoiceInboxItem } from '@/types'
import type { TransactionCategory, CreateTransactionInput, Invoice, Customer, VatTreatment, InvoiceInboxItem, EntityType } from '@/types'
import type { SuggestedCategory, SuggestedTemplate } from '@/lib/transactions/category-suggestions'
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
@@ -54,11 +56,17 @@ export default function TransactionsPage() {
const [bookingDialogOpen, setBookingDialogOpen] = useState(false)
const [bookingDialogTransaction, setBookingDialogTransaction] = useState<TransactionWithInvoice | null>(null)
// Template picker dialog
const [templatePickerOpen, setTemplatePickerOpen] = useState(false)
const [templatePickerTransaction, setTemplatePickerTransaction] = useState<TransactionWithInvoice | null>(null)
// Quick review dialog (suggestion review before booking)
const [quickReviewOpen, setQuickReviewOpen] = useState(false)
const [quickReviewTransaction, setQuickReviewTransaction] = useState<TransactionWithInvoice | null>(null)
const [quickReviewCategory, setQuickReviewCategory] = useState<TransactionCategory | null>(null)
const [quickReviewLabel, setQuickReviewLabel] = useState('')
const [quickReviewTemplate, setQuickReviewTemplate] = useState<BookingTemplate | null>(null)
const [quickReviewTemplateId, setQuickReviewTemplateId] = useState<string | undefined>(undefined)
// Describe dialog
const [describeDialogOpen, setDescribeDialogOpen] = useState(false)
@@ -502,8 +510,8 @@ export default function TransactionsPage() {
}
function openCategoryDialog(transaction: TransactionWithInvoice) {
setBookingDialogTransaction(transaction)
setBookingDialogOpen(true)
setTemplatePickerTransaction(transaction)
setTemplatePickerOpen(true)
}
function handleOpenQuickReview(transaction: TransactionWithInvoice, suggestion: SuggestedCategory) {
@@ -515,17 +523,59 @@ export default function TransactionsPage() {
setQuickReviewOpen(true)
}
function handleTemplateSelected(template: BookingTemplate) {
setTemplatePickerOpen(false)
const tx = templatePickerTransaction
if (!tx) return
setQuickReviewTransaction(tx)
setQuickReviewCategory(template.fallback_category)
setQuickReviewLabel(template.name_sv)
setQuickReviewTemplate(template)
setQuickReviewTemplateId(template.id)
setQuickReviewOpen(true)
}
function handleOpenTemplateReview(transaction: TransactionWithInvoice, templateId: string) {
const template = getTemplateById(templateId)
if (!template) return
setQuickReviewTransaction(transaction)
setQuickReviewCategory(template.fallback_category)
setQuickReviewLabel(template.name_sv)
setQuickReviewTemplate(template)
setQuickReviewTemplateId(template.id)
setQuickReviewOpen(true)
}
function handleChangeTemplate() {
setQuickReviewOpen(false)
if (quickReviewTransaction) {
setTemplatePickerTransaction(quickReviewTransaction)
setTemplatePickerOpen(true)
}
}
function handleManualBooking() {
setTemplatePickerOpen(false)
if (templatePickerTransaction) {
setBookingDialogTransaction(templatePickerTransaction)
setBookingDialogOpen(true)
}
}
async function handleQuickReviewConfirm(
id: string,
category: TransactionCategory,
vatTreatment: VatTreatment | undefined,
accountOverride: string | undefined
accountOverride: string | undefined,
templateId?: string
): Promise<string | null> {
const journalEntryId = await handleCategorize(id, true, category, vatTreatment, accountOverride)
const journalEntryId = await handleCategorize(id, true, category, vatTreatment, accountOverride, templateId)
if (journalEntryId) {
setQuickReviewOpen(false)
setQuickReviewTransaction(null)
setQuickReviewCategory(null)
setQuickReviewTemplate(null)
setQuickReviewTemplateId(undefined)
}
return journalEntryId
}
@@ -567,7 +617,7 @@ export default function TransactionsPage() {
onCategorize={handleCategorize}
onMatchInvoice={handleMatchInvoice}
onClose={() => setShowSwipeView(false)}
entityType={entityType as import('@/types').EntityType}
entityType={entityType as EntityType}
/>
)
}
@@ -629,6 +679,7 @@ export default function TransactionsPage() {
onOpenCategoryDialog={openCategoryDialog}
onOpenDescribe={ENABLED_EXTENSION_IDS.has('ai-categorization') ? openDescribeDialog : undefined}
onOpenQuickReview={handleOpenQuickReview}
onOpenTemplateReview={handleOpenTemplateReview}
onToggleSelect={toggleBatchSelect}
/>
))}
@@ -684,8 +735,27 @@ export default function TransactionsPage() {
onBooked={handleTransactionBooked}
/>
<Dialog open={templatePickerOpen} onOpenChange={setTemplatePickerOpen}>
<DialogContent className="max-w-lg max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Välj mall</DialogTitle>
</DialogHeader>
<TemplatePicker
direction={templatePickerTransaction && templatePickerTransaction.amount < 0 ? 'expense' : 'income'}
entityType={entityType as EntityType}
suggestedTemplates={templatePickerTransaction ? templateSuggestions[templatePickerTransaction.id] : undefined}
onSelect={handleTemplateSelected}
/>
<div className="pt-2 border-t">
<Button variant="ghost" size="sm" className="w-full text-muted-foreground" onClick={handleManualBooking}>
Bokför manuellt utan mall...
</Button>
</div>
</DialogContent>
</Dialog>
<QuickReviewDialog
key={quickReviewTransaction?.id ?? '' + String(quickReviewCategory)}
key={quickReviewTransaction?.id ?? '' + String(quickReviewCategory) + String(quickReviewTemplateId)}
open={quickReviewOpen}
onOpenChange={setQuickReviewOpen}
transaction={quickReviewTransaction}
@@ -693,8 +763,11 @@ export default function TransactionsPage() {
categoryLabel={quickReviewLabel}
defaultAccount={quickReviewCategory ? getDefaultAccountForCategory(quickReviewCategory) : ''}
defaultVat={quickReviewCategory ? (getDefaultVatTreatmentForCategory(quickReviewCategory) ?? 'none') : 'none'}
entityType={entityType as import('@/types').EntityType}
entityType={entityType as EntityType}
template={quickReviewTemplate}
templateId={quickReviewTemplateId}
onConfirm={handleQuickReviewConfirm}
onChangeTemplate={handleChangeTemplate}
/>
<DescribeTransactionDialog
+31 -69
View File
@@ -14,8 +14,6 @@ import Step2CompanyDetails from '@/components/onboarding/Step2CompanyDetails'
import Step3TaxRegistration from '@/components/onboarding/Step3TaxRegistration'
import Step4PreliminaryTax from '@/components/onboarding/Step4PreliminaryTax'
import Step5ConnectBank from '@/components/onboarding/Step6ConnectBank'
import Step6SectorSelection from '@/components/onboarding/Step2SectorSelection'
import Step7ExtensionSuggestions from '@/components/onboarding/Step3ExtensionSuggestions'
const STEP_TITLES = [
'Verksamhetsform',
@@ -23,8 +21,6 @@ const STEP_TITLES = [
'Skatteregistrering',
'F-skatt',
'Anslut bank',
'Bransch',
'Tillägg',
]
function translatePeriodError(msg: string): string {
@@ -53,9 +49,8 @@ function OnboardingPageContent() {
const [isSaving, setIsSaving] = useState(false)
const [currentStep, setCurrentStep] = useState(1)
const [settings, setSettings] = useState<Partial<CompanySettings>>({})
const [sectorSlug, setSectorSlug] = useState<string | null>(null)
const totalSteps = 7
const totalSteps = 5
const stepTitles = STEP_TITLES
// Load existing settings on mount
@@ -76,10 +71,9 @@ function OnboardingPageContent() {
if (data) {
setSettings(data)
setCurrentStep(data.onboarding_step || 1)
if ((data as Record<string, unknown>).sector_slug) {
setSectorSlug((data as Record<string, unknown>).sector_slug as string)
}
const step = data.onboarding_step || 1
// Clamp to max steps (handles existing users mid-onboarding from old 7-step flow)
setCurrentStep(step > totalSteps ? totalSteps : step)
}
setIsLoading(false)
@@ -95,9 +89,14 @@ function OnboardingPageContent() {
title: 'Bank ansluten!',
description: 'Din bank har kopplats.',
})
saveSettings({}, 6).then((success) => {
// Complete onboarding after bank connection
saveSettings({ onboarding_complete: true }, totalSteps).then((success) => {
if (success) {
setCurrentStep(6)
toast({
title: 'Välkommen!',
description: 'Din profil är nu redo.',
})
router.push('/')
}
})
}
@@ -148,6 +147,11 @@ function OnboardingPageContent() {
}
const handleNext = async (stepData: Partial<CompanySettings>) => {
// Fix org number bug: clear dependent fields when entity type changes
if (currentStep === 1 && stepData.entity_type && stepData.entity_type !== settings.entity_type) {
stepData = { ...stepData, org_number: '', company_name: '' }
}
const nextStep = currentStep + 1
const success = await saveSettings(stepData, nextStep)
@@ -275,6 +279,10 @@ function OnboardingPageContent() {
if (nextStep > totalSteps) {
await saveSettings({ onboarding_complete: true }, totalSteps)
toast({
title: 'Välkommen!',
description: 'Din profil är nu redo.',
})
router.push('/')
} else {
setCurrentStep(nextStep)
@@ -307,41 +315,6 @@ function OnboardingPageContent() {
const progressPercent = ((currentStep - 1) / (totalSteps - 1)) * 100
const handleSectorNext = async (data: { sector_slug: string | null }) => {
setSectorSlug(data.sector_slug)
const nextStep = currentStep + 1
const settingsUpdate: Partial<CompanySettings> = {}
if (data.sector_slug) {
;(settingsUpdate as Record<string, unknown>).sector_slug = data.sector_slug
}
const success = await saveSettings(settingsUpdate, nextStep)
if (success) {
setCurrentStep(nextStep)
}
}
const handleExtensionsNext = async (data: { enabled_extensions: { sector_slug: string; extension_slug: string }[] }) => {
// Insert extension toggles if any were selected
if (data.enabled_extensions.length > 0) {
try {
const { data: { user } } = await supabase.auth.getUser()
if (user) {
const rows = data.enabled_extensions.map(ext => ({
user_id: user.id,
sector_slug: ext.sector_slug,
extension_slug: ext.extension_slug,
enabled: true,
}))
await supabase.from('extension_toggles').upsert(rows, {
onConflict: 'user_id,sector_slug,extension_slug',
})
}
} catch (err) {
console.error('Failed to save extension toggles:', err)
}
}
}
const renderSteps = () => (
<>
{currentStep === 1 && (
@@ -354,6 +327,7 @@ function OnboardingPageContent() {
{currentStep === 2 && (
<Step2CompanyDetails
key={settings.entity_type}
initialData={{
company_name: settings.company_name ?? undefined,
org_number: settings.org_number ?? undefined,
@@ -379,6 +353,7 @@ function OnboardingPageContent() {
accounting_method: (settings.accounting_method as 'accrual' | 'cash') ?? undefined,
}}
entityType={settings.entity_type as EntityType}
orgNumber={settings.org_number ?? undefined}
onNext={(data) => handleNext(data)}
onBack={handleBack}
isSaving={isSaving}
@@ -408,29 +383,8 @@ function OnboardingPageContent() {
}}
onComplete={async (data) => {
if (data) {
await saveSettings(data, 6)
await saveSettings(data, totalSteps)
}
setCurrentStep(6)
}}
onBack={handleBack}
onSkip={() => setCurrentStep(6)}
isSaving={isSaving}
/>
)}
{currentStep === 6 && (
<Step6SectorSelection
onNext={handleSectorNext}
onBack={handleBack}
isSaving={isSaving}
/>
)}
{currentStep === 7 && (
<Step7ExtensionSuggestions
sectorSlug={sectorSlug}
onNext={async (data) => {
await handleExtensionsNext(data)
await saveSettings({ onboarding_complete: true }, totalSteps)
toast({
title: 'Välkommen!',
@@ -439,6 +393,14 @@ function OnboardingPageContent() {
router.push('/')
}}
onBack={handleBack}
onSkip={async () => {
await saveSettings({ onboarding_complete: true }, totalSteps)
toast({
title: 'Välkommen!',
description: 'Din profil är nu redo.',
})
router.push('/')
}}
isSaving={isSaving}
/>
)}
+54
View File
@@ -0,0 +1,54 @@
import { createClient, createServiceClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { z } from 'zod'
const DeleteAccountSchema = z.object({
confirm: z.literal('RADERA'),
})
export async function POST(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
let body: unknown
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
}
const parsed = DeleteAccountSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ error: 'Skriv RADERA för att bekräfta kontoborttagning' },
{ status: 400 }
)
}
try {
// Delete the auth user via service role — all data cascades via ON DELETE CASCADE
const serviceClient = await createServiceClient()
const { error } = await serviceClient.auth.admin.deleteUser(user.id)
if (error) {
console.error('Failed to delete user:', error)
return NextResponse.json(
{ error: 'Kunde inte radera kontot. Försök igen.' },
{ status: 500 }
)
}
return NextResponse.json({ success: true })
} catch (error) {
console.error('Account deletion error:', error)
return NextResponse.json(
{ error: 'Kunde inte radera kontot. Försök igen.' },
{ status: 500 }
)
}
}
@@ -0,0 +1,91 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import {
previewCurrencyRevaluation,
executeCurrencyRevaluation,
} from '@/lib/bookkeeping/currency-revaluation'
/**
* GET: Preview currency revaluation for a fiscal period
*/
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
// Fetch period to get closing date
const { data: period, error: periodError } = await supabase
.from('fiscal_periods')
.select('*')
.eq('id', id)
.eq('user_id', user.id)
.single()
if (periodError || !period) {
return NextResponse.json({ error: 'Fiscal period not found' }, { status: 404 })
}
const preview = await previewCurrencyRevaluation(supabase, user.id, period.period_end)
return NextResponse.json({ data: preview })
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to preview currency revaluation' },
{ status: 400 }
)
}
}
/**
* POST: Execute currency revaluation for a fiscal period
*/
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
// Fetch period to get closing date
const { data: period, error: periodError } = await supabase
.from('fiscal_periods')
.select('*')
.eq('id', id)
.eq('user_id', user.id)
.single()
if (periodError || !period) {
return NextResponse.json({ error: 'Fiscal period not found' }, { status: 404 })
}
if (period.is_closed) {
return NextResponse.json({ error: 'Period is already closed' }, { status: 400 })
}
const result = await executeCurrencyRevaluation(supabase, user.id, period.period_end, id)
if (!result) {
return NextResponse.json({ data: null, message: 'No foreign currency items to revalue' })
}
return NextResponse.json({ data: result })
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to execute currency revaluation' },
{ status: 400 }
)
}
}
+2 -2
View File
@@ -27,8 +27,8 @@ export async function GET(request: Request) {
.from('journal_entries')
.select('*, lines:journal_entry_lines(*)', { count: 'exact' })
.eq('user_id', user.id)
.order('entry_date', { ascending: false })
.order('voucher_number', { ascending: false })
.order('voucher_series', { ascending: true })
.order('voucher_number', { ascending: true })
.range(offset, offset + limit - 1)
if (periodId) {
+14 -8
View File
@@ -157,15 +157,21 @@ export async function POST(request: Request) {
}
}
// Generate invoice number with appropriate prefix
const { data: baseNumber } = await supabase.rpc('generate_invoice_number', {
p_user_id: user.id,
})
const invoiceNumber = documentType === 'proforma'
? `PF-${baseNumber}`
: documentType === 'delivery_note'
? `FS-${baseNumber}`
// Generate document number from the appropriate sequence
let invoiceNumber: string
if (documentType === 'delivery_note') {
const { data: dnNumber } = await supabase.rpc('generate_delivery_note_number', {
p_user_id: user.id,
})
invoiceNumber = dnNumber
} else {
const { data: baseNumber } = await supabase.rpc('generate_invoice_number', {
p_user_id: user.id,
})
invoiceNumber = documentType === 'proforma'
? `PF-${baseNumber}`
: baseNumber
}
// Create invoice
const { data: invoice, error: invoiceError } = await supabase
+1 -1
View File
@@ -5,7 +5,7 @@ import {
generateSRUFile,
sruFileToString,
getSRUFilename,
} from '@/lib/reports/sru-export/sru-generator'
} from '@/lib/reports/ne-bilaga/sru-generator'
/**
* GET /api/reports/ne-bilaga
@@ -1,29 +0,0 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { getSRUCoverage } from '@/lib/reports/sru-export/sru-engine'
/**
* GET /api/reports/sru-export/coverage
*
* Returns SRU code coverage stats: how many accounts have SRU codes vs total,
* and a list of accounts missing codes.
*/
export async function GET() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const coverage = await getSRUCoverage(supabase, user.id)
return NextResponse.json({ data: coverage })
} catch (err) {
console.error('Error fetching SRU coverage:', err)
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to fetch SRU coverage' },
{ status: 500 }
)
}
}
-150
View File
@@ -1,150 +0,0 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { aggregateBalancesBySRU } from '@/lib/reports/sru-export/sru-engine'
import {
generateGenericSRU,
getGenericSRUFilename,
sruFileToString,
validateSRUFile,
SRU_CODE_DESCRIPTIONS,
} from '@/lib/reports/sru-export/sru-generic-generator'
import type { SRUFormType } from '@/lib/reports/sru-export/sru-generic-generator'
import type { EntityType } from '@/types'
/**
* GET /api/reports/sru-export
*
* Generate SRU export from chart_of_accounts sru_code mappings.
*
* Query parameters:
* - period_id: Fiscal period ID (required)
* - format: 'json' (default) or 'sru' for downloadable file
*/
export async function GET(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const periodId = searchParams.get('period_id')
const format = searchParams.get('format') || 'json'
if (!periodId) {
return NextResponse.json(
{ error: 'period_id is required' },
{ status: 400 }
)
}
try {
// Fetch company settings to determine entity type
const { data: settings } = await supabase
.from('company_settings')
.select('entity_type, company_name, org_number')
.eq('user_id', user.id)
.single()
if (!settings) {
return NextResponse.json(
{ error: 'Company settings not found. Complete onboarding first.' },
{ status: 400 }
)
}
// Determine form type from entity type
const formType: SRUFormType = settings.entity_type === 'aktiebolag' ? 'INK2' : 'NE'
// Fetch fiscal period for date range
const { data: period } = await supabase
.from('fiscal_periods')
.select('*')
.eq('id', periodId)
.eq('user_id', user.id)
.single()
if (!period) {
return NextResponse.json(
{ error: 'Fiscal period not found' },
{ status: 404 }
)
}
// Aggregate balances by SRU code
const sruBalances = await aggregateBalancesBySRU(supabase, user.id, periodId)
if (format === 'sru') {
// Generate and return SRU file
const sruFile = generateGenericSRU({
formType,
orgNumber: settings.org_number,
companyName: settings.company_name || 'Okänt företag',
fiscalYearStart: period.period_start,
fiscalYearEnd: period.period_end,
sruBalances,
})
const validation = validateSRUFile(sruFile)
if (!validation.isValid) {
return NextResponse.json(
{ error: 'SRU validation failed', details: validation.errors },
{ status: 500 }
)
}
const sruContent = sruFileToString(sruFile)
const filename = getGenericSRUFilename(formType, settings.org_number, period.period_start)
return new NextResponse(sruContent, {
status: 200,
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Content-Disposition': `attachment; filename="${filename}"`,
},
})
}
// Default: return JSON preview
const balancesArray = Array.from(sruBalances.values())
.sort((a, b) => a.sruCode.localeCompare(b.sruCode))
.map((b) => ({
sruCode: b.sruCode,
description: SRU_CODE_DESCRIPTIONS[b.sruCode] || `SRU ${b.sruCode}`,
amount: b.amount,
accounts: b.accounts,
}))
const warnings: string[] = []
if (!period.is_closed) {
warnings.push('Räkenskapsåret är inte stängt. Siffrorna kan ändras.')
}
if (balancesArray.length === 0) {
warnings.push('Inga konton med SRU-koder har bokförda poster i denna period.')
}
return NextResponse.json({
data: {
formType,
entityType: settings.entity_type as EntityType,
companyName: settings.company_name,
orgNumber: settings.org_number,
fiscalYear: {
id: period.id,
name: period.name,
start: period.period_start,
end: period.period_end,
},
balances: balancesArray,
warnings,
},
})
} catch (err) {
console.error('Error generating SRU export:', err)
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to generate SRU export' },
{ status: 500 }
)
}
}
+7 -1
View File
@@ -38,7 +38,7 @@ export async function PUT(request: Request) {
// Fetch current settings to check for tax-relevant changes
const { data: oldSettings } = await supabase
.from('company_settings')
.select('entity_type, moms_period, f_skatt, vat_registered, pays_salaries, fiscal_year_start_month')
.select('entity_type, moms_period, f_skatt, vat_registered, pays_salaries, fiscal_year_start_month, onboarding_complete')
.eq('user_id', user.id)
.single()
@@ -46,6 +46,12 @@ export async function PUT(request: Request) {
if (!validation.success) return validation.response
const body = validation.data
// Lock company_name and org_number after onboarding is complete
if (oldSettings && (oldSettings as Record<string, unknown>).onboarding_complete === true) {
delete (body as Record<string, unknown>).company_name
delete (body as Record<string, unknown>).org_number
}
// Validate: enskild firma must use calendar year (BFL 3 kap.)
const effectiveEntityType = body.entity_type || oldSettings?.entity_type
const effectiveFYStartMonth = body.fiscal_year_start_month ?? oldSettings?.fiscal_year_start_month
@@ -3,7 +3,7 @@ import { NextResponse } from 'next/server'
import { eventBus } from '@/lib/events'
import { ensureInitialized } from '@/lib/init'
import { buildMappingResultFromCategory } from '@/lib/bookkeeping/category-mapping'
import { getTemplateById, buildMappingResultFromTemplate } from '@/lib/bookkeeping/booking-templates'
import { getTemplateById, buildMappingResultFromTemplate, validateTemplateForEntity } from '@/lib/bookkeeping/booking-templates'
import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries'
import { saveUserMappingRule } from '@/lib/bookkeeping/mapping-engine'
import { validateBody } from '@/lib/api/validate'
@@ -158,6 +158,12 @@ export async function POST(
if (body.template_id) {
const template = getTemplateById(body.template_id)
if (template) {
// Hard entity guard — reject templates that don't match the user's entity type
const entityValidation = validateTemplateForEntity(template, entityType)
if (!entityValidation.valid) {
return NextResponse.json({ error: entityValidation.error }, { status: 400 })
}
finalCategory = is_business ? template.fallback_category : 'private'
console.log(`[categorize] tx=${id} using template="${body.template_id}" (${template.name_sv}) → category=${finalCategory}, debit=${template.debit_account}, credit=${template.credit_account}, vat=${template.vat_treatment}`)
} else {
@@ -1,27 +1,8 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { getSuggestedCategories, mergeAiSuggestions, getSuggestedTemplates, type SuggestedCategory, type SuggestedTemplate } from '@/lib/transactions/category-suggestions'
import { extensionRegistry } from '@/lib/extensions/registry'
import type { Transaction, TransactionCategory, EntityType } from '@/types'
// Minimum confidence threshold — below this, suggestions are considered weak
// and we trigger on-demand AI categorization to get better results.
const WEAK_SUGGESTION_THRESHOLD = 0.55
/**
* Check if suggestions are "weak" — only history-based fallbacks
* with no strong rule/pattern/AI match.
*/
function hasWeakSuggestions(result: SuggestedCategory[]): boolean {
if (result.length === 0) return true
// Weak if the best suggestion is below threshold
const bestConfidence = Math.max(...result.map((s) => s.confidence))
if (bestConfidence < WEAK_SUGGESTION_THRESHOLD) return true
// Weak if all suggestions are from history only (no rule/pattern/ai match)
if (result.every((s) => s.source === 'history')) return true
return false
}
/**
* POST /api/transactions/suggest-categories
* Batch endpoint for getting category suggestions for multiple transactions
@@ -115,7 +96,6 @@ export async function POST(request: Request) {
// Generate initial suggestions for each transaction
const suggestions: Record<string, SuggestedCategory[]> = {}
const template_suggestions: Record<string, SuggestedTemplate[]> = {}
const needsAiIds: string[] = []
for (const tx of transactions) {
let result = getSuggestedCategories(
@@ -128,14 +108,10 @@ export async function POST(request: Request) {
const aiSuggestions = aiSuggestionsMap[tx.id]
if (aiSuggestions && aiSuggestions.length > 0) {
result = mergeAiSuggestions(result, aiSuggestions, tx.amount)
} else if (hasWeakSuggestions(result)) {
// No pre-computed AI suggestion AND rule-based suggestions are weak —
// mark this transaction for on-demand AI categorization
needsAiIds.push(tx.id)
}
suggestions[tx.id] = result
template_suggestions[tx.id] = await getSuggestedTemplates(tx as Transaction, entityType)
template_suggestions[tx.id] = await getSuggestedTemplates(tx as Transaction, entityType, mappingRules || undefined)
}
// Inject document template suggestions from matched inbox items
@@ -183,63 +159,5 @@ export async function POST(request: Request) {
// Non-blocking
}
// Trigger on-demand AI categorization for transactions with weak suggestions
if (needsAiIds.length > 0) {
console.log(
`[suggest-categories] ${needsAiIds.length} transactions have weak suggestions, triggering on-demand AI:`,
needsAiIds.map((id) => {
const tx = transactions.find((t) => t.id === id)
return tx
? { id, description: tx.description, merchant_name: tx.merchant_name, amount: tx.amount }
: { id }
})
)
try {
const aiExt = extensionRegistry.get('ai-categorization')
if (!aiExt?.services?.categorizeTransactions) {
console.log('[suggest-categories] AI categorization extension not loaded, skipping on-demand AI')
return NextResponse.json({ suggestions, template_suggestions })
}
const aiResults: Array<{ transactionId: string; category: string; basAccount: string; confidence: number; reasoning: string; isPrivate?: boolean; templateId?: string }> = await aiExt.services.categorizeTransactions(user.id, needsAiIds)
console.log(
'[suggest-categories] AI categorization results:',
aiResults.map((r) => ({
id: r.transactionId,
category: r.category,
basAccount: r.basAccount,
confidence: r.confidence,
reasoning: r.reasoning,
isPrivate: r.isPrivate,
templateId: r.templateId,
}))
)
// Group AI results by transactionId (AI now returns 2 per transaction)
const groupedAi: Record<string, { category: string; basAccount: string; confidence: number; reasoning: string }[]> = {}
for (const aiResult of aiResults) {
if (!groupedAi[aiResult.transactionId]) {
groupedAi[aiResult.transactionId] = []
}
groupedAi[aiResult.transactionId].push({
category: aiResult.category,
basAccount: aiResult.basAccount,
confidence: aiResult.confidence,
reasoning: aiResult.reasoning,
})
}
for (const [txId, aiSuggestions] of Object.entries(groupedAi)) {
const existing = suggestions[txId] || []
const tx = transactions.find((t) => t.id === txId)
suggestions[txId] = mergeAiSuggestions(existing, aiSuggestions, tx?.amount)
}
} catch (err) {
// AI categorization is non-blocking — log and continue with existing suggestions
console.error('[suggest-categories] On-demand AI categorization failed:', err)
}
}
return NextResponse.json({ suggestions, template_suggestions })
}
+2 -2
View File
@@ -23,13 +23,13 @@ const fraunces = Fraunces({
});
export const metadata: Metadata = {
title: "ERP Base",
title: "Gnubok",
description: "Ekonomihantering",
manifest: "/manifest.json",
appleWebApp: {
capable: true,
statusBarStyle: "default",
title: "ERP Base",
title: "Gnubok",
},
};