feat: BAS data overhaul, currency revaluation, expenses, UI polish, and cleanup
- Update BAS account catalog with comprehensive SRU codes and K2 flags - Add currency revaluation service with tests and API route - Add expenses page and account deletion API - Enhance booking templates with new patterns and improved tests - Improve transaction categorization with template picker and description matching - Polish dashboard, onboarding, import, and transaction UIs - Refactor year-end service for multi-step closing - Move SRU generator to ne-bilaga, remove standalone SRU export - Remove unused dev docs, mock data, and extension hooks - Add invoice delivery note sequences migration Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
a9bf24ce0b
commit
66a4027f1e
+60
-53
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 "{searchTerm}"
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user