This commit is contained in:
Jakob Wennberg
2026-02-19 14:32:54 +01:00
parent cdf1dcc4c8
commit e8743e6e03
104 changed files with 4551 additions and 4172 deletions
@@ -25,6 +25,8 @@ export default function ChartOfAccounts() {
const [loading, setLoading] = useState(true)
const [expandedClasses, setExpandedClasses] = useState<Set<number>>(new Set())
const [searchQuery, setSearchQuery] = useState('')
const [editingSRU, setEditingSRU] = useState<string | null>(null)
const [sruValue, setSruValue] = useState('')
useEffect(() => {
fetchAccounts()
@@ -37,6 +39,20 @@ export default function ChartOfAccounts() {
setLoading(false)
}
async function updateSRUCode(accountId: string, newSruCode: string) {
const supabase = createClient()
const trimmed = newSruCode.trim() || null
await supabase
.from('chart_of_accounts')
.update({ sru_code: trimmed })
.eq('id', accountId)
setAccounts((prev) =>
prev.map((a) => (a.id === accountId ? { ...a, sru_code: trimmed } : a))
)
setEditingSRU(null)
}
const toggleClass = (cls: number) => {
const next = new Set(expandedClasses)
if (next.has(cls)) {
@@ -121,6 +137,7 @@ export default function ChartOfAccounts() {
<tr className="border-b text-left text-muted-foreground">
<th className="py-2 w-24">Konto</th>
<th className="py-2">Namn</th>
<th className="py-2 w-20 text-center">SRU</th>
<th className="py-2 w-24 text-center">Typ</th>
<th className="py-2 w-24 text-center">Normal</th>
</tr>
@@ -135,6 +152,32 @@ export default function ChartOfAccounts() {
>
<td className="py-2 font-mono">{account.account_number}</td>
<td className="py-2">{account.account_name}</td>
<td className="py-2 text-center">
{editingSRU === account.id ? (
<Input
value={sruValue}
onChange={(e) => setSruValue(e.target.value)}
onBlur={() => updateSRUCode(account.id, sruValue)}
onKeyDown={(e) => {
if (e.key === 'Enter') updateSRUCode(account.id, sruValue)
if (e.key === 'Escape') setEditingSRU(null)
}}
className="h-7 w-16 text-xs text-center px-1"
autoFocus
/>
) : (
<button
className="text-xs font-mono text-muted-foreground hover:text-foreground cursor-pointer min-w-[2rem]"
onClick={(e) => {
e.stopPropagation()
setEditingSRU(account.id)
setSruValue(account.sru_code || '')
}}
>
{account.sru_code || '—'}
</button>
)}
</td>
<td className="py-2 text-center">
<Badge variant="outline" className="text-xs">
{account.account_type === 'asset'
+1 -1
View File
@@ -45,7 +45,7 @@ export default function JournalEntryList({ periodId }: Props) {
const statusLabel = (status: string) => {
switch (status) {
case 'posted':
return <Badge className="bg-green-100 text-green-800">Bokförd</Badge>
return <Badge variant="success">Bokförd</Badge>
case 'draft':
return <Badge variant="secondary">Utkast</Badge>
case 'reversed':
+11 -11
View File
@@ -120,14 +120,14 @@ export function CalendarDayView({
{paidInvoices.map((invoice) => (
<div
key={invoice.id}
className="p-2 rounded-md bg-green-500/10 border border-green-500/30"
className="p-2 rounded-md bg-success/10 border border-success/30"
>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-green-500 flex-shrink-0" />
<div className="w-3 h-3 rounded-full bg-success flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-green-700">
<div className="text-sm font-medium text-success">
Faktura {invoice.invoice_number}
<span className="ml-2 text-xs bg-green-500/20 px-1.5 py-0.5 rounded">
<span className="ml-2 text-xs bg-success/20 px-1.5 py-0.5 rounded">
Betald
</span>
</div>
@@ -151,18 +151,18 @@ export function CalendarDayView({
'p-2 rounded-md',
isDeadlineOverdue(deadline)
? 'bg-destructive/10 border border-destructive/30'
: 'bg-orange-500/10 border border-orange-500/30'
: 'bg-warning/10 border border-warning/30'
)}
>
<div className="flex items-center gap-2">
<div className={cn(
'w-3 h-3 rounded-sm flex-shrink-0',
isDeadlineOverdue(deadline) ? 'bg-destructive' : 'bg-orange-500'
isDeadlineOverdue(deadline) ? 'bg-destructive' : 'bg-warning'
)} />
<div className="flex-1 min-w-0">
<div className={cn(
'text-sm font-medium',
isDeadlineOverdue(deadline) ? 'text-destructive' : 'text-orange-700'
isDeadlineOverdue(deadline) ? 'text-destructive' : 'text-warning-foreground'
)}>
{deadline.title}
{isDeadlineOverdue(deadline) && (
@@ -197,17 +197,17 @@ export function CalendarDayView({
{completedDeadlines.map((deadline) => (
<div
key={deadline.id}
className="p-2 rounded-md bg-green-500/10 border border-green-500/30 opacity-60"
className="p-2 rounded-md bg-success/10 border border-success/30 opacity-60"
>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-sm bg-green-500 flex-shrink-0" />
<div className="w-3 h-3 rounded-sm bg-success flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-green-700 line-through">
<div className="text-sm font-medium text-success line-through">
{deadline.title}
</div>
<div className="text-xs text-muted-foreground">
{DEADLINE_TYPE_LABELS[deadline.deadline_type] || deadline.deadline_type}
<span className="ml-2 bg-green-500/20 px-1.5 py-0.5 rounded">Klar</span>
<span className="ml-2 bg-success/20 px-1.5 py-0.5 rounded">Klar</span>
</div>
</div>
</div>
+1 -1
View File
@@ -114,7 +114,7 @@ export function CalendarWeekView({
'text-xs p-1 mb-1 rounded truncate',
isDeadlineOverdue(deadline)
? 'bg-destructive/20 text-destructive'
: 'bg-orange-500/20 text-orange-700'
: 'bg-warning/20 text-warning-foreground'
)}
title={deadline.title}
>
+1 -1
View File
@@ -76,7 +76,7 @@ export function DeadlineCard({
<div
className={cn(
'w-2 h-2 rounded-full flex-shrink-0',
deadline.priority === 'critical' ? 'bg-destructive' : 'bg-orange-500'
deadline.priority === 'critical' ? 'bg-destructive' : 'bg-warning'
)}
/>
)}
+8 -8
View File
@@ -26,8 +26,8 @@ export function PaymentSummaryCard({ invoices, year, month }: PaymentSummaryCard
{/* Expected */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="h-8 w-8 rounded-lg bg-primary/10 flex items-center justify-center">
<TrendingUp className="h-4 w-4 text-primary" />
<div className="h-8 w-8 rounded-lg bg-muted flex items-center justify-center">
<TrendingUp className="h-4 w-4 text-muted-foreground" />
</div>
<div>
<p className="text-sm text-muted-foreground">Förväntat</p>
@@ -41,8 +41,8 @@ export function PaymentSummaryCard({ invoices, year, month }: PaymentSummaryCard
{summary.overdueCount > 0 && (
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="h-8 w-8 rounded-lg bg-destructive/10 flex items-center justify-center">
<AlertTriangle className="h-4 w-4 text-destructive" />
<div className="h-8 w-8 rounded-lg bg-muted flex items-center justify-center">
<AlertTriangle className="h-4 w-4 text-muted-foreground" />
</div>
<div>
<p className="text-sm text-muted-foreground">Förfallen</p>
@@ -60,8 +60,8 @@ export function PaymentSummaryCard({ invoices, year, month }: PaymentSummaryCard
{/* Paid */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="h-8 w-8 rounded-lg bg-success/10 flex items-center justify-center">
<CheckCircle className="h-4 w-4 text-success" />
<div className="h-8 w-8 rounded-lg bg-muted flex items-center justify-center">
<CheckCircle className="h-4 w-4 text-muted-foreground" />
</div>
<div>
<p className="text-sm text-muted-foreground">Betald</p>
@@ -77,8 +77,8 @@ export function PaymentSummaryCard({ invoices, year, month }: PaymentSummaryCard
{summary.pendingCount > 0 && summary.overdueCount < summary.pendingCount && (
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="h-8 w-8 rounded-lg bg-warning/10 flex items-center justify-center">
<Clock className="h-4 w-4 text-warning" />
<div className="h-8 w-8 rounded-lg bg-muted flex items-center justify-center">
<Clock className="h-4 w-4 text-muted-foreground" />
</div>
<div>
<p className="text-sm text-muted-foreground">Väntande</p>
+4 -4
View File
@@ -112,11 +112,11 @@ export function TaxTodoWidget({ deadlines, onStatusChange }: TaxTodoWidgetProps)
}
return (
<Card className="border-orange-200 bg-orange-50/30">
<Card className="border-warning/30 bg-warning/5">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
<FileText className="h-5 w-5 text-orange-600" />
<FileText className="h-5 w-5 text-warning-foreground" />
Att göra - Skatt
</CardTitle>
<div className="flex gap-2">
@@ -143,7 +143,7 @@ export function TaxTodoWidget({ deadlines, onStatusChange }: TaxTodoWidgetProps)
isOverdue
? 'border-destructive/50 bg-destructive/5'
: isActionNeeded
? 'border-orange-300 bg-orange-50'
? 'border-warning/40 bg-warning/10'
: 'border-border bg-background'
}`}
>
@@ -152,7 +152,7 @@ export function TaxTodoWidget({ deadlines, onStatusChange }: TaxTodoWidgetProps)
{isOverdue ? (
<AlertTriangle className="h-4 w-4 text-destructive flex-shrink-0 mt-0.5" />
) : (
<Clock className="h-4 w-4 text-orange-600 flex-shrink-0 mt-0.5" />
<Clock className="h-4 w-4 text-warning-foreground flex-shrink-0 mt-0.5" />
)}
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{deadline.title}</p>
+2 -2
View File
@@ -60,7 +60,7 @@ export function useChatStream(options: UseChatStreamOptions = {}): UseChatStream
try {
abortControllerRef.current = new AbortController()
const response = await fetch('/api/chat/stream', {
const response = await fetch('/api/extensions/ai-chat/stream', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -175,7 +175,7 @@ export function useChatStream(options: UseChatStreamOptions = {}): UseChatStream
setError(null)
try {
const response = await fetch(`/api/chat/sessions/${id}`)
const response = await fetch(`/api/extensions/ai-chat/sessions/${id}`)
if (!response.ok) {
throw new Error('Failed to load session')
}
+143 -175
View File
@@ -5,13 +5,12 @@ import Link from 'next/link'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { formatCurrency } from '@/lib/utils'
import { cn, formatCurrency } from '@/lib/utils'
import {
calculateEFTax,
calculateABTax,
getEnhancedTaxWarningStatus
} from '@/lib/tax/calculator'
import { getSchablonavdragSummary } from '@/lib/tax/schablonavdrag'
import FSkattWarningCard from '@/components/dashboard/FSkattWarningCard'
import { UpcomingDeadlinesWidget } from '@/components/calendar/UpcomingDeadlinesWidget'
import NewUserChecklist from '@/components/onboarding/NewUserChecklist'
@@ -23,11 +22,9 @@ import {
ChevronDown,
ArrowRight,
Camera,
HelpCircle,
Users,
FileText,
} from 'lucide-react'
import type { CompanySettings, EntityType, MileageEntry, SchablonavdragSettings, Deadline, ReceiptQueueSummary, OnboardingProgress } from '@/types'
import type { CompanySettings, EntityType, Deadline, ReceiptQueueSummary, OnboardingProgress } from '@/types'
interface DashboardContentProps {
firstName?: string | null
@@ -43,7 +40,6 @@ interface DashboardContentProps {
unpaidVatTotal: number
overdueInvoicesCount: number
bankBalance: number | null
mileageEntries: MileageEntry[]
deadlines: Deadline[]
receiptQueue: ReceiptQueueSummary | null
}
@@ -58,19 +54,11 @@ export default function DashboardContent({ firstName, settings, summary, onboard
const currentMonth = new Date().getMonth() + 1
const preliminaryTaxPaidYTD = preliminaryTaxMonthly * currentMonth
const schablonavdragSettings = (settings as CompanySettings & { schablonavdrag_settings?: SchablonavdragSettings })?.schablonavdrag_settings || null
const schablonavdragSummary = getSchablonavdragSummary(
schablonavdragSettings,
summary.mileageEntries,
new Date().getFullYear(),
currentMonth
)
const totalTaxableIncome = summary.ytd.net
const taxEstimate =
entityType === 'enskild_firma'
? calculateEFTax(totalTaxableIncome, preliminaryTaxPaidYTD, schablonavdragSummary, summary.unpaidVatTotal)
? calculateEFTax(totalTaxableIncome, preliminaryTaxPaidYTD, null, summary.unpaidVatTotal)
: calculateABTax(totalTaxableIncome, 0, preliminaryTaxPaidYTD, summary.unpaidVatTotal)
const taxWarning = getEnhancedTaxWarningStatus(taxEstimate, preliminaryTaxMonthly, currentMonth)
@@ -91,24 +79,19 @@ export default function DashboardContent({ firstName, settings, summary, onboard
if (summary.overdueInvoicesCount > 0) {
alertItems.push(
<Link key="overdue" href="/invoices?status=unpaid" className="group">
<Card className="h-full border-l-4 border-l-destructive hover:border-primary/30 transition-colors">
<CardContent className="p-5">
<div className="flex items-start justify-between">
<div className="flex items-start gap-3">
<div className="p-2 rounded-lg bg-destructive/10">
<Receipt className="h-4 w-4 text-destructive" />
</div>
<Card className="h-full border-l-2 border-l-destructive hover:bg-muted/20 transition-colors">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Receipt className="h-4 w-4 text-destructive flex-shrink-0" />
<div>
<p className="font-medium text-sm">Förfallna fakturor</p>
<p className="text-sm text-muted-foreground mt-0.5">
{summary.overdueInvoicesCount} förfallna
<p className="text-xs text-muted-foreground mt-0.5">
{summary.overdueInvoicesCount} st
</p>
<Badge variant="destructive" className="mt-1.5 animate-pulse">
Kräver åtgärd
</Badge>
</div>
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground group-hover:text-foreground group-hover:translate-x-0.5 transition-all" />
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground/50 group-hover:text-muted-foreground group-hover:translate-x-0.5 transition-all" />
</div>
</CardContent>
</Card>
@@ -119,21 +102,19 @@ export default function DashboardContent({ firstName, settings, summary, onboard
if (summary.unpaidInvoicesCount > 0 && summary.overdueInvoicesCount < summary.unpaidInvoicesCount) {
alertItems.push(
<Link key="unpaid" href="/invoices?status=unpaid" className="group">
<Card className="h-full border-l-4 border-l-warning hover:border-primary/30 transition-colors">
<CardContent className="p-5">
<div className="flex items-start justify-between">
<div className="flex items-start gap-3">
<div className="p-2 rounded-lg bg-warning/10">
<Receipt className="h-4 w-4 text-warning-foreground" />
</div>
<Card className="h-full border-l-2 border-l-warning hover:bg-muted/20 transition-colors">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Receipt className="h-4 w-4 text-warning-foreground flex-shrink-0" />
<div>
<p className="font-medium text-sm">Obetalda fakturor</p>
<p className="text-sm text-muted-foreground mt-0.5">
{summary.unpaidInvoicesCount - summary.overdueInvoicesCount} obetalda · {formatCurrency(summary.unpaidInvoicesTotal)}
<p className="text-xs text-muted-foreground mt-0.5">
{summary.unpaidInvoicesCount - summary.overdueInvoicesCount} st · {formatCurrency(summary.unpaidInvoicesTotal)}
</p>
</div>
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground group-hover:text-foreground group-hover:translate-x-0.5 transition-all" />
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground/50 group-hover:text-muted-foreground group-hover:translate-x-0.5 transition-all" />
</div>
</CardContent>
</Card>
@@ -144,21 +125,19 @@ export default function DashboardContent({ firstName, settings, summary, onboard
if (summary.uncategorizedCount > 0) {
alertItems.push(
<Link key="transactions" href="/transactions" className="group">
<Card className="h-full border-l-4 border-l-warning hover:border-primary/30 transition-colors">
<CardContent className="p-5">
<div className="flex items-start justify-between">
<div className="flex items-start gap-3">
<div className="p-2 rounded-lg bg-warning/10">
<ArrowLeftRight className="h-4 w-4 text-warning-foreground" />
</div>
<Card className="h-full border-l-2 border-l-warning hover:bg-muted/20 transition-colors">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<ArrowLeftRight className="h-4 w-4 text-warning-foreground flex-shrink-0" />
<div>
<p className="font-medium text-sm">Transaktioner</p>
<p className="text-sm text-muted-foreground mt-0.5">
<p className="text-xs text-muted-foreground mt-0.5">
{summary.uncategorizedCount} okategoriserade
</p>
</div>
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground group-hover:text-foreground group-hover:translate-x-0.5 transition-all" />
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground/50 group-hover:text-muted-foreground group-hover:translate-x-0.5 transition-all" />
</div>
</CardContent>
</Card>
@@ -169,23 +148,21 @@ export default function DashboardContent({ firstName, settings, summary, onboard
if (summary.receiptQueue && (summary.receiptQueue.pending_review_count > 0 || summary.receiptQueue.unmatched_receipts_count > 0)) {
alertItems.push(
<Link key="receipts" href="/receipts" className="group">
<Card className="h-full border-l-4 border-l-primary hover:border-primary/30 transition-colors">
<CardContent className="p-5">
<div className="flex items-start justify-between">
<div className="flex items-start gap-3">
<div className="p-2 rounded-lg bg-blue-500/10">
<Camera className="h-4 w-4 text-blue-500" />
</div>
<Card className="h-full border-l-2 border-l-primary hover:bg-muted/20 transition-colors">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Camera className="h-4 w-4 text-primary flex-shrink-0" />
<div>
<p className="font-medium text-sm">Kvitton</p>
<p className="text-sm text-muted-foreground mt-0.5">
<p className="text-xs text-muted-foreground mt-0.5">
{summary.receiptQueue.pending_review_count > 0
? `${summary.receiptQueue.pending_review_count} att granska`
: `${summary.receiptQueue.unmatched_receipts_count} omatchade`}
</p>
</div>
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground group-hover:text-foreground group-hover:translate-x-0.5 transition-all" />
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground/50 group-hover:text-muted-foreground group-hover:translate-x-0.5 transition-all" />
</div>
</CardContent>
</Card>
@@ -207,34 +184,26 @@ export default function DashboardContent({ firstName, settings, summary, onboard
return (
<div className="stagger-enter">
{/* Hero section - personalized greeting */}
<header className="mb-12">
<div className="flex items-center justify-between">
<div>
<h1 className="font-display text-4xl md:text-5xl font-medium tracking-tight mb-1">
{(() => {
const hour = new Date().getHours()
if (hour < 12) return 'Godmorgon'
if (hour < 18) return 'God eftermiddag'
return 'God kväll'
})()}{firstName ? ` ${firstName}` : ''}{settings?.company_name ? `, ${settings.company_name}` : ''}
</h1>
<p className="text-muted-foreground text-lg">
{summary.overdueInvoicesCount > 0
? `${summary.overdueInvoicesCount} förfallna fakturor kräver åtgärd`
: summary.deadlines.filter(d => !d.is_completed && new Date(d.due_date) <= new Date()).length > 0
? `${summary.deadlines.filter(d => !d.is_completed && new Date(d.due_date) <= new Date()).length} passerade deadlines`
: 'Allt är som det ska'}
</p>
</div>
<Link href="/help" className="hidden sm:flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors">
<HelpCircle className="h-4 w-4" />
Hjälp
</Link>
</div>
{/* Header */}
<header className="mb-10">
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">
{(() => {
const hour = new Date().getHours()
if (hour < 12) return 'Godmorgon'
if (hour < 18) return 'God eftermiddag'
return 'God kväll'
})()}{firstName ? `, ${firstName}` : ''}
</h1>
<p className="text-muted-foreground mt-1">
{summary.overdueInvoicesCount > 0
? `${summary.overdueInvoicesCount} förfallna fakturor kräver åtgärd`
: summary.deadlines.filter(d => !d.is_completed && new Date(d.due_date) <= new Date()).length > 0
? `${summary.deadlines.filter(d => !d.is_completed && new Date(d.due_date) <= new Date()).length} passerade deadlines`
: 'Allt är som det ska'}
</p>
</header>
{/* TODO section - actionable items */}
{/* Status pills */}
{(() => {
const passedDeadlines = summary.deadlines.filter(d => !d.is_completed && new Date(d.due_date) <= new Date())
const todoItems: { label: string; href: string; count: number; variant: 'destructive' | 'warning' | 'default' }[] = []
@@ -246,7 +215,7 @@ export default function DashboardContent({ firstName, settings, summary, onboard
todoItems.push({ label: 'förfallna fakturor', href: '/invoices?status=unpaid', count: summary.overdueInvoicesCount, variant: 'destructive' })
}
if (summary.uncategorizedCount > 0) {
todoItems.push({ label: 'okategoriserade transaktioner', href: '/transactions', count: summary.uncategorizedCount, variant: 'warning' })
todoItems.push({ label: 'okategoriserade', href: '/transactions', count: summary.uncategorizedCount, variant: 'warning' })
}
if (summary.receiptQueue && summary.receiptQueue.pending_review_count > 0) {
todoItems.push({ label: 'kvitton att granska', href: '/receipts', count: summary.receiptQueue.pending_review_count, variant: 'default' })
@@ -255,13 +224,13 @@ export default function DashboardContent({ firstName, settings, summary, onboard
if (todoItems.length === 0) return null
return (
<section className="mb-8">
<div className="flex flex-wrap gap-2">
<section className="mb-10">
<div className="flex flex-wrap gap-1.5">
{todoItems.map((item) => (
<Link key={item.href} href={item.href}>
<Badge
variant={item.variant === 'destructive' ? 'destructive' : item.variant === 'warning' ? 'outline' : 'secondary'}
className="px-3 py-1.5 text-sm cursor-pointer hover:opacity-80 transition-opacity"
className="px-2.5 py-1 text-xs cursor-pointer hover:opacity-80 transition-opacity"
>
{item.count} {item.label}
</Badge>
@@ -274,7 +243,7 @@ export default function DashboardContent({ firstName, settings, summary, onboard
{/* New user checklist */}
{onboardingProgress && (
<section className="mb-8">
<section className="mb-10">
<NewUserChecklist
hasCustomers={onboardingProgress.hasCustomers}
hasInvoices={onboardingProgress.hasInvoices}
@@ -284,32 +253,41 @@ export default function DashboardContent({ firstName, settings, summary, onboard
</section>
)}
{/* Upcoming deadlines - prominent placement */}
{/* Upcoming deadlines */}
{summary.deadlines && summary.deadlines.length > 0 && (
<section className="mb-8">
<section className="mb-10">
<UpcomingDeadlinesWidget deadlines={summary.deadlines} maxItems={8} />
</section>
)}
{/* Quick actions - moved up, as visual cards */}
<section id="quick-actions" className="mb-12">
<h2 className="font-display text-xl font-medium mb-6">Snabbåtgärder</h2>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-3">
{/* Quick actions */}
<section id="quick-actions" className="mb-10">
<h2 className="font-display text-lg font-medium mb-4">Snabbåtgärder</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2.5">
{quickActions.map((action) => {
const Icon = action.icon
return (
<Link key={action.href} href={action.href} className="group">
<Card className={`h-full hover-lift text-center ${action.accent ? 'bg-primary/5 border-primary/20' : ''}`}>
<CardContent className="p-4 flex flex-col items-center gap-2">
<div className={`p-3 rounded-xl ${action.accent ? 'bg-primary/10' : 'bg-muted/50'} group-hover:scale-105 transition-transform`}>
<Icon className={`h-5 w-5 ${action.accent ? 'text-primary' : 'text-muted-foreground'}`} />
</div>
<div>
<p className="text-sm font-medium">{action.label}</p>
<p className="text-xs text-muted-foreground hidden md:block">{action.desc}</p>
</div>
</CardContent>
</Card>
<div className={cn(
'flex items-center gap-3 px-4 py-3 rounded-xl border transition-colors duration-150',
action.accent
? 'border-primary/20 bg-primary/[0.03] hover:bg-primary/[0.06]'
: 'border-border/40 hover:bg-muted/30'
)}>
<div className={cn(
'p-2 rounded-lg',
action.accent ? 'bg-primary/8' : 'bg-muted/50'
)}>
<Icon className={cn(
'h-4 w-4',
action.accent ? 'text-primary' : 'text-muted-foreground'
)} />
</div>
<div className="min-w-0">
<p className="text-sm font-medium truncate">{action.label}</p>
<p className="text-xs text-muted-foreground truncate hidden md:block">{action.desc}</p>
</div>
</div>
</Link>
)
})}
@@ -317,24 +295,24 @@ export default function DashboardContent({ firstName, settings, summary, onboard
</section>
{/* F-skatt warning */}
<section id="fskatt-section" className="mb-8">
<section id="fskatt-section" className="mb-10">
<FSkattWarningCard
warningStatus={taxWarning}
onAdjustClick={() => { window.location.href = '/settings' }}
/>
</section>
{/* Alerts section - with urgency indicators and limit */}
{/* Alerts section */}
{alertItems.length > 0 && (
<section id="alerts-section" className="mb-16">
<h2 className="font-display text-xl font-medium mb-6">Att hantera</h2>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<section id="alerts-section" className="mb-10">
<h2 className="font-display text-lg font-medium mb-4">Att hantera</h2>
<div className="grid gap-3 md:grid-cols-2">
{visibleAlerts}
</div>
{hasMoreAlerts && !showAllAlerts && (
<button
onClick={() => setShowAllAlerts(true)}
className="mt-4 text-sm text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1"
className="mt-3 text-sm text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1"
>
Visa alla ({alertItems.length})
<ChevronDown className="h-3 w-3" />
@@ -345,84 +323,74 @@ export default function DashboardContent({ firstName, settings, summary, onboard
{/* Uncategorized transactions warning */}
{summary.uncategorizedCount > 0 && (summary.uncategorizedIncome > 0 || summary.uncategorizedExpenses > 0) && (
<section className="mb-8">
<Link href="/transactions?tab=uncategorized">
<Card className="border-warning/50 bg-warning/5 hover:border-warning transition-colors">
<CardContent className="p-5">
<div className="flex items-start gap-3">
<div className="p-2 rounded-lg bg-warning/10">
<ArrowLeftRight className="h-4 w-4 text-warning-foreground" />
</div>
<div className="flex-1">
<p className="font-medium text-sm">
{summary.uncategorizedCount} okategoriserade transaktioner påverkar ditt resultat
</p>
<p className="text-sm text-muted-foreground mt-1">
{summary.uncategorizedIncome > 0 && (
<span>{formatCurrency(summary.uncategorizedIncome)} i potentiella intäkter</span>
)}
{summary.uncategorizedIncome > 0 && summary.uncategorizedExpenses > 0 && ' och '}
{summary.uncategorizedExpenses > 0 && (
<span>{formatCurrency(summary.uncategorizedExpenses)} i potentiella kostnader</span>
)}
{' '}visas inte i resultatet. Kategorisera dem för korrekt redovisning.
</p>
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground flex-shrink-0 mt-1" />
</div>
</CardContent>
</Card>
<section className="mb-10">
<Link href="/transactions?tab=uncategorized" className="group">
<div className="flex items-start gap-3 px-4 py-3.5 rounded-xl border border-warning/30 bg-warning/[0.03] hover:bg-warning/[0.06] transition-colors">
<ArrowLeftRight className="h-4 w-4 text-warning-foreground flex-shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<p className="font-medium text-sm">
{summary.uncategorizedCount} okategoriserade transaktioner
</p>
<p className="text-xs text-muted-foreground mt-0.5">
{summary.uncategorizedIncome > 0 && (
<span>{formatCurrency(summary.uncategorizedIncome)} intäkter</span>
)}
{summary.uncategorizedIncome > 0 && summary.uncategorizedExpenses > 0 && ', '}
{summary.uncategorizedExpenses > 0 && (
<span>{formatCurrency(summary.uncategorizedExpenses)} kostnader</span>
)}
{' '}saknas i resultatet
</p>
</div>
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground/50 group-hover:text-muted-foreground flex-shrink-0 mt-0.5 transition-colors" />
</div>
</Link>
</section>
)}
{/* Income/Expenses - Side by side editorial */}
<section className="mb-16">
<h2 className="font-display text-xl font-medium mb-6">Resultat</h2>
<div className="grid md:grid-cols-2 gap-6">
<Card className="hover-lift">
<CardContent className="p-6">
<div className="flex items-center gap-2 mb-4">
<TrendingUp className="h-4 w-4 text-success" />
{/* Income/Expenses */}
<section className="mb-10">
<h2 className="font-display text-lg font-medium mb-4">Resultat</h2>
<div className="grid md:grid-cols-2 gap-3">
<Card>
<CardContent className="p-5">
<div className="flex items-center gap-2 mb-3">
<TrendingUp className="h-3.5 w-3.5 text-success" />
<span className="text-sm text-muted-foreground">Intäkter</span>
</div>
<div className="space-y-4">
<div>
<p className="text-xs text-muted-foreground uppercase tracking-wide mb-1">Denna månad</p>
<p className="font-display text-3xl font-medium tabular-nums">
{formatLargeNumber(summary.mtd.income)}
<span className="text-lg ml-1 text-muted-foreground font-normal">kr</span>
</p>
</div>
<div className="pt-4 border-t border-border/50">
<p className="text-xs text-muted-foreground uppercase tracking-wide mb-1">I år totalt</p>
<p className="font-display text-xl tabular-nums">
{formatCurrency(summary.ytd.income)}
</p>
<div>
<p className="font-display text-2xl font-medium tabular-nums leading-tight">
{formatLargeNumber(summary.mtd.income)}
<span className="text-base ml-1 text-muted-foreground font-normal">kr</span>
</p>
<p className="text-xs text-muted-foreground mt-0.5">denna månad</p>
</div>
<div className="mt-4 pt-3 border-t border-border/30">
<div className="flex items-baseline justify-between">
<p className="text-xs text-muted-foreground">I år</p>
<p className="text-sm font-medium tabular-nums">{formatCurrency(summary.ytd.income)}</p>
</div>
</div>
</CardContent>
</Card>
<Card className="hover-lift">
<CardContent className="p-6">
<div className="flex items-center gap-2 mb-4">
<TrendingDown className="h-4 w-4 text-destructive" />
<Card>
<CardContent className="p-5">
<div className="flex items-center gap-2 mb-3">
<TrendingDown className="h-3.5 w-3.5 text-destructive" />
<span className="text-sm text-muted-foreground">Kostnader</span>
</div>
<div className="space-y-4">
<div>
<p className="text-xs text-muted-foreground uppercase tracking-wide mb-1">Denna månad</p>
<p className="font-display text-3xl font-medium tabular-nums">
{formatLargeNumber(summary.mtd.expenses)}
<span className="text-lg ml-1 text-muted-foreground font-normal">kr</span>
</p>
</div>
<div className="pt-4 border-t border-border/50">
<p className="text-xs text-muted-foreground uppercase tracking-wide mb-1">I år totalt</p>
<p className="font-display text-xl tabular-nums">
{formatCurrency(summary.ytd.expenses)}
</p>
<div>
<p className="font-display text-2xl font-medium tabular-nums leading-tight">
{formatLargeNumber(summary.mtd.expenses)}
<span className="text-base ml-1 text-muted-foreground font-normal">kr</span>
</p>
<p className="text-xs text-muted-foreground mt-0.5">denna månad</p>
</div>
<div className="mt-4 pt-3 border-t border-border/30">
<div className="flex items-baseline justify-between">
<p className="text-xs text-muted-foreground">I år</p>
<p className="text-sm font-medium tabular-nums">{formatCurrency(summary.ytd.expenses)}</p>
</div>
</div>
</CardContent>
+36 -134
View File
@@ -15,16 +15,12 @@ import {
BarChart3,
Settings,
LogOut,
Calculator,
Upload,
Calendar,
Camera,
Menu,
X,
HelpCircle,
ChevronDown,
ChevronUp,
Building2,
} from 'lucide-react'
import type { EntityType } from '@/types'
@@ -48,10 +44,7 @@ const navItems: NavItem[] = [
{ href: '/invoices', label: 'Fakturor', icon: Receipt, group: 'finans' },
{ href: '/customers', label: 'Kunder', icon: Users, group: 'finans' },
{ href: '/transactions', label: 'Transaktioner', icon: ArrowLeftRight, group: 'finans' },
{ href: '/receipts', label: 'Kvitton', icon: Camera, group: 'finans' },
{ href: '/deductions', label: 'Avdrag', icon: Calculator, group: 'finans' },
{ href: '/bookkeeping', label: 'Bokföring', icon: BookOpen, group: 'finans' },
{ href: '/modules', label: 'Företagsmoduler', icon: Building2, group: 'moduler' },
{ href: '/import', label: 'Importera', icon: Upload, group: 'övrigt' },
{ href: '/reports', label: 'Rapporter', icon: BarChart3, group: 'övrigt' },
{ href: '/help', label: 'Hjälp', icon: HelpCircle, group: 'övrigt' },
@@ -61,7 +54,6 @@ const navItems: NavItem[] = [
const groupLabels: Record<string, string> = {
main: 'Huvudmeny',
finans: 'Finans',
moduler: 'Företagsmoduler',
övrigt: 'Övrigt',
}
@@ -93,40 +85,35 @@ export default function DashboardNav({ companyName, entityType }: DashboardNavPr
const mainItems = filteredItems.filter(i => i.group === 'main')
const finansItems = filteredItems.filter(i => i.group === 'finans')
const modulerItems = filteredItems.filter(i => i.group === 'moduler')
const övrigtItems = filteredItems.filter(i => i.group === 'övrigt')
const mobileNavItems = [
{ href: '/', label: 'Översikt', icon: LayoutDashboard },
{ href: '/invoices', label: 'Fakturor', icon: Receipt },
{ href: '/receipts/scan', label: 'Skanna', icon: Camera, isScan: true },
{ href: '/transactions', label: 'Transaktioner', icon: ArrowLeftRight },
]
return (
<>
{/* Desktop sidebar */}
<aside className="hidden md:fixed md:inset-y-0 md:flex md:w-60 md:flex-col">
<div className="flex min-h-0 flex-1 flex-col border-r border-border/40 bg-card">
<div className="flex flex-1 flex-col overflow-y-auto pt-8 pb-4">
{/* Logo */}
<div className="px-6 mb-10">
<h1 className="font-display text-lg font-medium tracking-tight truncate">
<aside className="hidden md:fixed md:inset-y-0 md:flex md:w-[232px] md:flex-col">
<div className="flex min-h-0 flex-1 flex-col border-r border-border/30 bg-card/80">
<div className="flex flex-1 flex-col overflow-y-auto pt-7 pb-4">
{/* Company name */}
<div className="px-5 mb-8">
<p className="text-[13px] font-semibold text-foreground truncate tracking-[-0.01em]">
{companyName}
</h1>
<p className="text-xs text-muted-foreground mt-0.5 tracking-wide uppercase">
Ekonomi
</p>
</div>
{/* Navigation with group headers */}
<nav className="flex-1 px-3" aria-label="Huvudnavigation">
{/* Huvudmeny group */}
<div className="mb-4">
<p className="px-3 mb-1 text-[11px] font-medium text-muted-foreground uppercase tracking-wider">
<div className="mb-6">
<p className="px-3 mb-1.5 text-[10px] font-semibold text-muted-foreground/70 uppercase tracking-[0.08em]">
{groupLabels.main}
</p>
<div className="space-y-0.5">
<div className="space-y-px">
{mainItems.map((item) => {
const Icon = item.icon
const active = isActive(item.href)
@@ -135,15 +122,15 @@ export default function DashboardNav({ companyName, entityType }: DashboardNavPr
key={item.href}
href={item.href}
className={cn(
'group flex items-center px-3 py-2 text-sm transition-all duration-200 rounded-lg',
'group flex items-center px-3 py-[7px] text-[13px] transition-colors duration-150 rounded-lg',
active
? 'bg-primary/10 text-primary font-medium'
: 'text-muted-foreground hover:text-foreground hover:bg-secondary/50'
? 'bg-primary/8 text-foreground font-medium'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/40'
)}
>
<Icon className={cn(
"mr-3 h-4 w-4 flex-shrink-0",
active ? "text-primary" : "text-muted-foreground group-hover:text-foreground"
"mr-2.5 h-[15px] w-[15px] flex-shrink-0",
active ? "text-primary" : "text-muted-foreground/70 group-hover:text-muted-foreground"
)} />
{item.label}
</Link>
@@ -152,15 +139,12 @@ export default function DashboardNav({ companyName, entityType }: DashboardNavPr
</div>
</div>
{/* Separator */}
<div className="border-t border-border/40 mx-3 mb-4" />
{/* Finans group */}
<div className="mb-4">
<p className="px-3 mb-1 text-[11px] font-medium text-muted-foreground uppercase tracking-wider">
<div className="mb-6">
<p className="px-3 mb-1.5 text-[10px] font-semibold text-muted-foreground/70 uppercase tracking-[0.08em]">
{groupLabels.finans}
</p>
<div className="space-y-0.5">
<div className="space-y-px">
{finansItems.map((item) => {
const Icon = item.icon
const active = isActive(item.href)
@@ -169,15 +153,15 @@ export default function DashboardNav({ companyName, entityType }: DashboardNavPr
key={item.href}
href={item.href}
className={cn(
'group flex items-center px-3 py-2 text-sm transition-all duration-200 rounded-lg',
'group flex items-center px-3 py-[7px] text-[13px] transition-colors duration-150 rounded-lg',
active
? 'bg-primary/10 text-primary font-medium'
: 'text-muted-foreground hover:text-foreground hover:bg-secondary/50'
? 'bg-primary/8 text-foreground font-medium'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/40'
)}
>
<Icon className={cn(
"mr-3 h-4 w-4 flex-shrink-0",
active ? "text-primary" : "text-muted-foreground group-hover:text-foreground"
"mr-2.5 h-[15px] w-[15px] flex-shrink-0",
active ? "text-primary" : "text-muted-foreground/70 group-hover:text-muted-foreground"
)} />
{item.label}
</Link>
@@ -186,58 +170,20 @@ export default function DashboardNav({ companyName, entityType }: DashboardNavPr
</div>
</div>
{/* Separator */}
<div className="border-t border-border/40 mx-3 mb-4" />
{/* Moduler group */}
<div className="mb-4">
<p className="px-3 mb-1 text-[11px] font-medium text-muted-foreground uppercase tracking-wider">
{groupLabels.moduler}
</p>
<div className="space-y-0.5">
{modulerItems.map((item) => {
const Icon = item.icon
const active = isActive(item.href)
return (
<Link
key={item.href}
href={item.href}
className={cn(
'group flex items-center px-3 py-2 text-sm transition-all duration-200 rounded-lg',
active
? 'bg-primary/10 text-primary font-medium'
: 'text-muted-foreground hover:text-foreground hover:bg-secondary/50'
)}
>
<Icon className={cn(
"mr-3 h-4 w-4 flex-shrink-0",
active ? "text-primary" : "text-muted-foreground group-hover:text-foreground"
)} />
{item.label}
</Link>
)
})}
</div>
</div>
{/* Separator */}
<div className="border-t border-border/40 mx-3 mb-4" />
{/* Övrigt group - collapsible */}
<div className="mb-4">
<button
onClick={() => setIsOvrigtExpanded(!isOvrigtExpanded)}
className="w-full flex items-center justify-between px-3 mb-1 text-[11px] font-medium text-muted-foreground uppercase tracking-wider hover:text-foreground transition-colors"
className="w-full flex items-center justify-between px-3 mb-1.5 text-[10px] font-semibold text-muted-foreground/70 uppercase tracking-[0.08em] hover:text-muted-foreground transition-colors"
>
<span>{groupLabels.övrigt}</span>
{isOvrigtExpanded ? (
<ChevronUp className="h-3 w-3" />
) : (
<ChevronDown className="h-3 w-3" />
)}
<ChevronDown className={cn(
"h-3 w-3 transition-transform duration-200",
isOvrigtExpanded && "rotate-180"
)} />
</button>
{isOvrigtExpanded && (
<div className="space-y-0.5 animate-fade-in">
<div className="space-y-px animate-fade-in">
{övrigtItems.map((item) => {
const Icon = item.icon
const active = isActive(item.href)
@@ -246,15 +192,15 @@ export default function DashboardNav({ companyName, entityType }: DashboardNavPr
key={item.href}
href={item.href}
className={cn(
'group flex items-center px-3 py-2 text-sm transition-all duration-200 rounded-lg',
'group flex items-center px-3 py-[7px] text-[13px] transition-colors duration-150 rounded-lg',
active
? 'bg-primary/10 text-primary font-medium'
: 'text-muted-foreground hover:text-foreground hover:bg-secondary/50'
? 'bg-primary/8 text-foreground font-medium'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/40'
)}
>
<Icon className={cn(
"mr-3 h-4 w-4 flex-shrink-0",
active ? "text-primary" : "text-muted-foreground group-hover:text-foreground"
"mr-2.5 h-[15px] w-[15px] flex-shrink-0",
active ? "text-primary" : "text-muted-foreground/70 group-hover:text-muted-foreground"
)} />
{item.label}
</Link>
@@ -267,13 +213,13 @@ export default function DashboardNav({ companyName, entityType }: DashboardNavPr
</div>
{/* Logout button */}
<div className="flex-shrink-0 p-4 border-t border-border/40">
<div className="flex-shrink-0 px-3 py-3 border-t border-border/20">
<Button
variant="ghost"
className="w-full justify-start text-muted-foreground hover:text-foreground text-sm"
className="w-full justify-start text-muted-foreground hover:text-foreground text-[13px] h-9 px-3"
onClick={handleLogout}
>
<LogOut className="mr-3 h-4 w-4" />
<LogOut className="mr-2.5 h-[15px] w-[15px]" />
Logga ut
</Button>
</div>
@@ -286,23 +232,6 @@ export default function DashboardNav({ companyName, entityType }: DashboardNavPr
{mobileNavItems.map((item) => {
const Icon = item.icon
const active = isActive(item.href)
const isScan = 'isScan' in item && item.isScan
if (isScan) {
return (
<Link
key={item.href}
href={item.href}
aria-label="Skanna kvitto"
className="flex flex-col items-center justify-center flex-1 h-full -mt-3"
>
<div className="flex items-center justify-center w-12 h-12 rounded-full bg-accent text-accent-foreground shadow-md">
<Icon className="h-6 w-6" />
</div>
<span className="text-[10px] mt-0.5 text-accent font-medium">{item.label}</span>
</Link>
)
}
return (
<Link
@@ -424,33 +353,6 @@ export default function DashboardNav({ companyName, entityType }: DashboardNavPr
})}
</div>
{/* Moduler section */}
<div className="mb-4">
<p className="px-3 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">
Företagsmoduler
</p>
{modulerItems.map((item) => {
const Icon = item.icon
const active = isActive(item.href)
return (
<Link
key={item.href}
href={item.href}
onClick={closeMobileMenu}
className={cn(
'flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors',
active
? 'bg-primary/10 text-primary font-medium'
: 'text-muted-foreground hover:bg-secondary/50 hover:text-foreground'
)}
>
<Icon className="h-5 w-5" />
{item.label}
</Link>
)
})}
</div>
{/* Other section */}
<div className="mb-4">
<p className="px-3 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">
+3 -3
View File
@@ -44,9 +44,9 @@ export default function FSkattWarningCard({
},
info: {
icon: Info,
iconColor: 'text-blue-500',
bgColor: 'bg-blue-50 dark:bg-blue-950/20',
borderColor: 'border-blue-200 dark:border-blue-800',
iconColor: 'text-primary',
bgColor: 'bg-primary/5',
borderColor: 'border-primary/20',
badgeVariant: 'outline' as const,
},
warning: {
-87
View File
@@ -1,87 +0,0 @@
'use client'
import { useEffect, useState, useTransition } from 'react'
import { createClient } from '@/lib/supabase/client'
import { Switch } from '@/components/ui/switch'
import { Loader2 } from 'lucide-react'
interface ModuleToggleProps {
sectorSlug: string
moduleSlug: string
}
export function ModuleToggle({ sectorSlug, moduleSlug }: ModuleToggleProps) {
const [enabled, setEnabled] = useState(false)
const [loading, setLoading] = useState(true)
const [isPending, startTransition] = useTransition()
const supabase = createClient()
useEffect(() => {
async function fetchToggle() {
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
setLoading(false)
return
}
const { data } = await supabase
.from('module_toggles')
.select('enabled')
.eq('user_id', user.id)
.eq('sector_slug', sectorSlug)
.eq('module_slug', moduleSlug)
.maybeSingle()
setEnabled(data?.enabled ?? false)
setLoading(false)
}
fetchToggle()
}, [sectorSlug, moduleSlug, supabase])
function handleToggle(checked: boolean) {
setEnabled(checked)
startTransition(async () => {
const { data: { user } } = await supabase.auth.getUser()
if (!user) return
await supabase
.from('module_toggles')
.upsert(
{
user_id: user.id,
sector_slug: sectorSlug,
module_slug: moduleSlug,
enabled: checked,
},
{ onConflict: 'user_id,sector_slug,module_slug' }
)
})
}
if (loading) {
return (
<div className="flex items-center gap-3 rounded-xl border border-border bg-card px-5 py-4">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
<span className="text-sm text-muted-foreground">Laddar…</span>
</div>
)
}
return (
<label className="flex items-center justify-between gap-4 rounded-xl border border-border bg-card px-5 py-4 cursor-pointer transition-colors hover:border-primary/20">
<div>
<p className="text-sm font-medium text-foreground">Aktivera modul</p>
<p className="text-xs text-muted-foreground mt-0.5">
{enabled ? 'Modulen är aktiverad för ditt konto' : 'Aktivera för att använda modulen'}
</p>
</div>
<Switch
checked={enabled}
onCheckedChange={handleToggle}
disabled={isPending}
/>
</label>
)
}
-211
View File
@@ -1,211 +0,0 @@
'use client'
import { useState, useEffect } from 'react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { useToast } from '@/components/ui/use-toast'
import { Bell, BellOff, Loader2 } from 'lucide-react'
interface PushPromptProps {
onSubscribed?: () => void
onDismissed?: () => void
}
export function PushPrompt({ onSubscribed, onDismissed }: PushPromptProps) {
const { toast } = useToast()
const [isOpen, setIsOpen] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [isSupported, setIsSupported] = useState(false)
const [isSubscribed, setIsSubscribed] = useState(false)
useEffect(() => {
// Check if push notifications are supported
if ('serviceWorker' in navigator && 'PushManager' in window) {
setIsSupported(true)
checkSubscription()
}
}, [])
const checkSubscription = async () => {
try {
const registration = await navigator.serviceWorker.ready
const subscription = await registration.pushManager.getSubscription()
setIsSubscribed(!!subscription)
} catch (error) {
console.error('Error checking push subscription:', error)
}
}
const requestPermission = async () => {
if (!isSupported) return
setIsLoading(true)
try {
// Request notification permission
const permission = await Notification.requestPermission()
if (permission !== 'granted') {
toast({
title: 'Aviseringar avslagna',
description: 'Du kan aktivera aviseringar senare i inställningarna.',
})
setIsOpen(false)
onDismissed?.()
return
}
// Get VAPID public key
const vapidResponse = await fetch('/api/push/subscribe')
const { vapidPublicKey } = await vapidResponse.json()
if (!vapidPublicKey) {
throw new Error('Push notifications not configured')
}
// Subscribe to push notifications
const registration = await navigator.serviceWorker.ready
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
})
// Send subscription to server
const response = await fetch('/api/push/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(subscription.toJSON()),
})
if (!response.ok) {
throw new Error('Failed to save subscription')
}
setIsSubscribed(true)
setIsOpen(false)
toast({
title: 'Aviseringar aktiverade',
description: 'Du kommer få påminnelser om viktiga deadlines.',
})
onSubscribed?.()
} catch (error) {
console.error('Error subscribing to push:', error)
toast({
title: 'Fel',
description: 'Kunde inte aktivera aviseringar. Försök igen senare.',
variant: 'destructive',
})
} finally {
setIsLoading(false)
}
}
const handleDismiss = () => {
setIsOpen(false)
// Store dismissal in localStorage to not show again
localStorage.setItem('push-prompt-dismissed', 'true')
onDismissed?.()
}
// Don't show if not supported, already subscribed, or previously dismissed
if (!isSupported || isSubscribed) {
return null
}
// Show dialog after a delay or when triggered
return (
<>
{/* Floating button to show prompt */}
<Button
variant="outline"
size="sm"
className="fixed bottom-4 right-4 gap-2 shadow-lg z-50"
onClick={() => setIsOpen(true)}
>
<Bell className="h-4 w-4" />
Aktivera aviseringar
</Button>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Bell className="h-5 w-5 text-primary" />
Aktivera push-aviseringar
</DialogTitle>
<DialogDescription>
Få påminnelser om viktiga skattedeadlines, fakturor som förfaller och
kampanjleveranser direkt i din webbläsare.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="grid gap-2 text-sm">
<div className="flex items-center gap-2">
<div className="w-2 h-2 bg-primary rounded-full" />
<span>Skattedeadlines (7 och 1 dag innan)</span>
</div>
<div className="flex items-center gap-2">
<div className="w-2 h-2 bg-primary rounded-full" />
<span>Fakturor som förfaller</span>
</div>
<div className="flex items-center gap-2">
<div className="w-2 h-2 bg-primary rounded-full" />
<span>Kampanjleveranser</span>
</div>
</div>
<p className="text-xs text-muted-foreground">
Du kan när som helst stänga av aviseringar i inställningarna.
Vi respekterar tysta timmar (21:00-08:00).
</p>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="ghost" onClick={handleDismiss}>
Inte nu
</Button>
<Button onClick={requestPermission} disabled={isLoading}>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Aktiverar...
</>
) : (
<>
<Bell className="mr-2 h-4 w-4" />
Aktivera
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}
/**
* Convert base64 VAPID key to Uint8Array for subscription
*/
function urlBase64ToUint8Array(base64String: string): ArrayBuffer {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
const rawData = window.atob(base64)
const outputArray = new Uint8Array(rawData.length)
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i)
}
return outputArray.buffer
}
-336
View File
@@ -1,336 +0,0 @@
'use client'
import { useState, useRef, useCallback, useEffect } from 'react'
import { Button } from '@/components/ui/button'
import { Camera, X, RotateCcw, Check, Loader2, SunMedium, Move, Focus } from 'lucide-react'
import type { CameraQualityFeedback } from '@/types'
interface ReceiptCameraProps {
onCapture: (imageData: string, mimeType: string) => Promise<void>
onClose: () => void
}
export default function ReceiptCamera({ onCapture, onClose }: ReceiptCameraProps) {
const videoRef = useRef<HTMLVideoElement>(null)
const canvasRef = useRef<HTMLCanvasElement>(null)
const streamRef = useRef<MediaStream | null>(null)
const [isStreaming, setIsStreaming] = useState(false)
const [capturedImage, setCapturedImage] = useState<string | null>(null)
const [isProcessing, setIsProcessing] = useState(false)
const [error, setError] = useState<string | null>(null)
const [quality, setQuality] = useState<CameraQualityFeedback>({
lightingOk: false,
distanceOk: false,
focusOk: false,
readyToCapture: false,
})
// Start camera stream
const startCamera = useCallback(async () => {
try {
setError(null)
const constraints: MediaStreamConstraints = {
video: {
facingMode: 'environment', // Use back camera on mobile
width: { ideal: 1920 },
height: { ideal: 1080 },
},
}
const stream = await navigator.mediaDevices.getUserMedia(constraints)
streamRef.current = stream
if (videoRef.current) {
videoRef.current.srcObject = stream
try {
await videoRef.current.play()
setIsStreaming(true)
} catch (playErr) {
// Ignore AbortError - this happens when stream is interrupted (e.g., component unmount)
if (playErr instanceof Error && playErr.name === 'AbortError') {
console.log('Video play interrupted - this is normal during navigation')
return
}
throw playErr
}
}
} catch (err) {
console.error('Camera access error:', err)
// Don't show error for AbortError
if (err instanceof Error && err.name === 'AbortError') return
setError('Kunde inte komma åt kameran. Kontrollera att du har gett tillåtelse.')
}
}, [])
// Stop camera stream
const stopCamera = useCallback(() => {
if (streamRef.current) {
streamRef.current.getTracks().forEach((track) => track.stop())
streamRef.current = null
}
setIsStreaming(false)
}, [])
// Analyze video frame for quality feedback
const analyzeQuality = useCallback(() => {
if (!videoRef.current || !canvasRef.current || !isStreaming) return
const video = videoRef.current
const canvas = canvasRef.current
const ctx = canvas.getContext('2d')
if (!ctx) return
// Set canvas size to match video
canvas.width = video.videoWidth / 4 // Downsample for analysis
canvas.height = video.videoHeight / 4
// Draw current frame
ctx.drawImage(video, 0, 0, canvas.width, canvas.height)
// Get image data for analysis
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
const data = imageData.data
// Calculate average brightness
let totalBrightness = 0
for (let i = 0; i < data.length; i += 4) {
const r = data[i]
const g = data[i + 1]
const b = data[i + 2]
totalBrightness += (r + g + b) / 3
}
const avgBrightness = totalBrightness / (data.length / 4)
// Calculate contrast (standard deviation of brightness)
let sumSquares = 0
for (let i = 0; i < data.length; i += 4) {
const r = data[i]
const g = data[i + 1]
const b = data[i + 2]
const brightness = (r + g + b) / 3
sumSquares += Math.pow(brightness - avgBrightness, 2)
}
const contrast = Math.sqrt(sumSquares / (data.length / 4))
// Quality assessment
const lightingOk = avgBrightness > 80 && avgBrightness < 200
const focusOk = contrast > 30 // Higher contrast suggests better focus
const distanceOk = true // Would need edge detection for proper distance check
const newQuality: CameraQualityFeedback = {
lightingOk,
distanceOk,
focusOk,
readyToCapture: lightingOk && focusOk && distanceOk,
}
if (!lightingOk) {
newQuality.message = avgBrightness < 80 ? 'För mörkt - öka ljuset' : 'För ljust - minska ljuset'
} else if (!focusOk) {
newQuality.message = 'Försök hålla kameran stilla'
}
setQuality(newQuality)
}, [isStreaming])
// Quality analysis loop
useEffect(() => {
if (!isStreaming) return
const interval = setInterval(analyzeQuality, 500)
return () => clearInterval(interval)
}, [isStreaming, analyzeQuality])
// Start camera on mount
useEffect(() => {
startCamera()
return () => stopCamera()
}, [startCamera, stopCamera])
// Capture image
const captureImage = useCallback(() => {
if (!videoRef.current || !canvasRef.current) return
const video = videoRef.current
const canvas = canvasRef.current
const ctx = canvas.getContext('2d')
if (!ctx) return
// Set canvas to full resolution
canvas.width = video.videoWidth
canvas.height = video.videoHeight
// Draw the current video frame
ctx.drawImage(video, 0, 0)
// Convert to base64
const imageData = canvas.toDataURL('image/jpeg', 0.9)
setCapturedImage(imageData)
stopCamera()
}, [stopCamera])
// Retake photo
const retake = useCallback(() => {
setCapturedImage(null)
startCamera()
}, [startCamera])
// Confirm and upload
const confirmCapture = async () => {
if (!capturedImage) return
setIsProcessing(true)
try {
// Extract base64 data (remove data:image/jpeg;base64, prefix)
const base64Data = capturedImage.split(',')[1]
await onCapture(base64Data, 'image/jpeg')
} catch (err) {
console.error('Upload error:', err)
setError('Kunde inte ladda upp bilden. Försök igen.')
setIsProcessing(false)
}
}
// Render captured image review
if (capturedImage) {
return (
<div className="fixed inset-0 bg-black z-[100] overflow-hidden">
{/* Header - fixed at top */}
<div className="absolute top-0 left-0 right-0 z-20 flex items-center justify-between p-4 bg-gradient-to-b from-black/70 to-transparent">
<Button variant="ghost" size="icon" onClick={retake} disabled={isProcessing}>
<RotateCcw className="h-5 w-5 text-white" />
</Button>
<h2 className="text-white font-medium">Granska bild</h2>
<Button variant="ghost" size="icon" onClick={onClose} disabled={isProcessing}>
<X className="h-5 w-5 text-white" />
</Button>
</div>
{/* Image preview - centered */}
<div className="absolute inset-0 flex items-center justify-center p-4 pt-20 pb-32">
<img
src={capturedImage}
alt="Captured receipt"
className="max-h-full max-w-full object-contain rounded-lg"
/>
</div>
{/* Action buttons - fixed at bottom */}
<div
className="absolute bottom-0 left-0 right-0 z-20 flex gap-4 justify-center bg-gradient-to-t from-black/70 to-transparent pt-8 pb-8 px-4"
style={{ paddingBottom: 'max(2rem, env(safe-area-inset-bottom, 2rem))' }}
>
<Button variant="outline" onClick={retake} disabled={isProcessing} className="bg-white/10 border-white text-white hover:bg-white/20">
<RotateCcw className="mr-2 h-4 w-4" />
Ta om
</Button>
<Button onClick={confirmCapture} disabled={isProcessing} className="bg-white text-black hover:bg-white/90">
{isProcessing ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Analyserar...
</>
) : (
<>
<Check className="mr-2 h-4 w-4" />
Använd bild
</>
)}
</Button>
</div>
</div>
)
}
// Render camera view
return (
<div className="fixed inset-0 bg-black z-[100] overflow-hidden">
{/* Header - fixed at top */}
<div className="absolute top-0 left-0 right-0 z-20 flex items-center justify-between p-4 bg-gradient-to-b from-black/70 to-transparent">
<div className="w-10" />
<h2 className="text-white font-medium">Skanna kvitto</h2>
<Button variant="ghost" size="icon" onClick={onClose}>
<X className="h-5 w-5 text-white" />
</Button>
</div>
{/* Video preview - fills the screen */}
<video
ref={videoRef}
autoPlay
playsInline
muted
className="absolute inset-0 h-full w-full object-cover"
/>
{/* Quality feedback overlay */}
{isStreaming && (
<div className="absolute bottom-36 left-0 right-0 flex justify-center z-10">
<div className="bg-black/70 rounded-full px-4 py-2 flex items-center gap-4">
<div className={`flex items-center gap-1 ${quality.lightingOk ? 'text-green-400' : 'text-yellow-400'}`}>
<SunMedium className="h-4 w-4" />
<span className="text-xs">Ljus</span>
</div>
<div className={`flex items-center gap-1 ${quality.distanceOk ? 'text-green-400' : 'text-yellow-400'}`}>
<Move className="h-4 w-4" />
<span className="text-xs">Avstånd</span>
</div>
<div className={`flex items-center gap-1 ${quality.focusOk ? 'text-green-400' : 'text-yellow-400'}`}>
<Focus className="h-4 w-4" />
<span className="text-xs">Fokus</span>
</div>
</div>
</div>
)}
{/* Quality message */}
{quality.message && (
<div className="absolute top-20 left-0 right-0 flex justify-center z-10">
<div className="bg-yellow-500/90 text-black px-4 py-2 rounded-full text-sm font-medium">
{quality.message}
</div>
</div>
)}
{/* Error message */}
{error && (
<div className="absolute inset-0 flex items-center justify-center bg-black/80 z-30">
<div className="text-center p-4">
<p className="text-white mb-4">{error}</p>
<Button onClick={startCamera}>Försök igen</Button>
</div>
</div>
)}
{/* Guide overlay */}
{isStreaming && (
<div className="absolute inset-0 pointer-events-none z-10">
<div className="absolute top-20 left-8 right-8 bottom-36 border-2 border-white/30 rounded-lg">
<div className="absolute top-0 left-0 w-8 h-8 border-t-2 border-l-2 border-white rounded-tl-lg" />
<div className="absolute top-0 right-0 w-8 h-8 border-t-2 border-r-2 border-white rounded-tr-lg" />
<div className="absolute bottom-0 left-0 w-8 h-8 border-b-2 border-l-2 border-white rounded-bl-lg" />
<div className="absolute bottom-0 right-0 w-8 h-8 border-b-2 border-r-2 border-white rounded-br-lg" />
</div>
</div>
)}
{/* Capture button - fixed at bottom */}
<div
className="absolute bottom-0 left-0 right-0 z-20 flex justify-center bg-gradient-to-t from-black/70 to-transparent pt-8 pb-8"
style={{ paddingBottom: 'max(2rem, env(safe-area-inset-bottom, 2rem))' }}
>
<button
onClick={captureImage}
disabled={!isStreaming}
className="h-20 w-20 rounded-full border-4 border-white flex items-center justify-center bg-white/20 active:bg-white/40 disabled:opacity-50 transition-colors shadow-2xl backdrop-blur-sm"
>
<Camera className="h-8 w-8 text-white" />
</button>
</div>
{/* Hidden canvas for image processing */}
<canvas ref={canvasRef} className="hidden" />
</div>
)
}
-169
View File
@@ -1,169 +0,0 @@
'use client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Flame, Receipt, CreditCard, Camera, ArrowRight } from 'lucide-react'
import type { ReceiptQueueSummary } from '@/types'
interface ReceiptDashboardProps {
summary: ReceiptQueueSummary
onScanReceipt: () => void
onViewReceiptQueue: () => void
onViewTransactionQueue: () => void
}
export default function ReceiptDashboard({
summary,
onScanReceipt,
onViewReceiptQueue,
onViewTransactionQueue,
}: ReceiptDashboardProps) {
const hasWork =
summary.unmatched_receipts_count > 0 ||
summary.unmatched_transactions_count > 0 ||
summary.pending_review_count > 0
return (
<div className="space-y-4">
{/* Quick actions */}
<div className="grid grid-cols-1 gap-3">
<Button
variant="default"
className="h-auto py-4 flex-col gap-2"
onClick={onScanReceipt}
>
<Camera className="h-6 w-6" />
<span>Skanna kvitto</span>
</Button>
</div>
{/* Streak counter */}
{summary.streak_count > 0 && (
<Card className="bg-gradient-to-r from-orange-500/10 to-yellow-500/10 border-orange-500/20">
<CardContent className="pt-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="h-12 w-12 rounded-full bg-orange-500/20 flex items-center justify-center">
<Flame className="h-6 w-6 text-orange-500" />
</div>
<div>
<p className="text-2xl font-bold">{summary.streak_count}</p>
<p className="text-sm text-muted-foreground">dagars streak!</p>
</div>
</div>
<p className="text-xs text-muted-foreground max-w-24 text-right">
Fortsätt kategorisera varje dag
</p>
</div>
</CardContent>
</Card>
)}
{/* Queue cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{/* Pending review */}
{summary.pending_review_count > 0 && (
<Card
className="cursor-pointer hover:border-primary/50 transition-colors"
onClick={onViewReceiptQueue}
>
<CardContent className="pt-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-full bg-yellow-500/10 flex items-center justify-center">
<Receipt className="h-5 w-5 text-yellow-500" />
</div>
<div>
<p className="font-medium">Att granska</p>
<p className="text-sm text-muted-foreground">
{summary.pending_review_count} kvitton
</p>
</div>
</div>
<Badge variant="secondary">{summary.pending_review_count}</Badge>
</div>
</CardContent>
</Card>
)}
{/* Unmatched receipts */}
{summary.unmatched_receipts_count > 0 && (
<Card
className="cursor-pointer hover:border-primary/50 transition-colors"
onClick={onViewReceiptQueue}
>
<CardContent className="pt-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-full bg-blue-500/10 flex items-center justify-center">
<Receipt className="h-5 w-5 text-blue-500" />
</div>
<div>
<p className="font-medium">Kvitton utan transaktion</p>
<p className="text-sm text-muted-foreground">
{summary.unmatched_receipts_count} st
</p>
</div>
</div>
<ArrowRight className="h-5 w-5 text-muted-foreground" />
</div>
</CardContent>
</Card>
)}
{/* Unmatched transactions */}
{summary.unmatched_transactions_count > 0 && (
<Card
className="cursor-pointer hover:border-primary/50 transition-colors"
onClick={onViewTransactionQueue}
>
<CardContent className="pt-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-full bg-purple-500/10 flex items-center justify-center">
<CreditCard className="h-5 w-5 text-purple-500" />
</div>
<div>
<p className="font-medium">Transaktioner utan kvitto</p>
<p className="text-sm text-muted-foreground">
{summary.unmatched_transactions_count} st
</p>
</div>
</div>
<ArrowRight className="h-5 w-5 text-muted-foreground" />
</div>
</CardContent>
</Card>
)}
</div>
{/* All done state */}
{!hasWork && (
<Card className="bg-gradient-to-r from-green-500/10 to-emerald-500/10 border-green-500/20">
<CardContent className="pt-4 text-center py-8">
<div className="h-16 w-16 rounded-full bg-green-500/20 flex items-center justify-center mx-auto mb-4">
<svg
className="h-8 w-8 text-green-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
</div>
<h3 className="font-semibold text-lg">Allt är uppdaterat!</h3>
<p className="text-sm text-muted-foreground mt-1">
Inga kvitton eller transaktioner att granska
</p>
</CardContent>
</Card>
)}
</div>
)
}
@@ -1,99 +0,0 @@
'use client'
import { Switch } from '@/components/ui/switch'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Badge } from '@/components/ui/badge'
import { formatCurrency } from '@/lib/utils'
import { getExpenseCategories, CATEGORY_LABELS } from '@/lib/receipts/receipt-categorizer'
import type { ReceiptLineItem, TransactionCategory } from '@/types'
interface ReceiptLineItemRowProps {
item: ReceiptLineItem
onToggleBusiness: (id: string, isBusiness: boolean) => void
onCategoryChange: (id: string, category: TransactionCategory) => void
disabled?: boolean
}
export default function ReceiptLineItemRow({
item,
onToggleBusiness,
onCategoryChange,
disabled = false,
}: ReceiptLineItemRowProps) {
const expenseCategories = getExpenseCategories()
const isBusiness = item.is_business === true
return (
<div className="flex flex-col gap-2 p-3 border rounded-lg bg-card">
{/* Description and amount */}
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<p className="font-medium text-sm truncate">{item.description}</p>
{item.quantity !== 1 && (
<p className="text-xs text-muted-foreground">
{item.quantity} x {item.unit_price ? formatCurrency(item.unit_price, 'SEK') : '—'}
</p>
)}
</div>
<div className="text-right flex-shrink-0">
<p className="font-semibold">{formatCurrency(item.line_total, 'SEK')}</p>
{item.vat_rate && (
<p className="text-xs text-muted-foreground">{item.vat_rate}% moms</p>
)}
</div>
</div>
{/* Classification controls */}
<div className="flex items-center gap-3 pt-2 border-t">
{/* Business/Private toggle */}
<div className="flex items-center gap-2">
<span className={`text-xs ${!isBusiness ? 'text-muted-foreground' : 'font-medium'}`}>
Privat
</span>
<Switch
checked={isBusiness}
onCheckedChange={(checked) => onToggleBusiness(item.id, checked)}
disabled={disabled}
/>
<span className={`text-xs ${isBusiness ? 'font-medium' : 'text-muted-foreground'}`}>
Företag
</span>
</div>
{/* Category selector (only for business) */}
{isBusiness && (
<Select
value={item.category || undefined}
onValueChange={(value) => onCategoryChange(item.id, value as TransactionCategory)}
disabled={disabled}
>
<SelectTrigger className="h-8 w-32 text-xs">
<SelectValue placeholder="Kategori" />
</SelectTrigger>
<SelectContent>
{expenseCategories.map((cat) => (
<SelectItem key={cat.value} value={cat.value}>
{cat.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{/* Confidence badge */}
{item.extraction_confidence !== null && item.extraction_confidence < 0.8 && (
<Badge variant="outline" className="text-xs">
{Math.round((item.extraction_confidence || 0) * 100)}%
</Badge>
)}
{/* AI suggestion indicator */}
{item.suggested_category && item.is_business === null && (
<Badge variant="secondary" className="text-xs">
Förslag: {CATEGORY_LABELS[`expense_${item.suggested_category}` as TransactionCategory] || item.suggested_category}
</Badge>
)}
</div>
</div>
)
}
-418
View File
@@ -1,418 +0,0 @@
'use client'
import { useState, useMemo } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import {
AlertTriangle,
Check,
X,
Building,
User,
Wine,
Utensils,
Globe,
ChevronUp,
ChevronDown,
Loader2,
} from 'lucide-react'
import { formatCurrency, formatDate } from '@/lib/utils'
import { calculateReceiptSplit, getDefaultClassification } from '@/lib/receipts/receipt-categorizer'
import { calculateRepresentationLimits } from '@/lib/receipts/receipt-utils'
import ReceiptLineItemRow from './ReceiptLineItemRow'
import type { Receipt, ReceiptLineItem, TransactionCategory, ConfirmLineItemInput } from '@/types'
interface ReceiptReviewViewProps {
receipt: Receipt & { line_items: ReceiptLineItem[] }
onConfirm: (data: {
line_items: ConfirmLineItemInput[]
representation_persons?: number
representation_purpose?: string
}) => Promise<void>
onCancel: () => void
onFindMatches?: () => void
}
export default function ReceiptReviewView({
receipt,
onConfirm,
onCancel,
onFindMatches,
}: ReceiptReviewViewProps) {
// Initialize line items with their current state
const [lineItems, setLineItems] = useState<ReceiptLineItem[]>(receipt.line_items || [])
const [isProcessing, setIsProcessing] = useState(false)
const [showImage, setShowImage] = useState(false)
// Restaurant representation state
const [representationPersons, setRepresentationPersons] = useState<number | undefined>(
receipt.representation_persons || undefined
)
const [representationPurpose, setRepresentationPurpose] = useState<string>(
receipt.representation_purpose || ''
)
// Get default classification info
const defaultClassification = getDefaultClassification(
receipt.is_restaurant,
receipt.is_systembolaget
)
// Calculate split summary
const splitSummary = useMemo(() => {
return calculateReceiptSplit(lineItems.map(item => ({
lineTotal: item.line_total,
is_business: item.is_business,
})))
}, [lineItems])
// Calculate representation limits if restaurant
const representationCalc = useMemo(() => {
if (!receipt.is_restaurant || !representationPersons || !receipt.total_amount) return null
return calculateRepresentationLimits(receipt.total_amount, representationPersons)
}, [receipt.is_restaurant, receipt.total_amount, representationPersons])
// Toggle business status for a line item
const handleToggleBusiness = (id: string, isBusiness: boolean) => {
setLineItems((items) =>
items.map((item) =>
item.id === id
? { ...item, is_business: isBusiness, category: isBusiness ? item.category : null }
: item
)
)
}
// Change category for a line item
const handleCategoryChange = (id: string, category: TransactionCategory) => {
setLineItems((items) =>
items.map((item) => (item.id === id ? { ...item, category } : item))
)
}
// Mark all as business
const handleAllBusiness = () => {
setLineItems((items) =>
items.map((item) => ({
...item,
is_business: true,
category: item.category || 'expense_other',
}))
)
}
// Mark all as private
const handleAllPrivate = () => {
setLineItems((items) =>
items.map((item) => ({
...item,
is_business: false,
category: null,
}))
)
}
// Confirm and save
const handleConfirm = async () => {
// Validate restaurant representation
if (receipt.is_restaurant && splitSummary.businessTotal > 0) {
if (!representationPersons || representationPersons < 1) {
alert('Ange antal personer för restaurangrepresentation')
return
}
if (!representationPurpose.trim()) {
alert('Ange syfte för restaurangrepresentation')
return
}
}
setIsProcessing(true)
try {
await onConfirm({
line_items: lineItems.map((item) => ({
id: item.id,
is_business: item.is_business ?? false,
category: item.category || undefined,
bas_account: item.bas_account || undefined,
})),
representation_persons: representationPersons,
representation_purpose: representationPurpose.trim() || undefined,
})
} catch (error) {
console.error('Confirm error:', error)
} finally {
setIsProcessing(false)
}
}
return (
<div className="fixed inset-0 bg-background z-50 flex flex-col overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b flex-shrink-0">
<Button variant="ghost" size="icon" onClick={onCancel} disabled={isProcessing} aria-label="Avbryt">
<X className="h-5 w-5" />
</Button>
<h1 className="font-semibold">Granska kvitto</h1>
<div className="w-10" />
</div>
{/* Content */}
<div className="flex-1 overflow-auto p-4 space-y-4">
{/* Merchant and total card */}
<Card>
<CardContent className="pt-4">
<div className="flex items-start justify-between">
<div>
<h2 className="font-semibold text-lg">{receipt.merchant_name || 'Okänd handlare'}</h2>
{receipt.receipt_date && (
<p className="text-sm text-muted-foreground">{formatDate(receipt.receipt_date)}</p>
)}
</div>
<div className="text-right">
<p className="text-2xl font-bold">
{formatCurrency(receipt.total_amount || 0, receipt.currency)}
</p>
{receipt.vat_amount && (
<p className="text-sm text-muted-foreground">
varav moms {formatCurrency(receipt.vat_amount, receipt.currency)}
</p>
)}
</div>
</div>
{/* Special flags */}
<div className="flex flex-wrap gap-2 mt-3">
{receipt.is_restaurant && (
<Badge variant="secondary">
<Utensils className="mr-1 h-3 w-3" /> Restaurang
</Badge>
)}
{receipt.is_systembolaget && (
<Badge variant="secondary">
<Wine className="mr-1 h-3 w-3" /> Systembolaget
</Badge>
)}
{receipt.is_foreign_merchant && (
<Badge variant="secondary">
<Globe className="mr-1 h-3 w-3" /> Utländskt
</Badge>
)}
{receipt.extraction_confidence && (
<Badge
variant={receipt.extraction_confidence > 0.8 ? 'outline' : receipt.extraction_confidence > 0.5 ? 'warning' : 'destructive'}
className={receipt.extraction_confidence > 0.8 ? 'border-success/50 text-success' : ''}
>
{Math.round(receipt.extraction_confidence * 100)}% säkerhet
{receipt.extraction_confidence <= 0.8 && ' — kontrollera'}
</Badge>
)}
</div>
{/* Toggle image preview */}
<Button
variant="ghost"
size="sm"
className="mt-2 text-xs"
onClick={() => setShowImage(!showImage)}
>
{showImage ? <ChevronUp className="mr-1 h-3 w-3" /> : <ChevronDown className="mr-1 h-3 w-3" />}
{showImage ? 'Dölj bild' : 'Visa bild'}
</Button>
{showImage && receipt.image_url && (
<img
src={receipt.image_url}
alt="Receipt"
className="mt-2 max-h-48 rounded-lg object-contain"
/>
)}
</CardContent>
</Card>
{/* Warning message */}
{defaultClassification.warningMessage && (
<div className="flex items-start gap-2 p-3 rounded-lg bg-yellow-50 dark:bg-yellow-950/20 border border-yellow-200 dark:border-yellow-800">
<AlertTriangle className="h-5 w-5 text-yellow-600 flex-shrink-0 mt-0.5" />
<p className="text-sm text-yellow-800 dark:text-yellow-200">
{defaultClassification.warningMessage}
</p>
</div>
)}
{/* Restaurant representation section */}
{receipt.is_restaurant && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base flex items-center gap-2">
<Utensils className="h-4 w-4" />
Representation
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<Label htmlFor="persons" className="text-xs">
Antal personer
</Label>
<Input
id="persons"
type="number"
min={1}
value={representationPersons || ''}
onChange={(e) => setRepresentationPersons(parseInt(e.target.value) || undefined)}
placeholder="2"
/>
</div>
<div>
<Label className="text-xs">Per person</Label>
<p className="h-10 flex items-center font-medium">
{representationPersons && receipt.total_amount
? formatCurrency(receipt.total_amount / representationPersons, receipt.currency)
: '—'}
</p>
</div>
</div>
<div>
<Label htmlFor="purpose" className="text-xs">
Syfte med representationen
</Label>
<Textarea
id="purpose"
value={representationPurpose}
onChange={(e) => setRepresentationPurpose(e.target.value)}
placeholder="T.ex. kundmöte, kontraktsförhandling..."
rows={2}
/>
</div>
{representationCalc && (
<div className="p-2 rounded bg-muted text-xs space-y-1">
<p>
<span className="text-muted-foreground">Avdragsgill del:</span>{' '}
<span className="font-medium text-green-600">
{formatCurrency(representationCalc.deductibleAmount, receipt.currency)}
</span>
{' '}(max {representationCalc.maxDeductiblePerPerson} kr/person)
</p>
{representationCalc.nonDeductibleAmount > 0 && (
<p>
<span className="text-muted-foreground">Ej avdragsgill del:</span>{' '}
<span className="font-medium text-orange-600">
{formatCurrency(representationCalc.nonDeductibleAmount, receipt.currency)}
</span>
</p>
)}
</div>
)}
</CardContent>
</Card>
)}
{/* Quick actions */}
<div className="flex gap-2">
<Button variant="outline" className="flex-1" onClick={handleAllBusiness}>
<Building className="mr-2 h-4 w-4" />
Allt företag
</Button>
<Button variant="outline" className="flex-1" onClick={handleAllPrivate}>
<User className="mr-2 h-4 w-4" />
Allt privat
</Button>
</div>
{/* Line items */}
<div className="space-y-2">
<h3 className="font-medium text-sm flex items-center justify-between">
Artiklar ({lineItems.length})
<span className="text-muted-foreground font-normal">
Svajpa eller klicka för att klassificera
</span>
</h3>
{lineItems.map((item) => (
<ReceiptLineItemRow
key={item.id}
item={item}
onToggleBusiness={handleToggleBusiness}
onCategoryChange={handleCategoryChange}
disabled={isProcessing}
/>
))}
</div>
{/* Summary */}
<Card>
<CardContent className="pt-4">
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Företag:</span>
<span className="font-medium text-green-600">
{formatCurrency(splitSummary.businessTotal, receipt.currency)}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Privat:</span>
<span className="font-medium text-orange-600">
{formatCurrency(splitSummary.privateTotal, receipt.currency)}
</span>
</div>
{splitSummary.unclassifiedTotal > 0 && (
<div className="flex justify-between">
<span className="text-muted-foreground">Oklassificerat:</span>
<span className="font-medium text-yellow-600">
{formatCurrency(splitSummary.unclassifiedTotal, receipt.currency)}
</span>
</div>
)}
<div className="pt-2 border-t flex justify-between font-medium">
<span>Totalt:</span>
<span>{formatCurrency(receipt.total_amount || 0, receipt.currency)}</span>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Footer actions - large touch targets for mobile */}
<div className="p-4 border-t flex-shrink-0 space-y-2 safe-area-bottom">
{onFindMatches && (
<Button
variant="outline"
className="w-full h-12 text-base"
onClick={onFindMatches}
disabled={isProcessing}
>
Hitta matchande transaktion
</Button>
)}
<Button
className="w-full h-14 text-base font-medium"
onClick={handleConfirm}
disabled={isProcessing || splitSummary.unclassifiedTotal > 0}
>
{isProcessing ? (
<>
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
Sparar...
</>
) : (
<>
<Check className="mr-2 h-5 w-5" />
Bekräfta klassificering
</>
)}
</Button>
{splitSummary.unclassifiedTotal > 0 && (
<p className="text-xs text-center text-muted-foreground">
Klassificera alla artiklar innan du bekräftar
</p>
)}
</div>
</div>
)
}
-229
View File
@@ -1,229 +0,0 @@
'use client'
import { useState, useEffect } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { formatCurrency, formatDate } from '@/lib/utils'
import { X, Check, Link2, AlertCircle, Search, Loader2 } from 'lucide-react'
import type { Receipt, ReceiptMatchCandidate, Transaction } from '@/types'
interface TransactionMatcherProps {
receipt: Receipt
onMatch: (transactionId: string, confidence: number) => Promise<void>
onSkip: () => void
onClose: () => void
}
export default function TransactionMatcher({
receipt,
onMatch,
onSkip,
onClose,
}: TransactionMatcherProps) {
const [matches, setMatches] = useState<ReceiptMatchCandidate[]>([])
const [isLoading, setIsLoading] = useState(true)
const [isMatching, setIsMatching] = useState(false)
const [selectedMatch, setSelectedMatch] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
// Fetch matches on mount
useEffect(() => {
const fetchMatches = async () => {
setIsLoading(true)
setError(null)
try {
const response = await fetch(`/api/receipts/${receipt.id}/match`, {
method: 'POST',
})
const data = await response.json()
if (response.ok && data.data?.matches) {
setMatches(data.data.matches)
} else {
setError(data.error || 'Kunde inte hämta matchningar')
}
} catch {
setError('Nätverksfel vid hämtning av matchningar')
} finally {
setIsLoading(false)
}
}
fetchMatches()
}, [receipt.id])
// Handle match confirmation
const handleConfirmMatch = async () => {
if (!selectedMatch) return
const match = matches.find((m) => m.transaction.id === selectedMatch)
if (!match) return
setIsMatching(true)
try {
await onMatch(selectedMatch, match.confidence)
} catch {
setError('Kunde inte koppla kvitto till transaktion')
} finally {
setIsMatching(false)
}
}
return (
<div className="fixed inset-0 bg-background z-50 flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b">
<Button variant="ghost" size="icon" onClick={onClose}>
<X className="h-5 w-5" />
</Button>
<h1 className="font-semibold">Matcha transaktion</h1>
<div className="w-10" />
</div>
{/* Receipt summary */}
<Card className="m-4 mb-2">
<CardContent className="pt-4">
<div className="flex items-center justify-between">
<div>
<p className="font-medium">{receipt.merchant_name || 'Kvitto'}</p>
{receipt.receipt_date && (
<p className="text-sm text-muted-foreground">{formatDate(receipt.receipt_date)}</p>
)}
</div>
<p className="text-xl font-bold">
{formatCurrency(receipt.total_amount || 0, receipt.currency)}
</p>
</div>
</CardContent>
</Card>
{/* Content */}
<div className="flex-1 overflow-auto px-4 pb-4">
<h2 className="text-sm font-medium text-muted-foreground mb-3 flex items-center gap-2">
<Search className="h-4 w-4" />
Möjliga matchningar
</h2>
{isLoading ? (
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<Card key={i}>
<CardContent className="pt-4">
<Skeleton className="h-4 w-2/3 mb-2" />
<Skeleton className="h-6 w-1/3 mb-2" />
<Skeleton className="h-3 w-1/2" />
</CardContent>
</Card>
))}
</div>
) : error ? (
<Card className="border-destructive">
<CardContent className="pt-4 flex items-center gap-3 text-destructive">
<AlertCircle className="h-5 w-5" />
<p>{error}</p>
</CardContent>
</Card>
) : matches.length === 0 ? (
<Card>
<CardContent className="pt-4 text-center py-8">
<Search className="h-12 w-12 text-muted-foreground mx-auto mb-3" />
<p className="font-medium">Inga matchande transaktioner hittades</p>
<p className="text-sm text-muted-foreground mt-1">
Det finns ingen banktransaktion som matchar detta kvitto
</p>
</CardContent>
</Card>
) : (
<div className="space-y-3">
{matches.map((match) => (
<Card
key={match.transaction.id}
className={`cursor-pointer transition-colors ${
selectedMatch === match.transaction.id
? 'border-primary ring-2 ring-primary/20'
: 'hover:border-primary/50'
}`}
onClick={() => setSelectedMatch(match.transaction.id)}
>
<CardContent className="pt-4">
<div className="flex items-start justify-between mb-2">
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{match.transaction.description}</p>
<p className="text-sm text-muted-foreground">
{formatDate(match.transaction.date)}
</p>
</div>
<div className="text-right flex-shrink-0">
<p className="font-bold">
{formatCurrency(Math.abs(match.transaction.amount), match.transaction.currency)}
</p>
<Badge
variant={match.confidence > 0.8 ? 'default' : match.confidence > 0.6 ? 'secondary' : 'outline'}
>
{Math.round(match.confidence * 100)}% match
</Badge>
</div>
</div>
{/* Match reasons */}
<div className="flex flex-wrap gap-1 mt-2">
{match.matchReasons.map((reason, idx) => (
<Badge key={idx} variant="outline" className="text-xs">
{reason}
</Badge>
))}
</div>
{/* Variance info */}
<div className="flex gap-4 mt-2 text-xs text-muted-foreground">
{match.dateVariance > 0 && (
<span>Datum: ±{Math.round(match.dateVariance)} dagar</span>
)}
{match.amountVariance > 0.01 && (
<span>Belopp: ±{Math.round(match.amountVariance * 100)}%</span>
)}
</div>
{/* Selected indicator */}
{selectedMatch === match.transaction.id && (
<div className="absolute top-2 right-2">
<div className="h-6 w-6 rounded-full bg-primary flex items-center justify-center">
<Check className="h-4 w-4 text-primary-foreground" />
</div>
</div>
)}
</CardContent>
</Card>
))}
</div>
)}
</div>
{/* Footer actions */}
<div className="p-4 border-t flex gap-3">
<Button variant="outline" className="flex-1" onClick={onSkip}>
Hoppa över
</Button>
<Button
className="flex-1"
onClick={handleConfirmMatch}
disabled={!selectedMatch || isMatching}
>
{isMatching ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Kopplar...
</>
) : (
<>
<Link2 className="mr-2 h-4 w-4" />
Koppla transaktion
</>
)}
</Button>
</div>
</div>
)
}
-5
View File
@@ -1,5 +0,0 @@
export { default as ReceiptCamera } from './ReceiptCamera'
export { default as ReceiptLineItemRow } from './ReceiptLineItemRow'
export { default as ReceiptReviewView } from './ReceiptReviewView'
export { default as TransactionMatcher } from './TransactionMatcher'
export { default as ReceiptDashboard } from './ReceiptDashboard'
@@ -1,349 +0,0 @@
'use client'
import { useState, useEffect } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { useToast } from '@/components/ui/use-toast'
import { createClient } from '@/lib/supabase/client'
import { Bell, BellOff, Loader2, Moon } from 'lucide-react'
import type { NotificationSettings as NotificationSettingsType } from '@/types'
interface NotificationSettingsProps {
onSettingsChange?: () => void
}
export function NotificationSettings({ onSettingsChange }: NotificationSettingsProps) {
const { toast } = useToast()
const supabase = createClient()
const [isLoading, setIsLoading] = useState(true)
const [isSaving, setIsSaving] = useState(false)
const [settings, setSettings] = useState<NotificationSettingsType | null>(null)
const [isPushSupported, setIsPushSupported] = useState(false)
const [isPushSubscribed, setIsPushSubscribed] = useState(false)
useEffect(() => {
fetchSettings()
checkPushSupport()
}, [])
const checkPushSupport = async () => {
if ('serviceWorker' in navigator && 'PushManager' in window) {
setIsPushSupported(true)
try {
const registration = await navigator.serviceWorker.ready
const subscription = await registration.pushManager.getSubscription()
setIsPushSubscribed(!!subscription)
} catch (error) {
console.error('Error checking push subscription:', error)
}
}
}
const fetchSettings = async () => {
setIsLoading(true)
const { data: { user } } = await supabase.auth.getUser()
if (!user) return
const { data, error } = await supabase
.from('notification_settings')
.select('*')
.eq('user_id', user.id)
.single()
if (!error && data) {
setSettings(data)
} else {
// Create default settings if not found
const { data: newSettings, error: insertError } = await supabase
.from('notification_settings')
.insert({
user_id: user.id,
tax_deadlines_enabled: true,
invoice_reminders_enabled: true,
push_enabled: true,
email_enabled: true,
quiet_start: '21:00',
quiet_end: '08:00',
})
.select()
.single()
if (!insertError && newSettings) {
setSettings(newSettings)
}
}
setIsLoading(false)
}
const updateSetting = async (key: keyof NotificationSettingsType, value: boolean | string) => {
if (!settings) return
setIsSaving(true)
const { error } = await supabase
.from('notification_settings')
.update({ [key]: value })
.eq('id', settings.id)
if (error) {
toast({
title: 'Fel',
description: 'Kunde inte spara inställning',
variant: 'destructive',
})
} else {
setSettings({ ...settings, [key]: value })
onSettingsChange?.()
}
setIsSaving(false)
}
const subscribeToPush = async () => {
try {
const permission = await Notification.requestPermission()
if (permission !== 'granted') {
toast({
title: 'Aviseringar avslagna',
description: 'Du behöver ge tillåtelse för push-aviseringar i webbläsaren.',
})
return
}
const vapidResponse = await fetch('/api/push/subscribe')
const { vapidPublicKey } = await vapidResponse.json()
if (!vapidPublicKey) {
throw new Error('Push not configured')
}
const registration = await navigator.serviceWorker.ready
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
})
const response = await fetch('/api/push/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(subscription.toJSON()),
})
if (!response.ok) {
throw new Error('Failed to save subscription')
}
setIsPushSubscribed(true)
toast({
title: 'Push-aviseringar aktiverade',
description: 'Du kommer nu få push-aviseringar.',
})
} catch (error) {
toast({
title: 'Fel',
description: 'Kunde inte aktivera push-aviseringar.',
variant: 'destructive',
})
}
}
const unsubscribeFromPush = async () => {
try {
const registration = await navigator.serviceWorker.ready
const subscription = await registration.pushManager.getSubscription()
if (subscription) {
await subscription.unsubscribe()
await fetch('/api/push/subscribe', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ endpoint: subscription.endpoint }),
})
}
setIsPushSubscribed(false)
toast({
title: 'Push-aviseringar inaktiverade',
description: 'Du kommer inte längre få push-aviseringar.',
})
} catch (error) {
toast({
title: 'Fel',
description: 'Kunde inte inaktivera push-aviseringar.',
variant: 'destructive',
})
}
}
if (isLoading) {
return (
<div className="flex items-center justify-center h-32">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
)
}
if (!settings) {
return null
}
return (
<div className="space-y-6">
{/* Push notification status */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Bell className="h-5 w-5" />
Push-aviseringar
</CardTitle>
<CardDescription>
Få påminnelser direkt i din webbläsare
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{isPushSupported ? (
<div className="flex items-center justify-between">
<div>
<p className="font-medium">
{isPushSubscribed ? 'Aktiverade' : 'Inaktiverade'}
</p>
<p className="text-sm text-muted-foreground">
{isPushSubscribed
? 'Du får push-aviseringar på denna enhet'
: 'Aktivera för att få push-aviseringar'}
</p>
</div>
<Button
variant={isPushSubscribed ? 'outline' : 'default'}
onClick={isPushSubscribed ? unsubscribeFromPush : subscribeToPush}
>
{isPushSubscribed ? (
<>
<BellOff className="mr-2 h-4 w-4" />
Inaktivera
</>
) : (
<>
<Bell className="mr-2 h-4 w-4" />
Aktivera
</>
)}
</Button>
</div>
) : (
<p className="text-sm text-muted-foreground">
Din webbläsare stöder inte push-aviseringar.
</p>
)}
</CardContent>
</Card>
{/* Category settings */}
<Card>
<CardHeader>
<CardTitle>Aviseringskategorier</CardTitle>
<CardDescription>
Välj vilka typer av aviseringar du vill få
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="tax-deadlines">Skattedeadlines</Label>
<p className="text-sm text-muted-foreground">
Moms, F-skatt, inkomstdeklaration m.m.
</p>
</div>
<Switch
id="tax-deadlines"
checked={settings.tax_deadlines_enabled}
onCheckedChange={(checked) =>
updateSetting('tax_deadlines_enabled', checked)
}
disabled={isSaving}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="invoice-reminders">Fakturapåminnelser</Label>
<p className="text-sm text-muted-foreground">
Fakturor som förfaller eller är försenade
</p>
</div>
<Switch
id="invoice-reminders"
checked={settings.invoice_reminders_enabled}
onCheckedChange={(checked) =>
updateSetting('invoice_reminders_enabled', checked)
}
disabled={isSaving}
/>
</div>
</CardContent>
</Card>
{/* Quiet hours */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Moon className="h-5 w-5" />
Tysta timmar
</CardTitle>
<CardDescription>
Inga aviseringar skickas under dessa timmar
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="quiet-start">Från</Label>
<Input
id="quiet-start"
type="time"
value={settings.quiet_start}
onChange={(e) => updateSetting('quiet_start', e.target.value)}
disabled={isSaving}
/>
</div>
<div className="space-y-2">
<Label htmlFor="quiet-end">Till</Label>
<Input
id="quiet-end"
type="time"
value={settings.quiet_end}
onChange={(e) => updateSetting('quiet_end', e.target.value)}
disabled={isSaving}
/>
</div>
</div>
<p className="text-xs text-muted-foreground">
Standard: 21:00 - 08:00 (Sveriges tid)
</p>
</CardContent>
</Card>
</div>
)
}
function urlBase64ToUint8Array(base64String: string): ArrayBuffer {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
const rawData = window.atob(base64)
const outputArray = new Uint8Array(rawData.length)
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i)
}
return outputArray.buffer
}
@@ -1,204 +0,0 @@
'use client'
import { useState } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
import { useToast } from '@/components/ui/use-toast'
import { formatCurrency } from '@/lib/utils'
import { SCHABLONAVDRAG_RATES, type HousingType } from '@/lib/tax/schablonavdrag'
import { Home, Car, Loader2, Info, Building } from 'lucide-react'
import type { SchablonavdragSettings as SchablonavdragSettingsType } from '@/types'
interface SchablonavdragSettingsProps {
settings: SchablonavdragSettingsType
onSave: (settings: SchablonavdragSettingsType) => Promise<void>
}
export default function SchablonavdragSettings({
settings: initialSettings,
onSave,
}: SchablonavdragSettingsProps) {
const { toast } = useToast()
const [isSaving, setIsSaving] = useState(false)
const [settings, setSettings] = useState<SchablonavdragSettingsType>(initialSettings)
const hasChanges =
settings.hemmakontor_enabled !== initialSettings.hemmakontor_enabled ||
settings.hemmakontor_housing_type !== initialSettings.hemmakontor_housing_type ||
settings.bil_enabled !== initialSettings.bil_enabled
// Get the current hemmakontor amount based on housing type
const housingType = settings.hemmakontor_housing_type || 'apartment'
const hemmakontorAmount = housingType === 'villa'
? SCHABLONAVDRAG_RATES.hemmakontor.villa
: SCHABLONAVDRAG_RATES.hemmakontor.apartment
async function handleSave() {
setIsSaving(true)
try {
await onSave(settings)
toast({
title: 'Sparat',
description: 'Dina schablonavdragsinställningar har uppdaterats',
})
} catch (error) {
toast({
title: 'Fel',
description: 'Kunde inte spara inställningar',
variant: 'destructive',
})
}
setIsSaving(false)
}
function setHousingType(type: HousingType) {
setSettings({ ...settings, hemmakontor_housing_type: type })
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
Schablonavdrag
</CardTitle>
<CardDescription>
Aktivera schablonmässiga avdrag som inte kräver kvitton
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Hemmakontor */}
<div className="p-4 border rounded-lg space-y-4">
<div className="flex items-start justify-between">
<div className="flex items-start gap-4">
<div className="h-10 w-10 rounded-full bg-primary/10 flex items-center justify-center">
<Home className="h-5 w-5 text-primary" />
</div>
<div className="space-y-1">
<Label htmlFor="hemmakontor" className="text-base font-medium">
Hemmakontor
</Label>
<p className="text-sm text-muted-foreground">
Schablonavdrag för hemmakontor (minst 800 tim/år)
</p>
<div className="flex items-center gap-2 text-sm">
<span className="font-medium text-success">
{formatCurrency(hemmakontorAmount)}
</span>
<span className="text-muted-foreground">per år</span>
</div>
</div>
</div>
<Switch
id="hemmakontor"
checked={settings.hemmakontor_enabled}
onCheckedChange={(checked) =>
setSettings({ ...settings, hemmakontor_enabled: checked })
}
/>
</div>
{/* Housing type selection */}
{settings.hemmakontor_enabled && (
<div className="ml-14 space-y-2">
<Label className="text-sm text-muted-foreground">Typ av bostad</Label>
<div className="flex gap-2">
<Button
type="button"
variant={housingType === 'apartment' ? 'default' : 'outline'}
size="sm"
onClick={() => setHousingType('apartment')}
className="flex items-center gap-2"
>
<Building className="h-4 w-4" />
Hyresrätt/Bostadsrätt
<span className="text-xs opacity-70">({formatCurrency(SCHABLONAVDRAG_RATES.hemmakontor.apartment)})</span>
</Button>
<Button
type="button"
variant={housingType === 'villa' ? 'default' : 'outline'}
size="sm"
onClick={() => setHousingType('villa')}
className="flex items-center gap-2"
>
<Home className="h-4 w-4" />
Villa
<span className="text-xs opacity-70">({formatCurrency(SCHABLONAVDRAG_RATES.hemmakontor.villa)})</span>
</Button>
</div>
</div>
)}
</div>
{/* Bilkostnader */}
<div className="flex items-start justify-between p-4 border rounded-lg">
<div className="flex items-start gap-4">
<div className="h-10 w-10 rounded-full bg-primary/10 flex items-center justify-center">
<Car className="h-5 w-5 text-primary" />
</div>
<div className="space-y-1">
<Label htmlFor="bil" className="text-base font-medium">
Bilkostnader (körjournal)
</Label>
<p className="text-sm text-muted-foreground">
Milersättning för tjänsteresor med egen bil
</p>
<div className="flex items-center gap-2 text-sm">
<span className="font-medium text-success">
{SCHABLONAVDRAG_RATES.bil.rate_per_mil} kr/mil
</span>
<span className="text-muted-foreground">
({SCHABLONAVDRAG_RATES.bil.rate_per_km.toFixed(2)} kr/km)
</span>
</div>
</div>
</div>
<Switch
id="bil"
checked={settings.bil_enabled}
onCheckedChange={(checked) =>
setSettings({ ...settings, bil_enabled: checked })
}
/>
</div>
{/* Info box */}
<div className="flex items-start gap-3 p-4 rounded-lg bg-muted/50">
<Info className="h-5 w-5 text-muted-foreground flex-shrink-0 mt-0.5" />
<div className="text-sm text-muted-foreground">
<p className="font-medium text-foreground">Om schablonavdrag</p>
<ul className="mt-2 space-y-1 list-disc list-inside">
<li>
<strong>Hemmakontor:</strong> Kräver minst 800 timmars arbete hemifrån per år.
Avdrag: {formatCurrency(SCHABLONAVDRAG_RATES.hemmakontor.apartment)} (lägenhet) eller{' '}
{formatCurrency(SCHABLONAVDRAG_RATES.hemmakontor.villa)} (villa).
</li>
<li>
<strong>Bilkostnader:</strong> Kräver att du loggar dina resor i körjournalen.
Du får {SCHABLONAVDRAG_RATES.bil.rate_per_mil} kr per mil ({SCHABLONAVDRAG_RATES.bil.rate_per_km.toFixed(2)} kr/km).
</li>
<li>Schablonavdrag minskar din beskattningsbara inkomst</li>
</ul>
</div>
</div>
{/* Save button */}
{hasChanges && (
<div className="flex justify-end">
<Button onClick={handleSave} disabled={isSaving}>
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Sparar...
</>
) : (
'Spara ändringar'
)}
</Button>
</div>
)}
</CardContent>
</Card>
)
}
-242
View File
@@ -1,242 +0,0 @@
'use client'
import { useState } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { useToast } from '@/components/ui/use-toast'
import { formatCurrency } from '@/lib/utils'
import { SCHABLONAVDRAG_RATES, validateMileageEntry } from '@/lib/tax/schablonavdrag'
import { Car, Loader2, MapPin, Calendar, FileText, X } from 'lucide-react'
import type { CreateMileageEntryInput } from '@/types'
interface MileageEntryProps {
onSave: (entry: CreateMileageEntryInput) => Promise<void>
onCancel?: () => void
initialDate?: string
}
export default function MileageEntry({
onSave,
onCancel,
initialDate,
}: MileageEntryProps) {
const { toast } = useToast()
const [isSaving, setIsSaving] = useState(false)
const [errors, setErrors] = useState<string[]>([])
const today = new Date().toISOString().split('T')[0]
const [formData, setFormData] = useState<CreateMileageEntryInput>({
date: initialDate || today,
distance_km: 0,
purpose: '',
from_location: '',
to_location: '',
})
// Calculate deduction preview
const deductionPreview = formData.distance_km > 0
? formData.distance_km * SCHABLONAVDRAG_RATES.bil.rate_per_km
: 0
function handleChange(
field: keyof CreateMileageEntryInput,
value: string | number
) {
setFormData((prev) => ({ ...prev, [field]: value }))
setErrors([]) // Clear errors on change
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
// Validate
const validation = validateMileageEntry({
date: formData.date,
distance_km: formData.distance_km,
purpose: formData.purpose,
})
if (!validation.valid) {
setErrors(validation.errors)
return
}
setIsSaving(true)
try {
await onSave(formData)
toast({
title: 'Körning registrerad',
description: `${formData.distance_km} km - avdrag ${formatCurrency(deductionPreview)}`,
})
// Reset form
setFormData({
date: today,
distance_km: 0,
purpose: '',
from_location: '',
to_location: '',
})
} catch (error) {
toast({
title: 'Fel',
description: 'Kunde inte spara körningen',
variant: 'destructive',
})
}
setIsSaving(false)
}
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<Car className="h-5 w-5" />
Registrera körning
</CardTitle>
<CardDescription>
Logga en tjänsteresa för milersättning
</CardDescription>
</div>
{onCancel && (
<Button variant="ghost" size="icon" onClick={onCancel}>
<X className="h-4 w-4" />
</Button>
)}
</div>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Error display */}
{errors.length > 0 && (
<div className="p-3 rounded-lg bg-destructive/10 border border-destructive/20">
<ul className="text-sm text-destructive space-y-1">
{errors.map((error, idx) => (
<li key={idx}>{error}</li>
))}
</ul>
</div>
)}
{/* Date and distance row */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="date" className="flex items-center gap-2">
<Calendar className="h-4 w-4" />
Datum
</Label>
<Input
id="date"
type="date"
value={formData.date}
max={today}
onChange={(e) => handleChange('date', e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="distance_km">Avstånd (km)</Label>
<Input
id="distance_km"
type="number"
step="0.1"
min="0.1"
max="10000"
placeholder="0"
value={formData.distance_km || ''}
onChange={(e) =>
handleChange('distance_km', parseFloat(e.target.value) || 0)
}
required
/>
</div>
</div>
{/* Locations row */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="from_location" className="flex items-center gap-2">
<MapPin className="h-4 w-4" />
Från
</Label>
<Input
id="from_location"
placeholder="T.ex. Hemma"
value={formData.from_location || ''}
onChange={(e) => handleChange('from_location', e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="to_location" className="flex items-center gap-2">
<MapPin className="h-4 w-4" />
Till
</Label>
<Input
id="to_location"
placeholder="T.ex. Kundens kontor"
value={formData.to_location || ''}
onChange={(e) => handleChange('to_location', e.target.value)}
/>
</div>
</div>
{/* Purpose */}
<div className="space-y-2">
<Label htmlFor="purpose" className="flex items-center gap-2">
<FileText className="h-4 w-4" />
Ändamål
</Label>
<Textarea
id="purpose"
placeholder="Beskriv syftet med resan, t.ex. kundmöte, fotografering, leverans..."
value={formData.purpose}
onChange={(e) => handleChange('purpose', e.target.value)}
rows={2}
required
/>
</div>
{/* Deduction preview */}
{formData.distance_km > 0 && (
<div className="p-4 rounded-lg bg-success/10 border border-success/20">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">Beräknat avdrag</p>
<p className="text-xs text-muted-foreground">
{formData.distance_km} km × {SCHABLONAVDRAG_RATES.bil.rate_per_km.toFixed(2)} kr/km
</p>
</div>
<span className="text-xl font-bold text-success">
{formatCurrency(deductionPreview)}
</span>
</div>
</div>
)}
{/* Submit button */}
<div className="flex justify-end gap-2">
{onCancel && (
<Button type="button" variant="outline" onClick={onCancel}>
Avbryt
</Button>
)}
<Button type="submit" disabled={isSaving}>
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Sparar...
</>
) : (
'Spara körning'
)}
</Button>
</div>
</form>
</CardContent>
</Card>
)
}
@@ -341,13 +341,13 @@ export default function SwipeCategorizationView({
{/* Potential Invoice Match */}
{currentTransaction.potential_invoice && (
<div className="p-4 rounded-lg border-2 border-green-500/50 bg-green-50/50 dark:bg-green-950/20 space-y-2">
<div className="p-4 rounded-lg border-2 border-success/40 bg-success/5 space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-green-600">
<div className="flex items-center gap-2 text-success">
<FileText className="h-5 w-5" />
<span className="font-semibold text-sm">Fakturamatchning hittad</span>
</div>
<Badge variant="outline" className="text-green-600 border-green-600">
<Badge variant="outline" className="text-success border-success">
Match
</Badge>
</div>
@@ -358,7 +358,7 @@ export default function SwipeCategorizationView({
<p className="text-muted-foreground">
{currentTransaction.potential_invoice.customer?.name || 'Okänd kund'}
</p>
<p className="font-medium text-green-600">
<p className="font-medium text-success">
{formatCurrency(
currentTransaction.potential_invoice.total,
currentTransaction.potential_invoice.currency
@@ -410,7 +410,7 @@ export default function SwipeCategorizationView({
{/* Invoice match button - primary action when there's a match */}
{currentTransaction.potential_invoice && onMatchInvoice && (
<Button
className="w-full bg-green-600 hover:bg-green-700 text-white"
className="w-full bg-success hover:bg-success/90 text-success-foreground"
onClick={handleMatchInvoice}
disabled={isProcessing}
>
+1 -1
View File
@@ -8,7 +8,7 @@ const Card = React.forwardRef<
<div
ref={ref}
className={cn(
"rounded-xl border border-border/60 bg-card text-card-foreground shadow-sm transition-shadow duration-300 hover:shadow-md",
"rounded-xl border border-border/50 bg-card text-card-foreground shadow-[0_1px_3px_rgba(30,25,20,0.04)]",
className
)}
{...props}
+3 -6
View File
@@ -47,11 +47,8 @@ export function EmptyState({
return (
<div className={cn('flex flex-col items-center justify-center py-12 px-4 text-center', className)}>
{Icon && (
<div className="relative mb-6">
{/* Background pattern */}
<div className="absolute inset-0 -m-4 rounded-full bg-gradient-to-br from-secondary/60 to-muted/30 blur-xl" />
{/* Icon container */}
<div className="relative p-5 rounded-full bg-gradient-to-br from-muted/80 to-secondary/50 ring-1 ring-border/20">
<div className="mb-6">
<div className="p-5 rounded-full bg-muted">
<Icon className="h-8 w-8 text-muted-foreground" />
</div>
</div>
@@ -63,7 +60,7 @@ export function EmptyState({
<div className="flex flex-col sm:flex-row items-center gap-3">
{actionHref && actionLabel && (
<Link href={actionHref}>
<Button className="bg-accent hover:bg-accent/90 text-accent-foreground">
<Button>
<Plus className="mr-2 h-4 w-4" />
{actionLabel}
</Button>
+3 -3
View File
@@ -8,11 +8,11 @@ interface PageHeaderProps {
export function PageHeader({ title, description, action }: PageHeaderProps) {
return (
<div className="flex items-center justify-between">
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-3xl font-bold tracking-tight">{title}</h1>
<h1 className="font-display text-2xl font-medium tracking-tight">{title}</h1>
{description && (
<p className="text-muted-foreground text-balance">{description}</p>
<p className="text-muted-foreground mt-1 text-balance">{description}</p>
)}
</div>
{action && <div>{action}</div>}