Merge remote-tracking branch 'origin/main' into export-biz
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
+2
-2
@@ -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>
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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' },
|
||||
|
||||
Reference in New Issue
Block a user