New classification logic etc
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user