Files
accounted/lib/calendar/utils.ts
T
Jakob Wennberg 0aecf5b41c Refactor to generic ERP base
Remove influencer-specific features (campaigns, TikTok, gifts, shadow ledger,
contracts, briefings) and consolidate into a clean ERP foundation with core
bookkeeping, invoicing, receipts, tax reporting, and calendar functionality.
Reorganize database migrations into a clean numbered sequence.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 15:39:13 +01:00

477 lines
15 KiB
TypeScript

import { Invoice, Deadline, PaymentCalendarDay } from '@/types'
// Swedish day names (Monday first)
export const SWEDISH_DAYS = ['Mån', 'Tis', 'Ons', 'Tor', 'Fre', 'Lör', 'Sön']
// Swedish month names
export const SWEDISH_MONTHS = [
'Januari', 'Februari', 'Mars', 'April', 'Maj', 'Juni',
'Juli', 'Augusti', 'September', 'Oktober', 'November', 'December'
]
// Parse ISO date string to Date object at start of day
export function parseDate(dateString: string): Date {
const [year, month, day] = dateString.split('-').map(Number)
return new Date(year, month - 1, day)
}
// Format date as ISO string (YYYY-MM-DD)
export function formatDateISO(date: Date): string {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
// Get start of day
export function startOfDay(date: Date): Date {
return new Date(date.getFullYear(), date.getMonth(), date.getDate())
}
// Check if date is before another
export function isBefore(date1: Date, date2: Date): boolean {
return startOfDay(date1).getTime() < startOfDay(date2).getTime()
}
// Check if two dates are the same day
export function isSameDay(date1: Date, date2: Date): boolean {
return (
date1.getFullYear() === date2.getFullYear() &&
date1.getMonth() === date2.getMonth() &&
date1.getDate() === date2.getDate()
)
}
// Check if invoice is overdue
export function isInvoiceOverdue(invoice: Invoice): boolean {
if (invoice.status === 'paid' || invoice.status === 'cancelled' || invoice.status === 'credited') {
return false
}
const today = startOfDay(new Date())
const dueDate = parseDate(invoice.due_date)
return isBefore(dueDate, today)
}
// Check if deadline is overdue
export function isDeadlineOverdue(deadline: Deadline): boolean {
if (deadline.is_completed) return false
const today = startOfDay(new Date())
const dueDate = parseDate(deadline.due_date)
return isBefore(dueDate, today)
}
// Get all days in a month as a grid (including padding days from prev/next months)
export function getMonthGrid(year: number, month: number): Date[][] {
const firstDay = new Date(year, month, 1)
const lastDay = new Date(year, month + 1, 0)
// Get day of week (0 = Sunday, 1 = Monday, etc.)
// Convert to Monday-first (0 = Monday, 6 = Sunday)
let startDayOfWeek = firstDay.getDay() - 1
if (startDayOfWeek < 0) startDayOfWeek = 6
const daysInMonth = lastDay.getDate()
const weeks: Date[][] = []
let currentWeek: Date[] = []
// Add padding days from previous month
const prevMonth = new Date(year, month, 0)
const prevMonthDays = prevMonth.getDate()
for (let i = startDayOfWeek - 1; i >= 0; i--) {
currentWeek.push(new Date(year, month - 1, prevMonthDays - i))
}
// Add days of current month
for (let day = 1; day <= daysInMonth; day++) {
currentWeek.push(new Date(year, month, day))
if (currentWeek.length === 7) {
weeks.push(currentWeek)
currentWeek = []
}
}
// Add padding days from next month
if (currentWeek.length > 0) {
let nextDay = 1
while (currentWeek.length < 7) {
currentWeek.push(new Date(year, month + 1, nextDay++))
}
weeks.push(currentWeek)
}
return weeks
}
// Group invoices by due date
export function groupInvoicesByDate(invoices: Invoice[]): Map<string, Invoice[]> {
const grouped = new Map<string, Invoice[]>()
for (const invoice of invoices) {
const dateKey = invoice.due_date
const existing = grouped.get(dateKey) || []
existing.push(invoice)
grouped.set(dateKey, existing)
}
return grouped
}
// Group deadlines by due date
export function groupDeadlinesByDate(deadlines: Deadline[]): Map<string, Deadline[]> {
const grouped = new Map<string, Deadline[]>()
for (const deadline of deadlines) {
const dateKey = deadline.due_date
const existing = grouped.get(dateKey) || []
existing.push(deadline)
grouped.set(dateKey, existing)
}
return grouped
}
// Create PaymentCalendarDay from invoices for a specific date
export function createPaymentCalendarDay(date: string, invoices: Invoice[]): PaymentCalendarDay {
const dayInvoices = invoices.filter(inv => inv.due_date === date)
const overdueCount = dayInvoices.filter(isInvoiceOverdue).length
const totalExpected = dayInvoices
.filter(inv => inv.status !== 'paid' && inv.status !== 'cancelled' && inv.status !== 'credited')
.reduce((sum, inv) => sum + (inv.total_sek || inv.total), 0)
return {
date,
invoices: dayInvoices,
totalExpected,
overdueCount
}
}
// Get invoices for a month
export function getInvoicesForMonth(invoices: Invoice[], year: number, month: number): Invoice[] {
const monthStr = String(month + 1).padStart(2, '0')
const prefix = `${year}-${monthStr}`
return invoices.filter(inv => inv.due_date.startsWith(prefix))
}
// Get deadlines for a month
export function getDeadlinesForMonth(deadlines: Deadline[], year: number, month: number): Deadline[] {
const monthStr = String(month + 1).padStart(2, '0')
const prefix = `${year}-${monthStr}`
return deadlines.filter(d => d.due_date.startsWith(prefix))
}
// Calculate summary for a period
export interface PeriodSummary {
totalExpected: number
totalOverdue: number
totalPaid: number
overdueCount: number
pendingCount: number
paidCount: number
}
export function calculatePeriodSummary(invoices: Invoice[]): PeriodSummary {
let totalExpected = 0
let totalOverdue = 0
let totalPaid = 0
let overdueCount = 0
let pendingCount = 0
let paidCount = 0
for (const invoice of invoices) {
const amount = invoice.total_sek || invoice.total
if (invoice.status === 'paid') {
totalPaid += amount
paidCount++
} else if (invoice.status !== 'cancelled' && invoice.status !== 'credited') {
totalExpected += amount
pendingCount++
if (isInvoiceOverdue(invoice)) {
totalOverdue += amount
overdueCount++
}
}
}
return {
totalExpected,
totalOverdue,
totalPaid,
overdueCount,
pendingCount,
paidCount
}
}
// Get upcoming deadlines (next N days)
export function getUpcomingDeadlines(deadlines: Deadline[], days: number = 7): Deadline[] {
const today = startOfDay(new Date())
const endDate = new Date(today)
endDate.setDate(endDate.getDate() + days)
return deadlines
.filter(d => {
if (d.is_completed) return false
const dueDate = parseDate(d.due_date)
return dueDate >= today && dueDate <= endDate
})
.sort((a, b) => a.due_date.localeCompare(b.due_date))
}
// Swedish deadline type labels
export const DEADLINE_TYPE_LABELS: Record<string, string> = {
delivery: 'Leverans',
approval: 'Godkännande',
invoicing: 'Fakturering',
report: 'Rapport',
revision: 'Revision',
other: 'Övrigt'
}
// Swedish priority labels
export const PRIORITY_LABELS: Record<string, string> = {
critical: 'Kritisk',
important: 'Viktig',
normal: 'Normal'
}
// Priority colors for styling
export const PRIORITY_COLORS: Record<string, { bg: string; text: string; border: string }> = {
critical: { bg: 'bg-red-50', text: 'text-red-700', border: 'border-red-200' },
important: { bg: 'bg-orange-50', text: 'text-orange-700', border: 'border-orange-200' },
normal: { bg: 'bg-gray-50', text: 'text-gray-700', border: 'border-gray-200' }
}
// ============================================================
// Status-aware deadline functions
// ============================================================
import type { DeadlineStatus } from '@/types'
// Status labels in Swedish
export const STATUS_LABELS: Record<DeadlineStatus, string> = {
upcoming: 'Kommande',
action_needed: 'Åtgärd krävs',
in_progress: 'Pågår',
submitted: 'Inskickad',
confirmed: 'Bekräftad',
overdue: 'Försenad'
}
// Status colors for styling
export const STATUS_COLORS: Record<DeadlineStatus, { bg: string; text: string; border: string; dot: string }> = {
upcoming: { bg: 'bg-blue-50', text: 'text-blue-700', border: 'border-blue-200', dot: 'bg-blue-500' },
action_needed: { bg: 'bg-orange-50', text: 'text-orange-700', border: 'border-orange-200', dot: 'bg-orange-500' },
in_progress: { bg: 'bg-yellow-50', text: 'text-yellow-700', border: 'border-yellow-200', dot: 'bg-yellow-500' },
submitted: { bg: 'bg-purple-50', text: 'text-purple-700', border: 'border-purple-200', dot: 'bg-purple-500' },
confirmed: { bg: 'bg-green-50', text: 'text-green-700', border: 'border-green-200', dot: 'bg-green-500' },
overdue: { bg: 'bg-red-50', text: 'text-red-700', border: 'border-red-200', dot: 'bg-red-500' }
}
// Get deadlines needing attention (action_needed or overdue)
export function getDeadlinesNeedingAttention(deadlines: Deadline[]): Deadline[] {
return deadlines.filter(d =>
!d.is_completed &&
(d.status === 'action_needed' || d.status === 'overdue')
).sort((a, b) => {
// Sort overdue first, then by date
if (a.status === 'overdue' && b.status !== 'overdue') return -1
if (b.status === 'overdue' && a.status !== 'overdue') return 1
return a.due_date.localeCompare(b.due_date)
})
}
// Get tax deadlines only
export function getTaxDeadlines(deadlines: Deadline[]): Deadline[] {
return deadlines.filter(d => d.deadline_type === 'tax')
}
// Get tax deadlines needing attention
export function getTaxDeadlinesNeedingAttention(deadlines: Deadline[]): Deadline[] {
return getDeadlinesNeedingAttention(deadlines).filter(d => d.deadline_type === 'tax')
}
// Count deadlines by status
export function countDeadlinesByStatus(deadlines: Deadline[]): Record<DeadlineStatus, number> {
const counts: Record<DeadlineStatus, number> = {
upcoming: 0,
action_needed: 0,
in_progress: 0,
submitted: 0,
confirmed: 0,
overdue: 0
}
for (const d of deadlines) {
if (!d.is_completed && d.status) {
counts[d.status]++
}
}
return counts
}
// Get all calendar events for a month (unified view)
export interface CalendarEvent {
id: string
type: 'invoice' | 'deadline'
date: string
title: string
subtitle?: string
status?: string
priority?: string
isOverdue?: boolean
customerId?: string
}
export function getCalendarEventsForMonth(
invoices: Invoice[],
deadlines: Deadline[],
year: number,
month: number
): CalendarEvent[] {
const events: CalendarEvent[] = []
const monthStr = String(month + 1).padStart(2, '0')
const prefix = `${year}-${monthStr}`
const today = formatDateISO(new Date())
// Invoice due dates
for (const inv of invoices) {
if (inv.due_date.startsWith(prefix) && !['paid', 'cancelled', 'credited'].includes(inv.status)) {
events.push({
id: `inv-${inv.id}`,
type: 'invoice',
date: inv.due_date,
title: `Faktura ${inv.invoice_number}`,
subtitle: inv.customer?.name,
status: inv.status,
isOverdue: inv.due_date < today && !['paid', 'cancelled', 'credited'].includes(inv.status),
customerId: inv.customer_id
})
}
}
// Deadlines
for (const d of deadlines) {
if (d.due_date.startsWith(prefix) && !d.is_completed) {
events.push({
id: `dl-${d.id}`,
type: 'deadline',
date: d.due_date,
title: d.title,
subtitle: d.customer?.name,
priority: d.priority,
isOverdue: d.due_date < today,
customerId: d.customer_id || undefined
})
}
}
// Sort by date
events.sort((a, b) => a.date.localeCompare(b.date))
return events
}
// Group calendar events by date
export function groupCalendarEventsByDate(events: CalendarEvent[]): Map<string, CalendarEvent[]> {
const grouped = new Map<string, CalendarEvent[]>()
for (const event of events) {
const existing = grouped.get(event.date) || []
existing.push(event)
grouped.set(event.date, existing)
}
return grouped
}
// ============================================================
// Week and Day View Utilities
// ============================================================
// View mode labels in Swedish
export const VIEW_MODE_LABELS = {
month: 'Månad',
week: 'Vecka',
day: 'Dag'
} as const
// Swedish full day names
export const SWEDISH_DAYS_FULL = ['Måndag', 'Tisdag', 'Onsdag', 'Torsdag', 'Fredag', 'Lördag', 'Söndag']
// Get Monday of the week for a given date (ISO week - Monday first)
export function getWeekStart(date: Date): Date {
const d = new Date(date.getFullYear(), date.getMonth(), date.getDate())
const dayOfWeek = d.getDay()
// Convert Sunday (0) to 7 for ISO week calculation
const diff = dayOfWeek === 0 ? 6 : dayOfWeek - 1
d.setDate(d.getDate() - diff)
return d
}
// Get array of 7 dates for the week containing the given date
export function getWeekDays(date: Date): Date[] {
const weekStart = getWeekStart(date)
const days: Date[] = []
for (let i = 0; i < 7; i++) {
const day = new Date(weekStart)
day.setDate(weekStart.getDate() + i)
days.push(day)
}
return days
}
// Get ISO week number for a date
export function getWeekNumber(date: Date): number {
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()))
// Set to nearest Thursday (current date + 4 - current day number, make Sunday = 7)
const dayNum = d.getUTCDay() || 7
d.setUTCDate(d.getUTCDate() + 4 - dayNum)
// Get first day of year
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1))
// Calculate full weeks to nearest Thursday
const weekNo = Math.ceil((((d.getTime() - yearStart.getTime()) / 86400000) + 1) / 7)
return weekNo
}
// Generate hourly time slots
export function getTimeSlots(startHour: number = 8, endHour: number = 20): string[] {
const slots: string[] = []
for (let hour = startHour; hour <= endHour; hour++) {
slots.push(`${String(hour).padStart(2, '0')}:00`)
}
return slots
}
// Get invoices for a date range (inclusive)
export function getInvoicesForDateRange(invoices: Invoice[], startDate: Date, endDate: Date): Invoice[] {
const start = formatDateISO(startDate)
const end = formatDateISO(endDate)
return invoices.filter(inv => inv.due_date >= start && inv.due_date <= end)
}
// Get deadlines for a date range (inclusive)
export function getDeadlinesForDateRange(deadlines: Deadline[], startDate: Date, endDate: Date): Deadline[] {
const start = formatDateISO(startDate)
const end = formatDateISO(endDate)
return deadlines.filter(d => d.due_date >= start && d.due_date <= end)
}
// Format date for week view header (e.g., "Mån 27")
export function formatWeekDayHeader(date: Date): string {
const dayIndex = date.getDay() === 0 ? 6 : date.getDay() - 1
return `${SWEDISH_DAYS[dayIndex]} ${date.getDate()}`
}
// Format date for day view header (e.g., "Måndag 27 januari 2025")
export function formatDayViewHeader(date: Date): string {
const dayIndex = date.getDay() === 0 ? 6 : date.getDay() - 1
return `${SWEDISH_DAYS_FULL[dayIndex]} ${date.getDate()} ${SWEDISH_MONTHS[date.getMonth()].toLowerCase()} ${date.getFullYear()}`
}