Merge remote-tracking branch 'origin/main' into export-biz

This commit is contained in:
Emil
2026-02-24 16:07:57 +01:00
69 changed files with 3584 additions and 208 deletions
+5 -1
View File
@@ -206,8 +206,11 @@ export default function JournalEntryList({ periodId }: Props) {
</div>
</button>
{isExpanded && lines.length > 0 && (
{isExpanded && (
<CardContent className="pt-0 pb-4">
{lines.length === 0 ? (
<p className="text-sm text-muted-foreground py-2">Inga kontorader hittades för denna verifikation.</p>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
@@ -261,6 +264,7 @@ export default function JournalEntryList({ periodId }: Props) {
</tr>
</tfoot>
</table>
)}
<JournalEntryAttachments
journalEntryId={entry.id}
-165
View File
@@ -1,165 +0,0 @@
'use client'
import { cn } from '@/lib/utils'
import { Invoice, Deadline, DeadlineStatus } from '@/types'
import { isInvoiceOverdue, isSameDay, formatDateISO, STATUS_COLORS } from '@/lib/calendar/utils'
interface CalendarDayCellProps {
date: Date
currentMonth: number
invoices: Invoice[]
deadlines: Deadline[]
onDayClick: (date: Date) => void
}
export function CalendarDayCell({
date,
currentMonth,
invoices,
deadlines,
onDayClick,
}: CalendarDayCellProps) {
const dateStr = formatDateISO(date)
const isCurrentMonth = date.getMonth() === currentMonth
const isToday = isSameDay(date, new Date())
// Filter invoices and deadlines for this day
const dayInvoices = invoices.filter(inv => inv.due_date === dateStr)
const dayDeadlines = deadlines.filter(d => d.due_date === dateStr)
// Count invoices by status
const overdueInvoices = dayInvoices.filter(isInvoiceOverdue)
const paidInvoices = dayInvoices.filter(inv => inv.status === 'paid')
const pendingInvoices = dayInvoices.filter(
inv => inv.status !== 'paid' && inv.status !== 'cancelled' && inv.status !== 'credited' && !isInvoiceOverdue(inv)
)
// Group deadlines by status
const deadlinesByStatus = dayDeadlines.reduce((acc, d) => {
if (d.is_completed) {
acc.completed = (acc.completed || 0) + 1
} else if (d.status) {
acc[d.status] = (acc[d.status] || 0) + 1
}
return acc
}, {} as Record<string, number>)
const hasItems = dayInvoices.length > 0 || dayDeadlines.length > 0
const hasOverdue = overdueInvoices.length > 0 || deadlinesByStatus.overdue > 0
const hasActionNeeded = deadlinesByStatus.action_needed > 0
return (
<button
onClick={() => onDayClick(date)}
className={cn(
'min-h-[80px] p-1 border-b border-r text-left transition-colors',
'hover:bg-muted/50 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-inset',
!isCurrentMonth && 'bg-muted/30 text-muted-foreground',
isToday && 'bg-primary/5',
hasOverdue && 'border-l-2 border-l-destructive',
!hasOverdue && hasActionNeeded && 'border-l-2 border-l-orange-500'
)}
>
<div className={cn(
'text-sm font-medium mb-1',
isToday && 'text-primary font-bold'
)}>
{date.getDate()}
</div>
{hasItems && (
<div className="space-y-0.5">
{/* Overdue invoices */}
{overdueInvoices.length > 0 && (
<div className="flex items-center gap-1">
<div className="w-2 h-2 rounded-full bg-destructive" />
<span className="text-xs text-destructive truncate">
{overdueInvoices.length} förfallen
</span>
</div>
)}
{/* Pending invoices */}
{pendingInvoices.length > 0 && (
<div className="flex items-center gap-1">
<div className="w-2 h-2 rounded-full bg-primary" />
<span className="text-xs text-muted-foreground truncate">
{pendingInvoices.length} faktura
</span>
</div>
)}
{/* Paid invoices */}
{paidInvoices.length > 0 && (
<div className="flex items-center gap-1">
<div className="w-2 h-2 rounded-full bg-success" />
<span className="text-xs text-muted-foreground truncate">
{paidInvoices.length} betald
</span>
</div>
)}
{/* Overdue deadlines */}
{deadlinesByStatus.overdue > 0 && (
<div className="flex items-center gap-1">
<div className={cn('w-2 h-2 rounded-sm', STATUS_COLORS.overdue.dot)} />
<span className="text-xs text-destructive truncate">
{deadlinesByStatus.overdue} försenad
</span>
</div>
)}
{/* Action needed deadlines */}
{deadlinesByStatus.action_needed > 0 && (
<div className="flex items-center gap-1">
<div className={cn('w-2 h-2 rounded-sm', STATUS_COLORS.action_needed.dot)} />
<span className="text-xs text-orange-700 truncate">
{deadlinesByStatus.action_needed} åtgärd
</span>
</div>
)}
{/* Upcoming deadlines */}
{deadlinesByStatus.upcoming > 0 && (
<div className="flex items-center gap-1">
<div className={cn('w-2 h-2 rounded-sm', STATUS_COLORS.upcoming.dot)} />
<span className="text-xs text-muted-foreground truncate">
{deadlinesByStatus.upcoming} deadline
</span>
</div>
)}
{/* In progress deadlines */}
{deadlinesByStatus.in_progress > 0 && (
<div className="flex items-center gap-1">
<div className={cn('w-2 h-2 rounded-sm', STATUS_COLORS.in_progress.dot)} />
<span className="text-xs text-yellow-700 truncate">
{deadlinesByStatus.in_progress} pågår
</span>
</div>
)}
{/* Submitted deadlines */}
{deadlinesByStatus.submitted > 0 && (
<div className="flex items-center gap-1">
<div className={cn('w-2 h-2 rounded-sm', STATUS_COLORS.submitted.dot)} />
<span className="text-xs text-purple-700 truncate">
{deadlinesByStatus.submitted} inskickad
</span>
</div>
)}
{/* Completed/Confirmed deadlines */}
{(deadlinesByStatus.completed > 0 || deadlinesByStatus.confirmed > 0) && (
<div className="flex items-center gap-1">
<div className={cn('w-2 h-2 rounded-sm', STATUS_COLORS.confirmed.dot)} />
<span className="text-xs text-muted-foreground truncate">
{(deadlinesByStatus.completed || 0) + (deadlinesByStatus.confirmed || 0)} klar
</span>
</div>
)}
</div>
)}
</button>
)
}
-245
View File
@@ -1,245 +0,0 @@
'use client'
import { Invoice, Deadline } from '@/types'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Plus } from 'lucide-react'
import {
formatDateISO,
isSameDay,
isInvoiceOverdue,
isDeadlineOverdue,
getTimeSlots,
formatDayViewHeader,
DEADLINE_TYPE_LABELS,
PRIORITY_LABELS,
} from '@/lib/calendar/utils'
interface CalendarDayViewProps {
date: Date
invoices: Invoice[]
deadlines: Deadline[]
onAddDeadline: (date: Date) => void
}
export function CalendarDayView({
date,
invoices,
deadlines,
onAddDeadline,
}: CalendarDayViewProps) {
const timeSlots = getTimeSlots(8, 20)
const today = new Date()
const isToday = isSameDay(date, today)
const dateStr = formatDateISO(date)
// Filter items for this day
const dayInvoices = invoices.filter(inv => inv.due_date === dateStr)
const dayDeadlines = deadlines.filter(d => d.due_date === dateStr)
// Categorize items
const activeInvoices = dayInvoices.filter(inv =>
inv.status !== 'paid' && inv.status !== 'cancelled' && inv.status !== 'credited'
)
const paidInvoices = dayInvoices.filter(inv => inv.status === 'paid')
const activeDeadlines = dayDeadlines.filter(d => !d.is_completed)
const completedDeadlines = dayDeadlines.filter(d => d.is_completed)
return (
<div className="border rounded-lg overflow-hidden">
{/* Header */}
<div className={cn(
'p-4 border-b flex items-center justify-between',
isToday && 'bg-primary/10'
)}>
<h3 className={cn(
'text-lg font-semibold capitalize',
isToday && 'text-primary'
)}>
{formatDayViewHeader(date)}
{isToday && <span className="ml-2 text-sm font-normal text-muted-foreground">(Idag)</span>}
</h3>
<Button
variant="outline"
size="sm"
onClick={() => onAddDeadline(date)}
>
<Plus className="h-4 w-4 mr-1" />
Lägg till deadline
</Button>
</div>
{/* All-day events section */}
<div className="border-b">
<div className="p-2 bg-muted/30">
<div className="text-xs font-medium text-muted-foreground mb-2">Hela dagen</div>
{/* Active invoices */}
{activeInvoices.length > 0 && (
<div className="space-y-1 mb-2">
{activeInvoices.map((invoice) => (
<div
key={invoice.id}
className={cn(
'p-2 rounded-md',
isInvoiceOverdue(invoice)
? 'bg-destructive/10 border border-destructive/30'
: 'bg-primary/10 border border-primary/30'
)}
>
<div className="flex items-center gap-2">
<div className={cn(
'w-3 h-3 rounded-full flex-shrink-0',
isInvoiceOverdue(invoice) ? 'bg-destructive' : 'bg-primary'
)} />
<div className="flex-1 min-w-0">
<div className={cn(
'text-sm font-medium',
isInvoiceOverdue(invoice) ? 'text-destructive' : 'text-primary'
)}>
Faktura {invoice.invoice_number}
{isInvoiceOverdue(invoice) && (
<span className="ml-2 text-xs bg-destructive/20 px-1.5 py-0.5 rounded">
Förfallen
</span>
)}
</div>
<div className="text-xs text-muted-foreground">
{invoice.customer?.name} • {(invoice.total_sek || invoice.total).toLocaleString('sv-SE')} kr
</div>
</div>
</div>
</div>
))}
</div>
)}
{/* Paid invoices */}
{paidInvoices.length > 0 && (
<div className="space-y-1 mb-2">
{paidInvoices.map((invoice) => (
<div
key={invoice.id}
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-success flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-success">
Faktura {invoice.invoice_number}
<span className="ml-2 text-xs bg-success/20 px-1.5 py-0.5 rounded">
Betald
</span>
</div>
<div className="text-xs text-muted-foreground">
{invoice.customer?.name} • {(invoice.total_sek || invoice.total).toLocaleString('sv-SE')} kr
</div>
</div>
</div>
</div>
))}
</div>
)}
{/* Active deadlines */}
{activeDeadlines.length > 0 && (
<div className="space-y-1 mb-2">
{activeDeadlines.map((deadline) => (
<div
key={deadline.id}
className={cn(
'p-2 rounded-md',
isDeadlineOverdue(deadline)
? 'bg-destructive/10 border border-destructive/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-warning'
)} />
<div className="flex-1 min-w-0">
<div className={cn(
'text-sm font-medium',
isDeadlineOverdue(deadline) ? 'text-destructive' : 'text-warning-foreground'
)}>
{deadline.title}
{isDeadlineOverdue(deadline) && (
<span className="ml-2 text-xs bg-destructive/20 px-1.5 py-0.5 rounded">
Försenad
</span>
)}
</div>
<div className="text-xs text-muted-foreground">
{DEADLINE_TYPE_LABELS[deadline.deadline_type] || deadline.deadline_type}
{deadline.priority !== 'normal' && (
<span className={cn(
'ml-2 px-1.5 py-0.5 rounded',
deadline.priority === 'critical' && 'bg-red-100 text-red-700',
deadline.priority === 'important' && 'bg-orange-100 text-orange-700'
)}>
{PRIORITY_LABELS[deadline.priority]}
</span>
)}
{deadline.customer?.name && ` • ${deadline.customer.name}`}
</div>
</div>
</div>
</div>
))}
</div>
)}
{/* Completed deadlines */}
{completedDeadlines.length > 0 && (
<div className="space-y-1">
{completedDeadlines.map((deadline) => (
<div
key={deadline.id}
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-success flex-shrink-0" />
<div className="flex-1 min-w-0">
<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-success/20 px-1.5 py-0.5 rounded">Klar</span>
</div>
</div>
</div>
</div>
))}
</div>
)}
{/* Empty state */}
{dayInvoices.length === 0 && dayDeadlines.length === 0 && (
<div className="text-sm text-muted-foreground py-4 text-center">
Inga händelser för denna dag
</div>
)}
</div>
</div>
{/* Time grid */}
<div className="max-h-[400px] overflow-y-auto">
{timeSlots.map((time) => (
<button
key={time}
onClick={() => onAddDeadline(date)}
className="w-full flex border-b last:border-b-0 hover:bg-muted/50 transition-colors"
>
<div className="w-16 p-2 border-r text-xs text-muted-foreground text-right pr-2 flex-shrink-0">
{time}
</div>
<div className="flex-1 min-h-[40px]" />
</button>
))}
</div>
</div>
)
}
-55
View File
@@ -1,55 +0,0 @@
'use client'
import { Invoice, Deadline } from '@/types'
import { SWEDISH_DAYS, getMonthGrid } from '@/lib/calendar/utils'
import { CalendarDayCell } from './CalendarDayCell'
interface CalendarGridProps {
year: number
month: number
invoices: Invoice[]
deadlines: Deadline[]
onDayClick: (date: Date) => void
}
export function CalendarGrid({
year,
month,
invoices,
deadlines,
onDayClick,
}: CalendarGridProps) {
const weeks = getMonthGrid(year, month)
return (
<div className="border rounded-lg overflow-hidden">
{/* Day headers */}
<div className="grid grid-cols-7 bg-muted">
{SWEDISH_DAYS.map((day) => (
<div
key={day}
className="py-2 text-center text-sm font-medium text-muted-foreground border-r last:border-r-0"
>
{day}
</div>
))}
</div>
{/* Week rows */}
{weeks.map((week, weekIndex) => (
<div key={weekIndex} className="grid grid-cols-7">
{week.map((date, dayIndex) => (
<CalendarDayCell
key={dayIndex}
date={date}
currentMonth={month}
invoices={invoices}
deadlines={deadlines}
onDayClick={onDayClick}
/>
))}
</div>
))}
</div>
)
}
-85
View File
@@ -1,85 +0,0 @@
'use client'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { CalendarViewMode } from '@/types'
import {
SWEDISH_MONTHS,
getWeekNumber,
formatDayViewHeader,
} from '@/lib/calendar/utils'
import { ViewModeSelector } from './ViewModeSelector'
interface CalendarHeaderProps {
year: number
month: number
currentDate?: Date
viewMode?: CalendarViewMode
onPrevious: () => void
onNext: () => void
onToday: () => void
onViewModeChange?: (mode: CalendarViewMode) => void
}
export function CalendarHeader({
year,
month,
currentDate,
viewMode = 'month',
onPrevious,
onNext,
onToday,
onViewModeChange,
}: CalendarHeaderProps) {
// Generate title based on view mode
const getTitle = () => {
switch (viewMode) {
case 'week':
if (currentDate) {
const weekNum = getWeekNumber(currentDate)
return `Vecka ${weekNum}, ${currentDate.getFullYear()}`
}
return `${SWEDISH_MONTHS[month]} ${year}`
case 'day':
if (currentDate) {
return formatDayViewHeader(currentDate)
}
return `${SWEDISH_MONTHS[month]} ${year}`
case 'month':
default:
return `${SWEDISH_MONTHS[month]} ${year}`
}
}
return (
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Button variant="outline" size="icon" onClick={onPrevious}>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button variant="outline" size="icon" onClick={onNext}>
<ChevronRight className="h-4 w-4" />
</Button>
<Button variant="outline" size="sm" onClick={onToday}>
Idag
</Button>
</div>
<h2 className="text-xl font-semibold capitalize">
{getTitle()}
</h2>
{onViewModeChange && (
<ViewModeSelector
viewMode={viewMode}
onViewModeChange={onViewModeChange}
/>
)}
{/* Spacer when no view mode selector */}
{!onViewModeChange && <div />}
</div>
)
}
-157
View File
@@ -1,157 +0,0 @@
'use client'
import { Invoice, Deadline } from '@/types'
import { cn } from '@/lib/utils'
import {
getWeekDays,
formatWeekDayHeader,
formatDateISO,
isSameDay,
isInvoiceOverdue,
isDeadlineOverdue,
getTimeSlots,
} from '@/lib/calendar/utils'
interface CalendarWeekViewProps {
currentDate: Date
invoices: Invoice[]
deadlines: Deadline[]
onDayClick: (date: Date) => void
}
export function CalendarWeekView({
currentDate,
invoices,
deadlines,
onDayClick,
}: CalendarWeekViewProps) {
const weekDays = getWeekDays(currentDate)
const timeSlots = getTimeSlots(8, 20)
const today = new Date()
// Group invoices and deadlines by date for quick lookup
const getItemsForDate = (date: Date) => {
const dateStr = formatDateISO(date)
const dayInvoices = invoices.filter(inv => inv.due_date === dateStr)
const dayDeadlines = deadlines.filter(d => d.due_date === dateStr)
return { invoices: dayInvoices, deadlines: dayDeadlines }
}
return (
<div className="border rounded-lg overflow-hidden">
{/* Header row with day names and dates */}
<div className="grid grid-cols-[60px_repeat(7,1fr)] bg-muted border-b">
<div className="p-2 border-r" /> {/* Empty corner cell */}
{weekDays.map((date, index) => {
const isToday = isSameDay(date, today)
return (
<button
key={index}
onClick={() => onDayClick(date)}
className={cn(
'p-2 text-center border-r last:border-r-0 hover:bg-muted/80 transition-colors',
isToday && 'bg-primary/10'
)}
>
<div className={cn(
'text-sm font-medium',
isToday && 'text-primary font-bold'
)}>
{formatWeekDayHeader(date)}
</div>
</button>
)
})}
</div>
{/* All-day events section */}
<div className="grid grid-cols-[60px_repeat(7,1fr)] border-b bg-muted/30">
<div className="p-2 border-r text-xs text-muted-foreground">
Heldag
</div>
{weekDays.map((date, index) => {
const items = getItemsForDate(date)
const isToday = isSameDay(date, today)
// Filter to only show non-timed items (all invoices and deadlines without times are "all-day")
const allDayInvoices = items.invoices.filter(inv =>
inv.status !== 'paid' && inv.status !== 'cancelled' && inv.status !== 'credited'
)
const allDayDeadlines = items.deadlines.filter(d => !d.is_completed)
return (
<div
key={index}
className={cn(
'min-h-[60px] p-1 border-r last:border-r-0',
isToday && 'bg-primary/5'
)}
>
{/* Invoice indicators */}
{allDayInvoices.map((invoice) => (
<div
key={invoice.id}
className={cn(
'text-xs p-1 mb-1 rounded truncate',
isInvoiceOverdue(invoice)
? 'bg-destructive/20 text-destructive'
: 'bg-primary/20 text-primary'
)}
title={`Faktura ${invoice.invoice_number} - ${invoice.customer?.name || ''}`}
>
<div className="flex items-center gap-1">
<div className="w-2 h-2 rounded-full bg-current flex-shrink-0" />
<span className="truncate">F#{invoice.invoice_number}</span>
</div>
</div>
))}
{/* Deadline indicators */}
{allDayDeadlines.map((deadline) => (
<div
key={deadline.id}
className={cn(
'text-xs p-1 mb-1 rounded truncate',
isDeadlineOverdue(deadline)
? 'bg-destructive/20 text-destructive'
: 'bg-warning/20 text-warning-foreground'
)}
title={deadline.title}
>
<div className="flex items-center gap-1">
<div className="w-2 h-2 rounded-sm bg-current flex-shrink-0" />
<span className="truncate">{deadline.title}</span>
</div>
</div>
))}
</div>
)
})}
</div>
{/* Time grid */}
<div className="max-h-[500px] overflow-y-auto">
{timeSlots.map((time, timeIndex) => (
<div key={time} className="grid grid-cols-[60px_repeat(7,1fr)] border-b last:border-b-0">
<div className="p-2 border-r text-xs text-muted-foreground text-right pr-2">
{time}
</div>
{weekDays.map((date, dayIndex) => {
const isToday = isSameDay(date, today)
return (
<button
key={dayIndex}
onClick={() => onDayClick(date)}
className={cn(
'min-h-[40px] border-r last:border-r-0 hover:bg-muted/50 transition-colors',
isToday && 'bg-primary/5'
)}
/>
)
})}
</div>
))}
</div>
</div>
)
}
-240
View File
@@ -1,240 +0,0 @@
'use client'
import Link from 'next/link'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Invoice, Deadline } from '@/types'
import { formatCurrency, formatDate } from '@/lib/utils'
import {
isInvoiceOverdue,
isDeadlineOverdue,
formatDateISO,
DEADLINE_TYPE_LABELS,
PRIORITY_LABELS,
PRIORITY_COLORS,
} from '@/lib/calendar/utils'
import { FileText, CheckCircle, Clock, AlertTriangle, Plus } from 'lucide-react'
interface DayDetailModalProps {
date: Date | null
invoices: Invoice[]
deadlines: Deadline[]
open: boolean
onOpenChange: (open: boolean) => void
onAddDeadline: (date: Date) => void
onToggleDeadline: (deadline: Deadline) => void
}
export function DayDetailModal({
date,
invoices,
deadlines,
open,
onOpenChange,
onAddDeadline,
onToggleDeadline,
}: DayDetailModalProps) {
if (!date) return null
const dateStr = formatDateISO(date)
const dayInvoices = invoices.filter(inv => inv.due_date === dateStr)
const dayDeadlines = deadlines.filter(d => d.due_date === dateStr)
const formattedDate = date.toLocaleDateString('sv-SE', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
})
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="capitalize">{formattedDate}</DialogTitle>
</DialogHeader>
<div className="space-y-6">
{/* Invoices section */}
{dayInvoices.length > 0 && (
<div>
<h3 className="text-sm font-medium text-muted-foreground mb-2">
Fakturor ({dayInvoices.length})
</h3>
<div className="space-y-2">
{dayInvoices.map((invoice) => {
const overdue = isInvoiceOverdue(invoice)
const paid = invoice.status === 'paid'
return (
<Link
key={invoice.id}
href={`/invoices/${invoice.id}`}
className="block"
>
<div
className={`p-3 rounded-lg border transition-colors hover:bg-muted/50 ${
overdue ? 'border-destructive/50 bg-destructive/5' :
paid ? 'border-success/50 bg-success/5' :
'border-border'
}`}
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-start gap-2">
{overdue ? (
<AlertTriangle className="h-4 w-4 text-destructive mt-0.5" />
) : paid ? (
<CheckCircle className="h-4 w-4 text-success mt-0.5" />
) : (
<FileText className="h-4 w-4 text-muted-foreground mt-0.5" />
)}
<div>
<p className="font-medium text-sm">
{invoice.invoice_number}
</p>
<p className="text-xs text-muted-foreground">
{(invoice.customer as { name: string })?.name || 'Okänd kund'}
</p>
</div>
</div>
<div className="text-right">
<p className="font-medium text-sm">
{formatCurrency(invoice.total, invoice.currency)}
</p>
<Badge
variant={
overdue ? 'destructive' :
paid ? 'success' :
'secondary'
}
className="text-xs"
>
{overdue ? 'Förfallen' : paid ? 'Betald' : 'Väntande'}
</Badge>
</div>
</div>
</div>
</Link>
)
})}
</div>
</div>
)}
{/* Deadlines section */}
<div>
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-medium text-muted-foreground">
Deadlines ({dayDeadlines.length})
</h3>
<Button
variant="ghost"
size="sm"
onClick={() => onAddDeadline(date)}
>
<Plus className="h-4 w-4 mr-1" />
Lägg till
</Button>
</div>
{dayDeadlines.length === 0 ? (
<p className="text-sm text-muted-foreground py-2">
Inga deadlines denna dag
</p>
) : (
<div className="space-y-2">
{dayDeadlines.map((deadline) => {
const overdue = isDeadlineOverdue(deadline)
const completed = deadline.is_completed
const priorityStyle = PRIORITY_COLORS[deadline.priority]
return (
<div
key={deadline.id}
className={`p-3 rounded-lg border transition-colors ${
overdue && !completed
? 'border-destructive/50 bg-destructive/5'
: completed
? 'border-success/50 bg-success/5'
: `${priorityStyle.border} ${priorityStyle.bg}`
}`}
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-start gap-2">
<button
onClick={() => onToggleDeadline(deadline)}
className="mt-0.5"
>
{completed ? (
<CheckCircle className="h-4 w-4 text-success" />
) : overdue ? (
<AlertTriangle className="h-4 w-4 text-destructive" />
) : (
<Clock className="h-4 w-4 text-muted-foreground" />
)}
</button>
<div>
<p className={`font-medium text-sm ${completed ? 'line-through text-muted-foreground' : ''}`}>
{deadline.title}
</p>
{deadline.due_time && (
<p className="text-xs text-muted-foreground">
kl. {deadline.due_time.slice(0, 5)}
</p>
)}
{deadline.customer && (
<p className="text-xs text-muted-foreground">
{deadline.customer.name}
</p>
)}
</div>
</div>
<div className="flex flex-col items-end gap-1">
<Badge variant="outline" className="text-xs">
{DEADLINE_TYPE_LABELS[deadline.deadline_type]}
</Badge>
{!completed && deadline.priority !== 'normal' && (
<Badge
variant={deadline.priority === 'critical' ? 'destructive' : 'warning'}
className="text-xs"
>
{PRIORITY_LABELS[deadline.priority]}
</Badge>
)}
</div>
</div>
</div>
)
})}
</div>
)}
</div>
{/* Empty state */}
{dayInvoices.length === 0 && dayDeadlines.length === 0 && (
<div className="text-center py-6">
<Clock className="h-8 w-8 text-muted-foreground mx-auto mb-2" />
<p className="text-sm text-muted-foreground">
Inga händelser denna dag
</p>
<Button
variant="outline"
size="sm"
className="mt-2"
onClick={() => onAddDeadline(date)}
>
<Plus className="h-4 w-4 mr-1" />
Lägg till deadline
</Button>
</div>
)}
</div>
</DialogContent>
</Dialog>
)
}
-200
View File
@@ -1,200 +0,0 @@
'use client'
import { useState, useCallback } from 'react'
import { Invoice, Deadline, CalendarViewMode } from '@/types'
import {
getWeekStart,
} from '@/lib/calendar/utils'
import { CalendarHeader } from './CalendarHeader'
import { CalendarGrid } from './CalendarGrid'
import { CalendarWeekView } from './CalendarWeekView'
import { CalendarDayView } from './CalendarDayView'
import { DayDetailModal } from './DayDetailModal'
import { DeadlineForm } from './DeadlineForm'
interface PaymentCalendarProps {
invoices: Invoice[]
deadlines: Deadline[]
customers: { id: string; name: string }[]
onDeadlineCreate: (data: Omit<Deadline, 'id' | 'user_id' | 'created_at' | 'updated_at'>) => Promise<void>
onDeadlineToggle: (deadline: Deadline) => Promise<void>
}
export function PaymentCalendar({
invoices,
deadlines,
customers,
onDeadlineCreate,
onDeadlineToggle,
}: PaymentCalendarProps) {
const today = new Date()
const [year, setYear] = useState(today.getFullYear())
const [month, setMonth] = useState(today.getMonth())
const [viewMode, setViewMode] = useState<CalendarViewMode>('month')
const [selectedDate, setSelectedDate] = useState<Date>(today)
const [showDayModal, setShowDayModal] = useState(false)
const [modalDate, setModalDate] = useState<Date | null>(null)
const [showDeadlineForm, setShowDeadlineForm] = useState(false)
const [deadlineFormDate, setDeadlineFormDate] = useState<Date | null>(null)
// Navigation handlers based on view mode
const handlePrevious = useCallback(() => {
if (viewMode === 'month') {
if (month === 0) {
setMonth(11)
setYear(year - 1)
} else {
setMonth(month - 1)
}
} else if (viewMode === 'week') {
const newDate = new Date(selectedDate)
newDate.setDate(newDate.getDate() - 7)
setSelectedDate(newDate)
setYear(newDate.getFullYear())
setMonth(newDate.getMonth())
} else if (viewMode === 'day') {
const newDate = new Date(selectedDate)
newDate.setDate(newDate.getDate() - 1)
setSelectedDate(newDate)
setYear(newDate.getFullYear())
setMonth(newDate.getMonth())
}
}, [viewMode, month, year, selectedDate])
const handleNext = useCallback(() => {
if (viewMode === 'month') {
if (month === 11) {
setMonth(0)
setYear(year + 1)
} else {
setMonth(month + 1)
}
} else if (viewMode === 'week') {
const newDate = new Date(selectedDate)
newDate.setDate(newDate.getDate() + 7)
setSelectedDate(newDate)
setYear(newDate.getFullYear())
setMonth(newDate.getMonth())
} else if (viewMode === 'day') {
const newDate = new Date(selectedDate)
newDate.setDate(newDate.getDate() + 1)
setSelectedDate(newDate)
setYear(newDate.getFullYear())
setMonth(newDate.getMonth())
}
}, [viewMode, month, year, selectedDate])
const handleToday = useCallback(() => {
const today = new Date()
setYear(today.getFullYear())
setMonth(today.getMonth())
setSelectedDate(today)
}, [])
const handleViewModeChange = useCallback((mode: CalendarViewMode) => {
setViewMode(mode)
// When switching to week view, ensure selectedDate is set properly
if (mode === 'week') {
const weekStart = getWeekStart(selectedDate)
setSelectedDate(weekStart)
}
}, [selectedDate])
const handleDayClick = useCallback((date: Date) => {
if (viewMode === 'month') {
// In month view, clicking a day shows the modal
setModalDate(date)
setShowDayModal(true)
} else if (viewMode === 'week') {
// In week view, clicking a day switches to day view
setSelectedDate(date)
setViewMode('day')
setYear(date.getFullYear())
setMonth(date.getMonth())
}
}, [viewMode])
const handleAddDeadline = useCallback((date: Date) => {
setDeadlineFormDate(date)
setShowDayModal(false)
setShowDeadlineForm(true)
}, [])
const handleDeadlineFormClose = useCallback(() => {
setShowDeadlineForm(false)
setDeadlineFormDate(null)
}, [])
const handleDeadlineSubmit = useCallback(async (data: Omit<Deadline, 'id' | 'user_id' | 'created_at' | 'updated_at'>) => {
await onDeadlineCreate(data)
handleDeadlineFormClose()
}, [onDeadlineCreate])
return (
<div className="space-y-4">
<div>
<CalendarHeader
year={year}
month={month}
currentDate={selectedDate}
viewMode={viewMode}
onPrevious={handlePrevious}
onNext={handleNext}
onToday={handleToday}
onViewModeChange={handleViewModeChange}
/>
{/* Month View */}
{viewMode === 'month' && (
<CalendarGrid
year={year}
month={month}
invoices={invoices}
deadlines={deadlines}
onDayClick={handleDayClick}
/>
)}
{/* Week View */}
{viewMode === 'week' && (
<CalendarWeekView
currentDate={selectedDate}
invoices={invoices}
deadlines={deadlines}
onDayClick={handleDayClick}
/>
)}
{/* Day View */}
{viewMode === 'day' && (
<CalendarDayView
date={selectedDate}
invoices={invoices}
deadlines={deadlines}
onAddDeadline={handleAddDeadline}
/>
)}
</div>
{/* Day detail modal (for month view clicks) */}
<DayDetailModal
date={modalDate}
invoices={invoices}
deadlines={deadlines}
open={showDayModal}
onOpenChange={setShowDayModal}
onAddDeadline={handleAddDeadline}
onToggleDeadline={onDeadlineToggle}
/>
{/* Deadline form dialog */}
<DeadlineForm
open={showDeadlineForm}
onOpenChange={handleDeadlineFormClose}
onSubmit={handleDeadlineSubmit}
initialDate={deadlineFormDate}
customers={customers}
/>
</div>
)
}
@@ -1,98 +0,0 @@
'use client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Invoice } from '@/types'
import { calculatePeriodSummary, SWEDISH_MONTHS } from '@/lib/calendar/utils'
import { formatCurrency } from '@/lib/utils'
import { TrendingUp, Clock, CheckCircle, AlertTriangle } from 'lucide-react'
interface PaymentSummaryCardProps {
invoices: Invoice[]
year: number
month: number
}
export function PaymentSummaryCard({ invoices, year, month }: PaymentSummaryCardProps) {
const summary = calculatePeriodSummary(invoices)
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-lg">
Sammanfattning {SWEDISH_MONTHS[month]} {year}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Expected */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<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>
<p className="text-sm font-medium">{summary.pendingCount} fakturor</p>
</div>
</div>
<p className="text-lg font-semibold">{formatCurrency(summary.totalExpected)}</p>
</div>
{/* Overdue */}
{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-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>
<p className="text-sm font-medium text-destructive">
{summary.overdueCount} fakturor
</p>
</div>
</div>
<p className="text-lg font-semibold text-destructive">
{formatCurrency(summary.totalOverdue)}
</p>
</div>
)}
{/* Paid */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<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>
<p className="text-sm font-medium">{summary.paidCount} fakturor</p>
</div>
</div>
<p className="text-lg font-semibold text-success">
{formatCurrency(summary.totalPaid)}
</p>
</div>
{/* Pending */}
{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-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>
<p className="text-sm font-medium">
{summary.pendingCount - summary.overdueCount} fakturor
</p>
</div>
</div>
<p className="text-lg font-semibold">
{formatCurrency(summary.totalExpected - summary.totalOverdue)}
</p>
</div>
)}
</CardContent>
</Card>
)
}
-34
View File
@@ -1,34 +0,0 @@
'use client'
import { CalendarViewMode } from '@/types'
import { VIEW_MODE_LABELS } from '@/lib/calendar/utils'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
interface ViewModeSelectorProps {
viewMode: CalendarViewMode
onViewModeChange: (mode: CalendarViewMode) => void
}
export function ViewModeSelector({ viewMode, onViewModeChange }: ViewModeSelectorProps) {
const modes: CalendarViewMode[] = ['month', 'week', 'day']
return (
<div className="inline-flex rounded-md border">
{modes.map((mode) => (
<Button
key={mode}
variant="ghost"
size="sm"
onClick={() => onViewModeChange(mode)}
className={cn(
'rounded-none border-r last:border-r-0 px-3',
viewMode === mode && 'bg-muted'
)}
>
{VIEW_MODE_LABELS[mode]}
</Button>
))}
</div>
)
}
-12
View File
@@ -1,12 +0,0 @@
export { CalendarHeader } from './CalendarHeader'
export { CalendarGrid } from './CalendarGrid'
export { CalendarDayCell } from './CalendarDayCell'
export { PaymentCalendar } from './PaymentCalendar'
export { PaymentSummaryCard } from './PaymentSummaryCard'
export { DayDetailModal } from './DayDetailModal'
export { DeadlineCard } from './DeadlineCard'
export { DeadlineFilters } from './DeadlineFilters'
export { DeadlineForm } from './DeadlineForm'
export { DeadlineList } from './DeadlineList'
export { UpcomingDeadlinesWidget } from './UpcomingDeadlinesWidget'
export { TaxTodoWidget } from './TaxTodoWidget'
+79 -17
View File
@@ -12,7 +12,8 @@ import {
getEnhancedTaxWarningStatus
} from '@/lib/tax/calculator'
import FSkattWarningCard from '@/components/dashboard/FSkattWarningCard'
import { UpcomingDeadlinesWidget } from '@/components/calendar/UpcomingDeadlinesWidget'
import { UpcomingDeadlinesWidget } from '@/components/deadlines/UpcomingDeadlinesWidget'
import { TaxTodoWidget } from '@/components/deadlines/TaxTodoWidget'
import NewUserChecklist from '@/components/onboarding/NewUserChecklist'
import {
TrendingUp,
@@ -27,9 +28,11 @@ import {
Landmark,
CheckCircle2,
ClipboardList,
MessageCircle,
FileWarning,
} from 'lucide-react'
import { getExtensionDefinition } from '@/lib/extensions/sectors'
import { resolveIcon } from '@/lib/extensions/icon-resolver'
import type { QuickActionDefinition } from '@/lib/extensions/types'
import type { CompanySettings, EntityType, Deadline, ReceiptQueueSummary, OnboardingProgress } from '@/types'
interface DashboardContentProps {
@@ -51,11 +54,31 @@ interface DashboardContentProps {
missingUnderlagCount: number
}
onboardingProgress?: OnboardingProgress
enabledExtensions?: { sector_slug: string; extension_slug: string }[]
}
export default function DashboardContent({ firstName, settings, summary, onboardingProgress }: DashboardContentProps) {
export default function DashboardContent({ firstName, settings, summary, onboardingProgress, enabledExtensions }: DashboardContentProps) {
const [showAllAlerts, setShowAllAlerts] = useState(false)
const [showMore, setShowMore] = useState(false)
const [liveExtensions, setLiveExtensions] = useState(enabledExtensions ?? [])
useEffect(() => {
setLiveExtensions(enabledExtensions ?? [])
}, [enabledExtensions])
useEffect(() => {
const handler = ((e: CustomEvent<{ sector_slug: string; extension_slug: string; enabled: boolean }>) => {
setLiveExtensions(prev => {
if (e.detail.enabled) {
if (prev.some(x => x.sector_slug === e.detail.sector_slug && x.extension_slug === e.detail.extension_slug)) return prev
return [...prev, { sector_slug: e.detail.sector_slug, extension_slug: e.detail.extension_slug }]
}
return prev.filter(x => !(x.sector_slug === e.detail.sector_slug && x.extension_slug === e.detail.extension_slug))
})
}) as EventListener
window.addEventListener('extension-toggle-changed', handler)
return () => window.removeEventListener('extension-toggle-changed', handler)
}, [])
const entityType = (settings?.entity_type as EntityType) || 'enskild_firma'
const preliminaryTaxMonthly = settings?.preliminary_tax_monthly || 0
@@ -205,7 +228,15 @@ export default function DashboardContent({ firstName, settings, summary, onboard
const visibleAlerts = showAllAlerts ? alertItems : alertItems.slice(0, MAX_VISIBLE_ALERTS)
const hasMoreAlerts = alertItems.length > MAX_VISIBLE_ALERTS
const openAiChat = () => window.dispatchEvent(new Event('open-ai-chat'))
// Build extension quick actions from enabled extensions
const extensionQuickActions: (QuickActionDefinition & { key: string })[] = liveExtensions
.map(toggle => {
const def = getExtensionDefinition(toggle.sector_slug, toggle.extension_slug)
if (!def?.quickAction) return null
return { ...def.quickAction, key: `${toggle.sector_slug}/${toggle.extension_slug}` }
})
.filter((a): a is QuickActionDefinition & { key: string } => a !== null)
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
// Quick action items
const quickActions = [
@@ -242,7 +273,7 @@ export default function DashboardContent({ firstName, settings, summary, onboard
const todoItems: { label: string; href: string; count: number; variant: 'destructive' | 'warning' | 'default' }[] = []
if (passedDeadlines.length > 0) {
todoItems.push({ label: 'passerade deadlines', href: '/calendar', count: passedDeadlines.length, variant: 'destructive' })
todoItems.push({ label: 'passerade deadlines', href: '/deadlines', count: passedDeadlines.length, variant: 'destructive' })
}
if (summary.overdueInvoicesCount > 0) {
todoItems.push({ label: 'förfallna fakturor', href: '/invoices?status=unpaid', count: summary.overdueInvoicesCount, variant: 'destructive' })
@@ -431,18 +462,42 @@ export default function DashboardContent({ firstName, settings, summary, onboard
</Link>
)
})}
{/* AI assistant quick action */}
<button onClick={openAiChat} className="group text-left">
<div className="flex items-center gap-3 px-4 py-3 rounded-xl border border-border/40 hover:bg-muted/30 transition-colors duration-150">
<div className="p-2 rounded-lg bg-muted/50">
<MessageCircle className="h-4 w-4 text-muted-foreground" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium truncate">AI-assistent</p>
<p className="text-xs text-muted-foreground truncate hidden md:block">Fråga om bokföring</p>
</div>
</div>
</button>
{/* Extension quick actions */}
{extensionQuickActions.map((action) => {
const Icon = resolveIcon(action.icon)
if (action.href) {
return (
<Link key={action.key} href={action.href} className="group">
<div className="flex items-center gap-3 px-4 py-3 rounded-xl border border-border/40 hover:bg-muted/30 transition-colors duration-150">
<div className="p-2 rounded-lg bg-muted/50">
<Icon className="h-4 w-4 text-muted-foreground" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium truncate">{action.label}</p>
<p className="text-xs text-muted-foreground truncate hidden md:block">{action.description}</p>
</div>
</div>
</Link>
)
}
return (
<button
key={action.key}
onClick={() => window.dispatchEvent(new Event(action.event!))}
className="group text-left"
>
<div className="flex items-center gap-3 px-4 py-3 rounded-xl border border-border/40 hover:bg-muted/30 transition-colors duration-150">
<div className="p-2 rounded-lg bg-muted/50">
<Icon className="h-4 w-4 text-muted-foreground" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium truncate">{action.label}</p>
<p className="text-xs text-muted-foreground truncate hidden md:block">{action.description}</p>
</div>
</div>
</button>
)
})}
</div>
</section>
@@ -453,6 +508,13 @@ export default function DashboardContent({ firstName, settings, summary, onboard
</section>
)}
{/* Tax todo widget — visible when there are incomplete tax deadlines */}
{summary.deadlines?.some(d => d.deadline_type === 'tax' && !d.is_completed) && (
<section className="mb-10">
<TaxTodoWidget deadlines={summary.deadlines} />
</section>
)}
{/* Alerts section — always visible */}
{alertItems.length > 0 && (
<section id="alerts-section" className="mb-10">
+1 -1
View File
@@ -46,7 +46,7 @@ interface NavItem {
// All nav items for sidebar and mobile drawer
const navItems: NavItem[] = [
{ href: '/', label: 'Översikt', icon: LayoutDashboard, group: 'main' },
{ href: '/calendar', label: 'Kalender', icon: Calendar, group: 'main' },
{ href: '/deadlines', label: 'Deadlines', icon: Calendar, group: 'main' },
{ href: '/invoices', label: 'Fakturor', icon: Receipt, group: 'finans' },
{ href: '/customers', label: 'Kunder', icon: Users, group: 'finans' },
{ href: '/suppliers', label: 'Leverantörer', icon: Building2, group: 'finans' },
@@ -231,9 +231,9 @@ export function TaxTodoWidget({ deadlines, onStatusChange }: TaxTodoWidgetProps)
</p>
)}
<Link href="/calendar" className="block">
<Link href="/deadlines" className="block">
<Button variant="ghost" className="w-full justify-between">
Visa alla skattedeadlines
Visa alla deadlines
<ChevronRight className="h-4 w-4" />
</Button>
</Link>
@@ -201,9 +201,9 @@ export function UpcomingDeadlinesWidget({ deadlines, maxItems = 5, onStatusChang
)
})}
<Link href="/calendar" className="block">
<Link href="/deadlines" className="block">
<Button variant="ghost" className="w-full justify-between mt-2">
Visa kalender
Visa alla deadlines
<ChevronRight className="h-4 w-4" />
</Button>
</Link>
+6
View File
@@ -0,0 +1,6 @@
export { DeadlineCard } from './DeadlineCard'
export { DeadlineFilters } from './DeadlineFilters'
export { DeadlineForm } from './DeadlineForm'
export { DeadlineList } from './DeadlineList'
export { UpcomingDeadlinesWidget } from './UpcomingDeadlinesWidget'
export { TaxTodoWidget } from './TaxTodoWidget'
@@ -0,0 +1,129 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { createClient } from '@/lib/supabase/client'
import { useToast } from '@/components/ui/use-toast'
import { PaymentCalendar } from '@/extensions/general/calendar/components/PaymentCalendar'
import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
import type { Invoice, Deadline } from '@/types'
export default function CalendarWorkspace({ userId }: WorkspaceComponentProps) {
const [invoices, setInvoices] = useState<Invoice[]>([])
const [deadlines, setDeadlines] = useState<Deadline[]>([])
const [customers, setCustomers] = useState<{ id: string; name: string }[]>([])
const [isLoading, setIsLoading] = useState(true)
const { toast } = useToast()
const supabase = createClient()
const fetchData = useCallback(async () => {
setIsLoading(true)
try {
const { data: invoicesData, error: invoicesError } = await supabase
.from('invoices')
.select('*, customer:customers(name)')
.order('due_date', { ascending: true })
if (invoicesError) throw invoicesError
const { data: deadlinesData, error: deadlinesError } = await supabase
.from('deadlines')
.select('*, customer:customers(name)')
.order('due_date', { ascending: true })
if (deadlinesError) throw deadlinesError
const { data: customersData, error: customersError } = await supabase
.from('customers')
.select('id, name')
.order('name', { ascending: true })
if (customersError) throw customersError
setInvoices(invoicesData || [])
setDeadlines(deadlinesData || [])
setCustomers(customersData || [])
} catch {
toast({
title: 'Fel',
description: 'Kunde inte hamta data',
variant: 'destructive',
})
} finally {
setIsLoading(false)
}
}, [supabase, toast])
useEffect(() => {
fetchData()
}, [fetchData])
const handleDeadlineCreate = async (
data: Omit<Deadline, 'id' | 'user_id' | 'created_at' | 'updated_at'>
) => {
try {
const { error } = await supabase.from('deadlines').insert([data])
if (error) throw error
toast({
title: 'Deadline skapad',
description: 'Din deadline har sparats',
})
fetchData()
} catch (error) {
toast({
title: 'Fel',
description: 'Kunde inte skapa deadline',
variant: 'destructive',
})
throw error
}
}
const handleDeadlineToggle = async (deadline: Deadline) => {
try {
const { error } = await supabase
.from('deadlines')
.update({
is_completed: !deadline.is_completed,
completed_at: !deadline.is_completed ? new Date().toISOString() : null,
})
.eq('id', deadline.id)
if (error) throw error
toast({
title: deadline.is_completed ? 'Markerad som ej klar' : 'Markerad som klar',
})
fetchData()
} catch {
toast({
title: 'Fel',
description: 'Kunde inte uppdatera deadline',
variant: 'destructive',
})
}
}
if (isLoading) {
return (
<div className="animate-pulse">
<div className="h-10 bg-muted rounded w-48 mb-4" />
<div className="h-96 bg-muted rounded" />
</div>
)
}
return (
<PaymentCalendar
invoices={invoices}
deadlines={deadlines}
customers={customers}
onDeadlineCreate={handleDeadlineCreate}
onDeadlineToggle={handleDeadlineToggle}
/>
)
}
@@ -0,0 +1,15 @@
'use client'
import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
import EmptyExtensionState from '@/components/extensions/shared/EmptyExtensionState'
import { TextSearch } from 'lucide-react'
export default function UserDescriptionMatchWorkspace({ userId }: WorkspaceComponentProps) {
return (
<EmptyExtensionState
title="Beskrivningsmatchning"
description="Beskriv transaktioner med egna ord vid kategorisering. Systemet lär sig automatiskt och applicerar på framtida transaktioner från samma leverantör."
icon={<TextSearch className="h-12 w-12 text-muted-foreground/40 mb-4" />}
/>
)
}
@@ -0,0 +1,539 @@
'use client'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent } from '@/components/ui/card'
import { Textarea } from '@/components/ui/textarea'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog'
import { useToast } from '@/components/ui/use-toast'
import { formatCurrency, formatDate } from '@/lib/utils'
import {
ArrowUpRight,
ArrowDownRight,
Loader2,
Search,
ArrowLeft,
Check,
CheckCircle2,
AlertTriangle,
} from 'lucide-react'
import JournalEntryPreview from './JournalEntryPreview'
import { formatAccountWithName } from '@/lib/bookkeeping/client-account-names'
import type { TransactionWithInvoice } from './transaction-types'
interface TemplateMatch {
template_id: string
name_sv: string
name_en: string
group: string
debit_account: string
credit_account: string
confidence: number
description_sv: string
vat_rate: number
vat_treatment: string | null
deductibility: 'full' | 'non_deductible' | 'conditional'
deductibility_note_sv: string | null
special_rules_sv: string | null
risk_level: string
}
interface DescribeResult {
templates: TemplateMatch[]
needs_more_detail: boolean
user_description: string
batch_candidate_count: number
merchant_name: string | null
}
interface DescribeTransactionDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
transaction: TransactionWithInvoice | null
onCategorized: (transactionId: string, journalEntryId: string | null) => void
onBatchApplied?: (count: number) => void
}
type Step = 'describe' | 'pick' | 'batch'
function getExamplePrompts(transaction: TransactionWithInvoice): string[] {
const desc = (transaction.description || '').toLowerCase()
const isExpense = transaction.amount < 0
if (!isExpense) {
return ['Konsultarvode', 'Forsaljning av varor', 'Aterbetalning']
}
// Contextual suggestions based on description keywords
if (desc.includes('restaurang') || desc.includes('lunch') || desc.includes('middag') || desc.includes('mat')) {
return ['Lunch med kund', 'Personalmiddag', 'Fika till kontoret']
}
if (desc.includes('hotel') || desc.includes('hotell') || desc.includes('boende') || desc.includes('resa')) {
return ['Tjansteresa', 'Hotell konferens', 'Flygbiljett']
}
if (desc.includes('uber') || desc.includes('taxi') || desc.includes('bolt') || desc.includes('sj ')) {
return ['Taxi till kund', 'Tjansteresa', 'Pendling']
}
if (desc.includes('google') || desc.includes('meta') || desc.includes('facebook') || desc.includes('linkedin')) {
return ['Online-annonsering', 'SaaS-prenumeration', 'Marknadsforingskampanj']
}
if (desc.includes('amazon') || desc.includes('aws') || desc.includes('azure') || desc.includes('cloud')) {
return ['Serverhosting', 'SaaS-prenumeration', 'Kontorsmaterial']
}
// Generic expense suggestions
return ['Kontorsmaterial', 'SaaS-prenumeration', 'Konsulttjanst', 'Reklam']
}
export default function DescribeTransactionDialog({
open,
onOpenChange,
transaction,
onCategorized,
onBatchApplied,
}: DescribeTransactionDialogProps) {
const { toast } = useToast()
const [step, setStep] = useState<Step>('describe')
const [description, setDescription] = useState('')
const [isSearching, setIsSearching] = useState(false)
const [isBooking, setIsBooking] = useState(false)
const [isBatchApplying, setIsBatchApplying] = useState(false)
const [describeResult, setDescribeResult] = useState<DescribeResult | null>(null)
const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(null)
function resetState() {
setStep('describe')
setDescription('')
setIsSearching(false)
setIsBooking(false)
setIsBatchApplying(false)
setDescribeResult(null)
setSelectedTemplateId(null)
}
function handleOpenChange(isOpen: boolean) {
if (!isOpen) {
resetState()
}
onOpenChange(isOpen)
}
async function handleSearch() {
if (!transaction || description.trim().length < 3) return
setIsSearching(true)
try {
const response = await fetch(`/api/transactions/${transaction.id}/describe`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ description: description.trim() }),
})
const result = await response.json()
if (!response.ok) {
toast({
title: 'Fel',
description: result.error || 'Kunde inte soka mallar',
variant: 'destructive',
})
setIsSearching(false)
return
}
setDescribeResult(result.data)
setSelectedTemplateId(null)
setStep('pick')
} catch {
toast({
title: 'Fel',
description: 'Nagot gick fel vid sokning',
variant: 'destructive',
})
}
setIsSearching(false)
}
async function handleBook() {
if (!transaction || !selectedTemplateId || !describeResult) return
setIsBooking(true)
try {
const response = await fetch(`/api/transactions/${transaction.id}/categorize`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
is_business: true,
template_id: selectedTemplateId,
user_description: describeResult.user_description,
}),
})
const result = await response.json()
if (!response.ok) {
toast({
title: 'Fel',
description: result.error || 'Kunde inte bokfora transaktion',
variant: 'destructive',
})
setIsBooking(false)
return
}
if (describeResult.batch_candidate_count > 0) {
setStep('batch')
setIsBooking(false)
onCategorized(transaction.id, result.journal_entry_id || null)
} else {
toast({ title: 'Bokford', description: 'Transaktion bokford och verifikation skapad' })
onCategorized(transaction.id, result.journal_entry_id || null)
handleOpenChange(false)
}
} catch {
toast({
title: 'Fel',
description: 'Nagot gick fel vid bokforing',
variant: 'destructive',
})
setIsBooking(false)
}
}
async function handleBatchApply() {
if (!describeResult || !selectedTemplateId) return
setIsBatchApplying(true)
try {
const response = await fetch('/api/transactions/batch-describe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
merchant_name: describeResult.merchant_name,
template_id: selectedTemplateId,
is_business: true,
user_description: describeResult.user_description,
}),
})
const result = await response.json()
if (!response.ok) {
toast({
title: 'Fel',
description: result.error || 'Kunde inte bokfora batch',
variant: 'destructive',
})
setIsBatchApplying(false)
return
}
const applied = result.data?.applied || 0
const errors = result.data?.errors || []
if (errors.length > 0) {
toast({
title: 'Delvis klart',
description: `${applied} lyckades, ${errors.length} misslyckades`,
variant: 'destructive',
})
} else {
toast({
title: 'Klart',
description: `${applied} transaktioner bokforda`,
})
}
onBatchApplied?.(applied)
handleOpenChange(false)
} catch {
toast({
title: 'Fel',
description: 'Nagot gick fel vid batchbokforing',
variant: 'destructive',
})
setIsBatchApplying(false)
}
}
function handleSkipBatch() {
toast({ title: 'Bokford', description: 'Transaktion bokford och verifikation skapad' })
handleOpenChange(false)
}
if (!transaction) return null
const isIncome = transaction.amount > 0
return (
<Dialog open={open} onOpenChange={(isBooking || isBatchApplying) ? undefined : handleOpenChange}>
<DialogContent className="max-w-md max-h-[90vh] flex flex-col">
<DialogHeader>
<DialogTitle>
{step === 'describe' && 'Beskriv transaktion'}
{step === 'pick' && 'Valj mall'}
{step === 'batch' && 'Bokfor liknande'}
</DialogTitle>
<DialogDescription>
{step === 'describe' && 'Beskriv vad transaktionen galler sa hittar vi ratt bokforingsmall'}
{step === 'pick' && 'Valj den mall som stammer bast'}
{step === 'batch' && 'Transaktion bokford!'}
</DialogDescription>
</DialogHeader>
{/* Transaction summary - shown in describe and pick steps */}
{(step === 'describe' || step === 'pick') && (
<div className="flex items-center gap-3 rounded-lg border p-3">
<div
className={`h-9 w-9 rounded-full flex items-center justify-center flex-shrink-0 ${
isIncome
? 'bg-success/10 text-success'
: 'bg-destructive/10 text-destructive'
}`}
>
{isIncome ? (
<ArrowUpRight className="h-4 w-4" />
) : (
<ArrowDownRight className="h-4 w-4" />
)}
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-sm truncate">{transaction.description}</p>
<p className="text-xs text-muted-foreground">{formatDate(transaction.date)}</p>
</div>
<p className={`font-medium text-sm flex-shrink-0 ${isIncome ? 'text-success' : ''}`}>
{isIncome ? '+' : ''}
{formatCurrency(transaction.amount, transaction.currency)}
</p>
</div>
)}
{/* Step 1: Describe */}
{step === 'describe' && (
<div className="space-y-4">
<Textarea
placeholder="Beskriv vad transaktionen galler, t.ex. 'lunch med kund' eller 'kontorsmaterial'"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey && description.trim().length >= 3) {
e.preventDefault()
handleSearch()
}
}}
/>
<div className="flex flex-wrap gap-1.5">
{getExamplePrompts(transaction).map((prompt) => (
<button
key={prompt}
type="button"
className="text-xs px-2.5 py-1 rounded-full border bg-muted/50 hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
onClick={() => setDescription(prompt)}
>
{prompt}
</button>
))}
</div>
<Button
className="w-full"
disabled={description.trim().length < 3 || isSearching}
onClick={handleSearch}
>
{isSearching ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Search className="mr-2 h-4 w-4" />
)}
{isSearching ? 'Soker...' : 'Sok'}
</Button>
</div>
)}
{/* Step 2: Pick template */}
{step === 'pick' && describeResult && (
<div className="space-y-4 min-h-0 flex flex-col">
{describeResult.needs_more_detail && (
<div className="flex items-start gap-2 p-3 rounded-lg bg-amber-500/10 text-amber-700 dark:text-amber-400 text-sm">
<AlertTriangle className="h-4 w-4 flex-shrink-0 mt-0.5" />
<p>Resultaten ar osakra. Forsok beskriv mer detaljerat for battre traffar.</p>
</div>
)}
<div className="overflow-y-auto max-h-[40vh] space-y-2 pr-1">
{describeResult.templates.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">
Inga matchande mallar hittades. Forsok med en annan beskrivning.
</p>
) : (
describeResult.templates.map((template) => (
<Card
key={template.template_id}
className={`cursor-pointer transition-colors hover:border-primary/50 ${
selectedTemplateId === template.template_id
? 'border-primary bg-primary/5'
: ''
}`}
onClick={() => setSelectedTemplateId(template.template_id)}
>
<CardContent className="py-3 px-4">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<p className="font-medium text-sm">{template.name_sv}</p>
{template.description_sv && (
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">
{template.description_sv}
</p>
)}
<div className="flex flex-wrap items-center gap-1.5 mt-1.5">
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
D: {formatAccountWithName(template.debit_account)}
</Badge>
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
K: {formatAccountWithName(template.credit_account)}
</Badge>
{template.vat_treatment && template.vat_treatment !== 'exempt' && (
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
Moms {Math.round(template.vat_rate * 100)}%
</Badge>
)}
{template.vat_treatment === 'exempt' && (
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
Momsfritt
</Badge>
)}
{template.deductibility === 'non_deductible' && (
<Badge variant="outline" className="text-[10px] px-1.5 py-0 text-amber-600 border-amber-300">
Ej avdragsgill
</Badge>
)}
{template.deductibility === 'conditional' && (
<Badge variant="outline" className="text-[10px] px-1.5 py-0 text-amber-600 border-amber-300">
Villkorligt avdrag
</Badge>
)}
</div>
{(template.deductibility_note_sv || template.special_rules_sv) && selectedTemplateId === template.template_id && (
<div className="mt-2 space-y-1">
{template.deductibility_note_sv && (
<p className="text-[11px] text-amber-600 dark:text-amber-400">
{template.deductibility_note_sv}
</p>
)}
{template.special_rules_sv && (
<p className="text-[11px] text-muted-foreground italic">
{template.special_rules_sv}
</p>
)}
</div>
)}
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<Badge
variant={template.confidence >= 0.7 ? 'default' : 'outline'}
className="text-[10px] px-1.5 py-0"
>
{Math.round(template.confidence * 100)}%
</Badge>
{selectedTemplateId === template.template_id && (
<Check className="h-4 w-4 text-primary" />
)}
</div>
</div>
</CardContent>
</Card>
))
)}
</div>
{/* Journal entry preview for selected template */}
{selectedTemplateId && (() => {
const tmpl = describeResult.templates.find(t => t.template_id === selectedTemplateId)
if (!tmpl) return null
return (
<JournalEntryPreview
amount={transaction.amount}
currency={transaction.currency}
templateDebitAccount={tmpl.debit_account}
templateCreditAccount={tmpl.credit_account}
templateVatRate={tmpl.vat_rate}
/>
)
})()}
<div className="flex gap-2 pt-2">
<Button
variant="ghost"
className="flex-shrink-0"
onClick={() => {
setStep('describe')
setSelectedTemplateId(null)
}}
>
<ArrowLeft className="mr-2 h-4 w-4" />
Beskriv igen
</Button>
<Button
className="flex-1"
disabled={!selectedTemplateId || isBooking}
onClick={handleBook}
>
{isBooking ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Check className="mr-2 h-4 w-4" />
)}
{isBooking ? 'Bokfor...' : 'Bokfor'}
</Button>
</div>
</div>
)}
{/* Step 3: Batch offer */}
{step === 'batch' && describeResult && (
<div className="space-y-4">
<div className="flex items-center gap-3 p-4 rounded-lg bg-success/10">
<CheckCircle2 className="h-6 w-6 text-success flex-shrink-0" />
<p className="text-sm font-medium">Transaktionen ar bokford!</p>
</div>
<p className="text-sm text-muted-foreground">
Det finns ytterligare{' '}
<span className="font-medium text-foreground">
{describeResult.batch_candidate_count}
</span>{' '}
obokforda transaktioner fran{' '}
<span className="font-medium text-foreground">
{describeResult.merchant_name}
</span>
. Anvand samma mall?
</p>
<div className="flex gap-2">
<Button
variant="outline"
className="flex-1"
onClick={handleSkipBatch}
disabled={isBatchApplying}
>
Nej, bara den har
</Button>
<Button
className="flex-1"
onClick={handleBatchApply}
disabled={isBatchApplying}
>
{isBatchApplying ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : null}
{isBatchApplying
? 'Bokfor...'
: `Ja, bokfor alla ${describeResult.batch_candidate_count} st`}
</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
)
}
@@ -3,6 +3,7 @@
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'
import { formatCurrency, formatDate } from '@/lib/utils'
import { CheckCircle2, AlertTriangle } from 'lucide-react'
import type { TransactionWithInvoice } from './transaction-types'
interface InvoiceMatchDialogProps {
@@ -66,6 +67,37 @@ export default function InvoiceMatchDialog({
</div>
</div>
{/* Amount comparison */}
{(() => {
const txAmount = transaction.amount
const invAmount = transaction.potential_invoice!.total
const sameCurrency = transaction.currency === transaction.potential_invoice!.currency
const amountsMatch = sameCurrency && Math.abs(txAmount - invAmount) < 0.01
if (amountsMatch) {
return (
<div className="flex items-center gap-2 p-3 rounded-lg bg-success/10 text-success">
<CheckCircle2 className="h-4 w-4 flex-shrink-0" />
<p className="text-sm font-medium">Beloppen stammer</p>
</div>
)
}
const diff = Math.abs(txAmount - invAmount)
return (
<div className="flex items-start gap-2 p-3 rounded-lg bg-amber-500/10 text-amber-700 dark:text-amber-400">
<AlertTriangle className="h-4 w-4 flex-shrink-0 mt-0.5" />
<div className="text-sm">
<p className="font-medium">Beloppen skiljer sig</p>
<p>
Differens: {formatCurrency(diff, transaction.currency)}
{!sameCurrency && ' (olika valutor)'}
</p>
</div>
</div>
)
})()}
{/* What will happen */}
<div className="rounded-lg bg-muted/50 p-4 space-y-2">
<p className="text-sm font-medium">Vid bekräftelse:</p>
@@ -0,0 +1,114 @@
'use client'
import { useMemo } from 'react'
import { formatCurrency } from '@/lib/utils'
import { formatAccountWithName } from '@/lib/bookkeeping/client-account-names'
import { getVatRate, extractVatAmount, extractNetAmount } from '@/lib/bookkeeping/vat-entries'
import { getCategoryAccountMapping } from '@/lib/bookkeeping/category-mapping'
import type { TransactionCategory, VatTreatment } from '@/types'
interface PreviewLine {
side: 'debet' | 'kredit'
account: string
amount: number
}
interface JournalEntryPreviewProps {
amount: number
currency?: string
category?: TransactionCategory
vatTreatment?: VatTreatment | 'none'
accountOverride?: string
/** For template-based bookings — overrides category mapping */
templateDebitAccount?: string
templateCreditAccount?: string
templateVatRate?: number
}
export default function JournalEntryPreview({
amount,
currency = 'SEK',
category,
vatTreatment,
accountOverride,
templateDebitAccount,
templateCreditAccount,
templateVatRate,
}: JournalEntryPreviewProps) {
const lines = useMemo(() => {
const result: PreviewLine[] = []
const absAmount = Math.abs(amount)
// Template-based preview
if (templateDebitAccount && templateCreditAccount) {
const vatRate = templateVatRate ?? 0
const vatAmt = extractVatAmount(absAmount, vatRate)
const netAmt = extractNetAmount(absAmount, vatRate)
result.push({ side: 'debet', account: templateDebitAccount, amount: netAmt })
if (vatAmt > 0) {
result.push({ side: 'debet', account: '2641', amount: vatAmt })
}
result.push({ side: 'kredit', account: templateCreditAccount, amount: absAmount })
return result
}
// Category-based preview
if (!category) return result
const resolvedVat = vatTreatment === 'none' ? undefined : vatTreatment
const mapping = getCategoryAccountMapping(category, amount, category !== 'private', 'enskild_firma', resolvedVat)
const debitAccount = accountOverride && amount < 0 ? accountOverride : mapping.debitAccount
const creditAccount = accountOverride && amount > 0 ? accountOverride : mapping.creditAccount
const treatment = mapping.vatTreatment as VatTreatment | null
const vatRate = treatment ? getVatRate(treatment) : 0
const vatAmt = vatRate > 0 ? extractVatAmount(absAmount, vatRate) : 0
const netAmt = vatRate > 0 ? extractNetAmount(absAmount, vatRate) : absAmount
if (amount < 0) {
// Expense: Debit expense + VAT, Credit bank
result.push({ side: 'debet', account: debitAccount, amount: netAmt })
if (vatAmt > 0 && mapping.vatDebitAccount) {
result.push({ side: 'debet', account: mapping.vatDebitAccount, amount: vatAmt })
}
result.push({ side: 'kredit', account: creditAccount, amount: absAmount })
} else {
// Income: Debit bank, Credit revenue + VAT
result.push({ side: 'debet', account: debitAccount, amount: absAmount })
if (vatAmt > 0 && mapping.vatCreditAccount) {
result.push({ side: 'kredit', account: mapping.vatCreditAccount, amount: vatAmt })
}
result.push({ side: 'kredit', account: creditAccount, amount: netAmt })
}
// Reverse charge: add offsetting lines
if (treatment === 'reverse_charge' && amount < 0) {
const rcVatAmt = Math.round(absAmount * 0.25 * 100) / 100
result.push({ side: 'debet', account: '2645', amount: rcVatAmt })
result.push({ side: 'kredit', account: '2614', amount: rcVatAmt })
}
return result
}, [amount, category, vatTreatment, accountOverride, templateDebitAccount, templateCreditAccount, templateVatRate])
if (lines.length === 0) return null
return (
<div className="rounded-lg border bg-muted/30 px-3 py-2.5">
<p className="text-xs font-medium text-muted-foreground mb-1.5">Verifikation</p>
<div className="space-y-0.5 font-mono text-xs">
{lines.map((line, i) => (
<div key={i} className="flex items-baseline gap-2">
<span className={`w-12 text-right flex-shrink-0 ${line.side === 'debet' ? 'text-foreground' : 'text-muted-foreground'}`}>
{line.side === 'debet' ? 'Debet' : 'Kredit'}
</span>
<span className="flex-1 truncate">{formatAccountWithName(line.account)}</span>
<span className="flex-shrink-0 tabular-nums">{formatCurrency(line.amount, currency)}</span>
</div>
))}
</div>
</div>
)
}
+32 -8
View File
@@ -8,10 +8,12 @@ import { useToast } from '@/components/ui/use-toast'
import { formatCurrency, formatDate } from '@/lib/utils'
import { ArrowUpRight, ArrowDownRight, Check, Paperclip, ChevronDown, ChevronUp } from 'lucide-react'
import { getDefaultAccountForCategory } from '@/lib/bookkeeping/category-mapping'
import JournalEntryPreview from './JournalEntryPreview'
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
import DocumentUploadZone from '@/components/bookkeeping/DocumentUploadZone'
import type { UploadedFile } from '@/components/bookkeeping/DocumentUploadZone'
import VatTreatmentSelect from './VatTreatmentSelect'
import { VAT_TREATMENT_OPTIONS } from './transaction-types'
import type { TransactionWithInvoice } from './transaction-types'
import type { TransactionCategory, VatTreatment, BASAccount } from '@/types'
@@ -49,6 +51,7 @@ export default function QuickReviewDialog({
const [error, setError] = useState<string | null>(null)
const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([])
const [showUploadZone, setShowUploadZone] = useState(false)
const [showVatDropdown, setShowVatDropdown] = useState(false)
// Handle account changes — clear VAT for liability/equity accounts (class 2)
const handleAccountChange = useCallback((account: string) => {
@@ -174,6 +177,15 @@ export default function QuickReviewDialog({
</div>
</div>
{/* Journal entry preview */}
<JournalEntryPreview
amount={transaction.amount}
currency={transaction.currency}
category={category}
vatTreatment={isLiabilityAccount ? 'none' : vatTreatment}
accountOverride={accountOverride}
/>
{/* Account */}
<div>
<label className="text-sm font-medium text-muted-foreground">Konto</label>
@@ -190,14 +202,26 @@ export default function QuickReviewDialog({
<div>
<label className="text-sm font-medium text-muted-foreground">Momsbehandling</label>
<div className="mt-1">
<VatTreatmentSelect
value={isLiabilityAccount ? 'none' : vatTreatment}
onValueChange={setVatTreatment}
disabled={isLiabilityAccount}
/>
{isLiabilityAccount && (
<p className="text-xs text-muted-foreground mt-1">
Ingen moms för skuld-/eget kapital-konton
{isLiabilityAccount ? (
<p className="text-sm text-muted-foreground">
Ingen moms for skuld-/eget kapital-konton
</p>
) : showVatDropdown ? (
<VatTreatmentSelect
value={vatTreatment}
onValueChange={setVatTreatment}
/>
) : (
<p className="text-sm">
{VAT_TREATMENT_OPTIONS.find(o => o.value === vatTreatment)?.label || 'Ingen moms'}
{' '}
<button
type="button"
className="text-xs text-primary hover:underline"
onClick={() => setShowVatDropdown(true)}
>
Andra
</button>
</p>
)}
</div>
@@ -10,18 +10,22 @@ import VatTreatmentSelect from './VatTreatmentSelect'
import { formatCurrency, formatDate } from '@/lib/utils'
import { checkExpenseWarnings } from '@/lib/tax/expense-warnings'
import { getDefaultAccountForCategory, getDefaultVatTreatmentForCategory } from '@/lib/bookkeeping/category-mapping'
import JournalEntryPreview from './JournalEntryPreview'
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
import DocumentUploadZone from '@/components/bookkeeping/DocumentUploadZone'
import type { UploadedFile } from '@/components/bookkeeping/DocumentUploadZone'
import { X, ArrowLeft, ArrowRight, Building, AlertTriangle, Check, FileText, Link2, Receipt as ReceiptIcon, SkipForward, Paperclip, ChevronDown, ChevronUp } from 'lucide-react'
import { X, ArrowLeft, ArrowRight, Building, AlertTriangle, Check, FileText, Link2, Receipt as ReceiptIcon, SkipForward, Paperclip, ChevronDown, ChevronUp, MessageSquareText } from 'lucide-react'
import DescribeTransactionDialog from './DescribeTransactionDialog'
import { formatAccountWithName } from '@/lib/bookkeeping/client-account-names'
import type { TransactionCategory, VatTreatment, BASAccount } from '@/types'
import type { SuggestedCategory } from '@/lib/transactions/category-suggestions'
import type { SuggestedCategory, SuggestedTemplate } from '@/lib/transactions/category-suggestions'
import type { TransactionWithInvoice, CategorizeHandler, MatchInvoiceHandler } from './transaction-types'
import { EXPENSE_CATEGORIES, INCOME_CATEGORIES } from './transaction-types'
import { EXPENSE_CATEGORIES, INCOME_CATEGORIES, VAT_TREATMENT_OPTIONS } from './transaction-types'
interface SwipeCategorizationViewProps {
transactions: TransactionWithInvoice[]
suggestions?: Record<string, SuggestedCategory[]>
templateSuggestions?: Record<string, SuggestedTemplate[]>
onCategorize: CategorizeHandler
onMatchInvoice?: MatchInvoiceHandler
onClose: () => void
@@ -33,6 +37,7 @@ const incomeCategories = INCOME_CATEGORIES
export default function SwipeCategorizationView({
transactions,
suggestions,
templateSuggestions,
onCategorize,
onMatchInvoice,
onClose,
@@ -52,6 +57,8 @@ export default function SwipeCategorizationView({
const [accounts, setAccounts] = useState<BASAccount[]>([])
const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([])
const [showUploadZone, setShowUploadZone] = useState(false)
const [showDescribeDialog, setShowDescribeDialog] = useState(false)
const [showVatDropdown, setShowVatDropdown] = useState(false)
// Clear VAT treatment when switching to a liability/equity account (class 2)
useEffect(() => {
@@ -113,6 +120,7 @@ export default function SwipeCategorizationView({
setPendingCategory(category)
setAccountOverride(defaultAccount)
setVatTreatment(defaultVat ?? 'none')
setShowVatDropdown(false)
setShowCategorySelect(false)
setShowReviewStep(true)
setError(null)
@@ -366,6 +374,15 @@ export default function SwipeCategorizationView({
</div>
</div>
{/* Journal entry preview */}
<JournalEntryPreview
amount={currentTransaction.amount}
currency={currentTransaction.currency}
category={pendingCategory}
vatTreatment={isLiabilityAccount ? 'none' : vatTreatment}
accountOverride={accountOverride}
/>
{/* Account override */}
<div>
<label className="text-sm font-medium text-muted-foreground">Konto</label>
@@ -382,15 +399,27 @@ export default function SwipeCategorizationView({
<div>
<label className="text-sm font-medium text-muted-foreground">Momsbehandling</label>
<div className="mt-1">
<VatTreatmentSelect
value={isLiabilityAccount ? 'none' : vatTreatment}
onValueChange={setVatTreatment}
disabled={isLiabilityAccount}
/>
{isLiabilityAccount && (
<p className="text-xs text-muted-foreground mt-1">
{isLiabilityAccount ? (
<p className="text-sm text-muted-foreground">
Ingen moms for skuld-/eget kapital-konton
</p>
) : showVatDropdown ? (
<VatTreatmentSelect
value={vatTreatment}
onValueChange={setVatTreatment}
/>
) : (
<p className="text-sm">
{VAT_TREATMENT_OPTIONS.find(o => o.value === vatTreatment)?.label || 'Ingen moms'}
{' '}
<button
type="button"
className="text-xs text-primary hover:underline"
onClick={() => setShowVatDropdown(true)}
>
Andra
</button>
</p>
)}
</div>
</div>
@@ -641,7 +670,7 @@ export default function SwipeCategorizationView({
<div className="flex flex-col">
<span className="text-sm font-medium">{suggestion.label}</span>
{suggestion.account && (
<span className="text-xs text-muted-foreground">{suggestion.account}</span>
<span className="text-xs text-muted-foreground">{formatAccountWithName(suggestion.account)}</span>
)}
</div>
</Button>
@@ -650,6 +679,45 @@ export default function SwipeCategorizationView({
</div>
)}
{/* Fallback templates when no strong suggestion */}
{(() => {
const txSuggestions = suggestions?.[currentTransaction.id]
const topConfidence = txSuggestions?.[0]?.confidence ?? 0
const templates = templateSuggestions?.[currentTransaction.id]
if (topConfidence < 0.55 && templates && templates.length > 0) {
return (
<div className="space-y-2">
<p className="text-xs text-muted-foreground text-center">Osaker? Prova dessa mallar:</p>
<div className="grid grid-cols-2 gap-2">
{templates.slice(0, 3).map((tmpl) => (
<Button
key={tmpl.template_id}
variant="outline"
className="h-auto py-2.5 px-3 text-left justify-start border-dashed"
onClick={() => setShowDescribeDialog(true)}
disabled={isProcessing}
>
<span className="text-sm">{tmpl.name_sv}</span>
</Button>
))}
</div>
</div>
)
}
return null
})()}
{/* Describe transaction button */}
<Button
variant="outline"
className="w-full"
onClick={() => setShowDescribeDialog(true)}
disabled={isProcessing}
>
<MessageSquareText className="mr-2 h-4 w-4" />
Beskriv transaktion...
</Button>
{/* Categorization button */}
<Button
className="w-full"
@@ -678,6 +746,20 @@ export default function SwipeCategorizationView({
</Button>
</div>
</div>
<DescribeTransactionDialog
open={showDescribeDialog}
onOpenChange={setShowDescribeDialog}
transaction={currentTransaction}
onCategorized={() => {
setShowDescribeDialog(false)
moveToNext()
}}
onBatchApplied={() => {
setShowDescribeDialog(false)
moveToNext()
}}
/>
</div>
)
}
@@ -6,14 +6,16 @@ import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Checkbox } from '@/components/ui/checkbox'
import { formatCurrency, formatDate } from '@/lib/utils'
import { ArrowUpRight, ArrowDownRight, FileText, Loader2 } from 'lucide-react'
import { ArrowUpRight, ArrowDownRight, FileText, Loader2, MessageSquareText } from 'lucide-react'
import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@/components/ui/info-tooltip'
import { formatAccountWithName } from '@/lib/bookkeeping/client-account-names'
import type { TransactionWithInvoice, CategorizeHandler } from './transaction-types'
import type { SuggestedCategory } from '@/lib/transactions/category-suggestions'
import type { SuggestedCategory, SuggestedTemplate } from '@/lib/transactions/category-suggestions'
interface TransactionInboxCardProps {
transaction: TransactionWithInvoice
suggestions?: SuggestedCategory[]
templateSuggestions?: SuggestedTemplate[]
processingId: string | null
isBatchMode: boolean
isSelected: boolean
@@ -22,6 +24,7 @@ interface TransactionInboxCardProps {
onMarkPrivate: (id: string) => void
onOpenMatchDialog: (transaction: TransactionWithInvoice) => void
onOpenCategoryDialog: (transaction: TransactionWithInvoice) => void
onOpenDescribe?: (transaction: TransactionWithInvoice) => void
onOpenQuickReview?: (transaction: TransactionWithInvoice, suggestion: SuggestedCategory) => void
onToggleSelect: (id: string) => void
onAnimationComplete?: (id: string) => void
@@ -30,6 +33,7 @@ interface TransactionInboxCardProps {
export default function TransactionInboxCard({
transaction,
suggestions,
templateSuggestions,
processingId,
isBatchMode,
isSelected,
@@ -38,6 +42,7 @@ export default function TransactionInboxCard({
onMarkPrivate,
onOpenMatchDialog,
onOpenCategoryDialog,
onOpenDescribe,
onOpenQuickReview,
onToggleSelect,
onAnimationComplete,
@@ -49,6 +54,8 @@ export default function TransactionInboxCard({
const topSuggestion = suggestions?.[0]
const isUncategorized = transaction.is_business === null && !transaction.journal_entry_id
const showCheckbox = isBatchMode && isUncategorized
const hasWeakSuggestions = !topSuggestion || topSuggestion.confidence < 0.55
const showTemplateFallback = hasWeakSuggestions && templateSuggestions && templateSuggestions.length > 0
function handleSuggestionClick(suggestion: SuggestedCategory) {
if (onOpenQuickReview) {
@@ -155,6 +162,11 @@ export default function TransactionInboxCard({
<Loader2 className="mr-1.5 h-3 w-3 animate-spin" />
) : null}
{topSuggestion.label}
{topSuggestion.account && (
<span className="ml-1 text-muted-foreground font-normal">
({formatAccountWithName(topSuggestion.account)})
</span>
)}
{topSuggestion.confidence >= 0.8 && (
<Badge variant="secondary" className="ml-1.5 text-[10px] px-1 py-0">
{Math.round(topSuggestion.confidence * 100)}%
@@ -173,9 +185,33 @@ export default function TransactionInboxCard({
disabled={isProcessing || isDisabled}
>
{suggestions[1].label}
{suggestions[1].account && (
<span className="ml-1 text-muted-foreground font-normal">
({formatAccountWithName(suggestions[1].account)})
</span>
)}
</Button>
)}
{/* Fallback templates when no strong suggestion */}
{showTemplateFallback && !hasInvoiceMatch && (
<>
<span className="text-[10px] text-muted-foreground">Osaker? Prova:</span>
{templateSuggestions!.slice(0, 3).map((tmpl) => (
<Button
key={tmpl.template_id}
size="sm"
variant="outline"
className="h-8 text-xs border-dashed"
onClick={() => onOpenDescribe?.(transaction)}
disabled={isProcessing || isDisabled}
>
{tmpl.name_sv}
</Button>
))}
</>
)}
{/* Private button */}
<TooltipProvider delayDuration={300}>
<Tooltip>
@@ -200,6 +236,20 @@ export default function TransactionInboxCard({
</Tooltip>
</TooltipProvider>
{/* Describe transaction */}
{onOpenDescribe && (
<Button
size="sm"
variant="outline"
className="h-8 text-xs"
onClick={() => onOpenDescribe(transaction)}
disabled={isProcessing || isDisabled}
>
<MessageSquareText className="mr-1.5 h-3 w-3" />
Beskriv...
</Button>
)}
{/* Open category dialog */}
<Button
size="sm"
@@ -32,10 +32,14 @@ export interface CategoryOption {
// Shared category arrays
export const EXPENSE_CATEGORIES: CategoryOption[] = [
{ value: 'expense_representation', label: 'Representation' },
{ value: 'expense_equipment', label: 'Utrustning' },
{ value: 'expense_software', label: 'Programvara' },
{ value: 'expense_consumables', label: 'Material' },
{ value: 'expense_travel', label: 'Resor' },
{ value: 'expense_office', label: 'Kontor' },
{ value: 'expense_vehicle', label: 'Bil & drivmedel' },
{ value: 'expense_telecom', label: 'Telefon & internet' },
{ value: 'expense_marketing', label: 'Marknadsföring' },
{ value: 'expense_professional_services', label: 'Konsulter' },
{ value: 'expense_education', label: 'Utbildning' },