New classification logic etc

This commit is contained in:
Jakob Wennberg
2026-02-24 11:33:22 +01:00
parent a3903f8428
commit 6cef4e11eb
41 changed files with 1193 additions and 134 deletions
@@ -0,0 +1,165 @@
'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>
)
}
@@ -0,0 +1,245 @@
'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>
)
}
@@ -0,0 +1,55 @@
'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>
)
}
@@ -0,0 +1,85 @@
'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>
)
}
@@ -0,0 +1,157 @@
'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>
)
}
@@ -0,0 +1,240 @@
'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>
)
}
@@ -0,0 +1,200 @@
'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 '@/components/deadlines/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>
)
}
@@ -0,0 +1,98 @@
'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>
)
}
@@ -0,0 +1,34 @@
'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>
)
}