feat: overhaul all 12 sector extensions with full CRUD, validation, and tests

Add shared components (ConfirmDeleteDialog, EditEntryDialog, validation utils),
enhance all 12 extension workspaces with edit/delete dialogs, input validation,
period comparisons, and new analytics features. Fix critical bugs in
ProjectBilling margin calculation and EarningsPerLiter revenue allocation.
Add pure calculation modules with 183 new tests across all extensions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Emil
2026-02-21 15:21:49 +01:00
co-authored by Claude Opus 4.6
parent 026497ed75
commit 6a5b2b7792
52 changed files with 14130 additions and 222 deletions
+112
View File
@@ -22,7 +22,12 @@ import {
LogOut,
Bell,
Calendar,
Sun,
Moon,
Monitor,
Palette,
} from 'lucide-react'
import { useTheme } from 'next-themes'
import type { CompanySettings, BankConnection } from '@/types'
import { NotificationSettings } from '@/extensions/general/push-notifications/NotificationSettings'
import { CalendarFeedSettings } from '@/components/settings/CalendarFeedSettings'
@@ -40,6 +45,12 @@ export default function SettingsPage() {
const [isSyncing, setIsSyncing] = useState(false)
const [isConnecting, setIsConnecting] = useState(false)
const [hasBankingExtension, setHasBankingExtension] = useState(false)
const { theme, setTheme } = useTheme()
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
}, [])
useEffect(() => {
fetchData()
@@ -309,6 +320,10 @@ export default function SettingsPage() {
<Calendar className="mr-2 h-4 w-4" />
Kalender
</TabsTrigger>
<TabsTrigger value="appearance">
<Palette className="mr-2 h-4 w-4" />
Utseende
</TabsTrigger>
<TabsTrigger value="account">
<User className="mr-2 h-4 w-4" />
Konto
@@ -600,6 +615,103 @@ export default function SettingsPage() {
<CalendarFeedSettings />
</TabsContent>
{/* Appearance settings */}
<TabsContent value="appearance">
<Card>
<CardHeader>
<CardTitle>Utseende</CardTitle>
<CardDescription>
Välj hur applikationen ska se ut
</CardDescription>
</CardHeader>
<CardContent>
{mounted && (
<div className="grid grid-cols-3 gap-4">
{/* Light */}
<button
type="button"
onClick={() => setTheme('light')}
className={`group relative rounded-lg border-2 p-4 text-left transition-colors ${
theme === 'light'
? 'border-primary bg-primary/5'
: 'border-border hover:border-primary/40'
}`}
>
<div className="mb-3 flex h-20 items-end gap-1.5 rounded-md border bg-white p-2">
<div className="h-full w-3 rounded-sm bg-[hsl(222,47%,35%)]" />
<div className="flex flex-1 flex-col gap-1">
<div className="h-2 w-3/4 rounded-sm bg-[hsl(220,14%,96%)]" />
<div className="h-2 w-1/2 rounded-sm bg-[hsl(220,14%,96%)]" />
<div className="h-2 w-2/3 rounded-sm bg-[hsl(220,14%,96%)]" />
</div>
</div>
<div className="flex items-center gap-2">
<Sun className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">Ljust</span>
</div>
</button>
{/* Dark */}
<button
type="button"
onClick={() => setTheme('dark')}
className={`group relative rounded-lg border-2 p-4 text-left transition-colors ${
theme === 'dark'
? 'border-primary bg-primary/5'
: 'border-border hover:border-primary/40'
}`}
>
<div className="mb-3 flex h-20 items-end gap-1.5 rounded-md border bg-[hsl(222,16%,10%)] p-2">
<div className="h-full w-3 rounded-sm bg-[hsl(222,50%,55%)]" />
<div className="flex flex-1 flex-col gap-1">
<div className="h-2 w-3/4 rounded-sm bg-[hsl(220,12%,20%)]" />
<div className="h-2 w-1/2 rounded-sm bg-[hsl(220,12%,20%)]" />
<div className="h-2 w-2/3 rounded-sm bg-[hsl(220,12%,20%)]" />
</div>
</div>
<div className="flex items-center gap-2">
<Moon className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">Mörkt</span>
</div>
</button>
{/* System */}
<button
type="button"
onClick={() => setTheme('system')}
className={`group relative rounded-lg border-2 p-4 text-left transition-colors ${
theme === 'system'
? 'border-primary bg-primary/5'
: 'border-border hover:border-primary/40'
}`}
>
<div className="mb-3 flex h-20 overflow-hidden rounded-md border">
<div className="flex flex-1 items-end gap-1 bg-white p-2">
<div className="h-full w-2 rounded-sm bg-[hsl(222,47%,35%)]" />
<div className="flex flex-1 flex-col gap-1">
<div className="h-2 w-3/4 rounded-sm bg-[hsl(220,14%,96%)]" />
<div className="h-2 w-1/2 rounded-sm bg-[hsl(220,14%,96%)]" />
</div>
</div>
<div className="flex flex-1 items-end gap-1 bg-[hsl(222,16%,10%)] p-2">
<div className="h-full w-2 rounded-sm bg-[hsl(222,50%,55%)]" />
<div className="flex flex-1 flex-col gap-1">
<div className="h-2 w-3/4 rounded-sm bg-[hsl(220,12%,20%)]" />
<div className="h-2 w-1/2 rounded-sm bg-[hsl(220,12%,20%)]" />
</div>
</div>
</div>
<div className="flex items-center gap-2">
<Monitor className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">System</span>
</div>
</button>
</div>
)}
</CardContent>
</Card>
</TabsContent>
{/* Account settings */}
<TabsContent value="account">
<Card>
+132
View File
@@ -0,0 +1,132 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
export async function GET(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const from = searchParams.get('from')
const to = searchParams.get('to')
const dateFrom = searchParams.get('date_from')
const dateTo = searchParams.get('date_to')
const groupBy = searchParams.get('group_by')
if (!from || !to) {
return NextResponse.json(
{ error: 'from and to account numbers are required' },
{ status: 400 }
)
}
// Get posted journal entries within date range
let entriesQuery = supabase
.from('journal_entries')
.select('id, entry_date')
.eq('user_id', user.id)
.eq('status', 'posted')
if (dateFrom) {
entriesQuery = entriesQuery.gte('entry_date', dateFrom)
}
if (dateTo) {
entriesQuery = entriesQuery.lte('entry_date', dateTo)
}
const { data: entries, error: entriesError } = await entriesQuery
if (entriesError) {
return NextResponse.json({ error: entriesError.message }, { status: 500 })
}
if (!entries || entries.length === 0) {
return NextResponse.json({ totals: [], monthly: groupBy === 'month' ? [] : undefined })
}
const entryIds = entries.map((e) => e.id)
const entryDateMap = new Map(entries.map((e) => [e.id, e.entry_date]))
// Fetch lines in batches to avoid URL length limits
const batchSize = 200
const allLines: Array<{
journal_entry_id: string
account_number: string
debit_amount: number
credit_amount: number
}> = []
for (let i = 0; i < entryIds.length; i += batchSize) {
const batch = entryIds.slice(i, i + batchSize)
const { data: lines, error: linesError } = await supabase
.from('journal_entry_lines')
.select('journal_entry_id, account_number, debit_amount, credit_amount')
.in('journal_entry_id', batch)
.gte('account_number', from)
.lte('account_number', to)
if (linesError) {
return NextResponse.json({ error: linesError.message }, { status: 500 })
}
if (lines) {
allLines.push(...lines)
}
}
// Aggregate by account
const accountTotals = new Map<string, { debit: number; credit: number }>()
for (const line of allLines) {
const existing = accountTotals.get(line.account_number) || { debit: 0, credit: 0 }
existing.debit += Number(line.debit_amount) || 0
existing.credit += Number(line.credit_amount) || 0
accountTotals.set(line.account_number, existing)
}
const totals = Array.from(accountTotals.entries())
.map(([account_number, bal]) => ({
account_number,
debit: Math.round(bal.debit * 100) / 100,
credit: Math.round(bal.credit * 100) / 100,
net: Math.round((bal.debit - bal.credit) * 100) / 100,
}))
.sort((a, b) => a.account_number.localeCompare(b.account_number))
// Monthly grouping
if (groupBy === 'month') {
const monthlyMap = new Map<string, Map<string, { debit: number; credit: number }>>()
for (const line of allLines) {
const entryDate = entryDateMap.get(line.journal_entry_id)
if (!entryDate) continue
const month = entryDate.slice(0, 7)
if (!monthlyMap.has(month)) {
monthlyMap.set(month, new Map())
}
const monthAccounts = monthlyMap.get(month)!
const existing = monthAccounts.get(line.account_number) || { debit: 0, credit: 0 }
existing.debit += Number(line.debit_amount) || 0
existing.credit += Number(line.credit_amount) || 0
monthAccounts.set(line.account_number, existing)
}
const monthlyFlat = Array.from(monthlyMap.entries())
.sort(([a], [b]) => a.localeCompare(b))
.flatMap(([month, accounts]) =>
Array.from(accounts.entries()).map(([account_number, bal]) => ({
month,
account_number,
debit: Math.round(bal.debit * 100) / 100,
credit: Math.round(bal.credit * 100) / 100,
net: Math.round((bal.debit - bal.credit) * 100) / 100,
}))
)
return NextResponse.json({ totals, monthly: monthlyFlat })
}
return NextResponse.json({ totals })
}
@@ -24,8 +24,12 @@ export async function GET(
.eq('user_id', user.id)
.eq('extension_id', extensionId)
const prefix = searchParams.get('prefix')
if (key) {
query = query.eq('key', key)
} else if (prefix) {
query = query.ilike('key', `${prefix}%`)
}
const { data, error } = await query
+49 -29
View File
@@ -1,36 +1,9 @@
@import "tailwindcss";
@plugin "@tailwindcss/typography";
/* Force light mode */
html, :root {
color-scheme: light only !important;
}
@media (prefers-color-scheme: dark) {
:root {
--background: 220 14% 96% !important;
--foreground: 224 12% 13% !important;
--card: 0 0% 100% !important;
--card-foreground: 224 12% 13% !important;
--popover: 0 0% 100% !important;
--popover-foreground: 224 12% 13% !important;
--primary: 222 47% 35% !important;
--primary-foreground: 0 0% 100% !important;
--secondary: 220 13% 93% !important;
--secondary-foreground: 224 12% 13% !important;
--muted: 218 12% 91% !important;
--muted-foreground: 218 8% 46% !important;
--accent: 213 72% 50% !important;
--accent-foreground: 0 0% 100% !important;
--destructive: 0 68% 50% !important;
--destructive-foreground: 0 0% 100% !important;
--border: 218 14% 89% !important;
--input: 218 14% 89% !important;
--ring: 222 47% 35% !important;
}
}
@custom-variant dark (&:where(.dark, .dark *));
:root {
color-scheme: light;
/* Clean Slate — Modern ERP Palette */
--background: 220 14% 96%; /* Cool off-white #F2F4F7 */
@@ -95,6 +68,48 @@ html, :root {
--duration-slow: 500ms;
}
.dark {
color-scheme: dark;
--background: 222 16% 10%;
--foreground: 216 12% 90%;
--card: 222 14% 13%;
--card-foreground: 216 12% 90%;
--popover: 222 14% 13%;
--popover-foreground: 216 12% 90%;
--primary: 222 50% 55%;
--primary-foreground: 0 0% 100%;
--secondary: 220 14% 18%;
--secondary-foreground: 216 12% 90%;
--muted: 220 12% 20%;
--muted-foreground: 218 8% 55%;
--accent: 213 72% 58%;
--accent-foreground: 0 0% 100%;
--destructive: 0 62% 55%;
--destructive-foreground: 0 0% 100%;
--border: 220 12% 22%;
--input: 220 12% 22%;
--ring: 222 50% 55%;
--success: 152 44% 45%;
--success-foreground: 0 0% 100%;
--warning: 38 85% 55%;
--warning-foreground: 224 12% 13%;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.25);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.35);
--shadow-lg: 0 12px 40px rgba(0, 0, 0, 0.45);
}
@theme inline {
--color-background: hsl(var(--background));
--color-foreground: hsl(var(--foreground));
@@ -283,3 +298,8 @@ h1, h2, h3 {
background: hsl(var(--primary) / 0.15);
color: hsl(var(--foreground));
}
.dark ::selection {
background: hsl(var(--primary) / 0.3);
color: hsl(var(--foreground));
}
+11 -4
View File
@@ -3,6 +3,7 @@ import { Geist, Geist_Mono } from "next/font/google";
import { Fraunces } from "next/font/google";
import Script from "next/script";
import { Toaster } from "@/components/ui/toaster";
import { ThemeProvider } from "@/components/theme-provider";
import "./globals.css";
const geistSans = Geist({
@@ -44,16 +45,22 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="sv">
<html lang="sv" suppressHydrationWarning>
<head>
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
</head>
<body
className={`${geistSans.variable} ${geistMono.variable} ${fraunces.variable} antialiased`}
suppressHydrationWarning
>
{children}
<Toaster />
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
<Toaster />
</ThemeProvider>
<Script src="/sw-register.js" strategy="afterInteractive" />
</body>
</html>
+52 -7
View File
@@ -1,6 +1,6 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useState, useCallback } from 'react'
import Link from 'next/link'
import { usePathname, useRouter } from 'next/navigation'
import { createClient } from '@/lib/supabase/client'
@@ -23,6 +23,7 @@ import {
ChevronDown,
Building2,
FileInput,
Store,
} from 'lucide-react'
import { getExtensionDefinition } from '@/lib/extensions/sectors'
import { resolveIcon } from '@/lib/extensions/icon-resolver'
@@ -85,10 +86,16 @@ export default function DashboardNav({ companyName, entityType, enabledExtension
}
}, [])
// Refresh extensions when dropdown is opened or mobile menu is opened
useEffect(() => {
if (isTillaggExpanded || isMobileMenuOpen) fetchExtensions()
}, [isTillaggExpanded, isMobileMenuOpen, fetchExtensions])
const toggleTillagg = () => {
const next = !isTillaggExpanded
setIsTillaggExpanded(next)
if (next) fetchExtensions()
}
const openMobileMenu = () => {
setIsMobileMenuOpen(true)
fetchExtensions()
}
const handleLogout = async () => {
await supabase.auth.signOut()
@@ -196,10 +203,31 @@ export default function DashboardNav({ companyName, entityType, enabledExtension
</div>
</div>
{/* Marketplace - standalone link */}
<div className="mb-4">
<div className="space-y-px">
<Link
href="/extensions"
className={cn(
'group flex items-center px-3 py-[7px] text-[13px] transition-colors duration-150 rounded-lg',
isActive('/extensions')
? 'bg-primary/8 text-foreground font-medium'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/40'
)}
>
<Store className={cn(
"mr-2.5 h-[15px] w-[15px] flex-shrink-0",
isActive('/extensions') ? "text-primary" : "text-muted-foreground/70 group-hover:text-muted-foreground"
)} />
Marketplace
</Link>
</div>
</div>
{/* Tillägg - collapsible */}
<div className="mb-4">
<button
onClick={() => setIsTillaggExpanded(!isTillaggExpanded)}
onClick={toggleTillagg}
className="w-full flex items-center justify-between px-3 mb-1.5 text-[10px] font-semibold text-muted-foreground/70 uppercase tracking-[0.08em] hover:text-muted-foreground transition-colors"
>
<span>Tillägg</span>
@@ -330,7 +358,7 @@ export default function DashboardNav({ companyName, entityType, enabledExtension
})}
{/* Menu button */}
<button
onClick={() => setIsMobileMenuOpen(true)}
onClick={openMobileMenu}
aria-label="Öppna meny"
className="flex flex-col items-center justify-center flex-1 h-full text-xs text-muted-foreground transition-colors duration-200"
>
@@ -426,6 +454,23 @@ export default function DashboardNav({ companyName, entityType, enabledExtension
})}
</div>
{/* Marketplace */}
<div className="mb-4">
<Link
href="/extensions"
onClick={closeMobileMenu}
className={cn(
'flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors',
isActive('/extensions')
? 'bg-primary/10 text-primary font-medium'
: 'text-muted-foreground hover:bg-secondary/50 hover:text-foreground'
)}
>
<Store className="h-5 w-5" />
Marketplace
</Link>
</div>
{/* Tillägg */}
<div className="mb-4">
<p className="px-3 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">
+4 -4
View File
@@ -5,10 +5,10 @@ import { cn } from '@/lib/utils'
import type { ExtensionCategory } from '@/lib/extensions/types'
const CATEGORY_CONFIG: Record<ExtensionCategory, { label: string; className: string }> = {
accounting: { label: 'Bokföring & Skatt', className: 'bg-rose-100 text-rose-700 border-rose-200' },
reports: { label: 'Branschrapporter', className: 'bg-blue-100 text-blue-700 border-blue-200' },
import: { label: 'Smart Import', className: 'bg-emerald-100 text-emerald-700 border-emerald-200' },
operations: { label: 'Verktyg', className: 'bg-slate-100 text-slate-700 border-slate-200' },
accounting: { label: 'Bokföring & Skatt', className: 'bg-rose-100 text-rose-700 border-rose-200 dark:bg-rose-950/30 dark:text-rose-400 dark:border-rose-800' },
reports: { label: 'Branschrapporter', className: 'bg-blue-100 text-blue-700 border-blue-200 dark:bg-blue-950/30 dark:text-blue-400 dark:border-blue-800' },
import: { label: 'Smart Import', className: 'bg-emerald-100 text-emerald-700 border-emerald-200 dark:bg-emerald-950/30 dark:text-emerald-400 dark:border-emerald-800' },
operations: { label: 'Verktyg', className: 'bg-slate-100 text-slate-700 border-slate-200 dark:bg-slate-800/30 dark:text-slate-400 dark:border-slate-700' },
}
export default function CategoryBadge({ category }: { category: ExtensionCategory }) {
@@ -1,14 +1,864 @@
'use client'
import { FolderKanban } from 'lucide-react'
import EmptyExtensionState from '@/components/extensions/shared/EmptyExtensionState'
import { useState, useMemo } from 'react'
import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
import { useExtensionData } from '@/lib/extensions/use-extension-data'
import KPICard from '@/components/extensions/shared/KPICard'
import ExtensionLoadingSkeleton from '@/components/extensions/shared/ExtensionLoadingSkeleton'
import ConfirmDeleteDialog from '@/components/extensions/shared/ConfirmDeleteDialog'
import EditEntryDialog from '@/components/extensions/shared/EditEntryDialog'
import DateRangeFilter from '@/components/extensions/shared/DateRangeFilter'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Progress } from '@/components/ui/progress'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select'
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from '@/components/ui/table'
import { Pencil, Plus, ChevronDown, ChevronUp, Trash2, AlertTriangle, CheckCircle } from 'lucide-react'
import { cn } from '@/lib/utils'
interface Project {
id: string
name: string
budget: number
status: 'active' | 'completed'
startDate: string
}
interface CostEntry {
id: string
projectId: string
description: string
amount: number
date: string
category: string
}
interface RevenueEntry {
id: string
projectId: string
description: string
amount: number
date: string
}
const COST_CATEGORIES = ['Material', 'Arbetskraft', 'Underentreprenor', 'Maskiner', 'Ovrigt']
function getBudgetStatus(totalCost: number, budget: number): 'ok' | 'warning' | 'danger' {
if (budget <= 0) return 'ok'
const ratio = totalCost / budget
if (ratio >= 1) return 'danger'
if (ratio >= 0.8) return 'warning'
return 'ok'
}
function getProgressColor(status: 'ok' | 'warning' | 'danger'): string {
switch (status) {
case 'danger': return '[&>div]:bg-red-500'
case 'warning': return '[&>div]:bg-amber-500'
default: return ''
}
}
export default function ProjectCostWorkspace({}: WorkspaceComponentProps) {
const { data, save, remove, refresh, isLoading } = useExtensionData('construction', 'project-cost')
// --- Date range filter ---
const now = new Date()
const [dateRange, setDateRange] = useState<{ start: string; end: string } | null>(null)
// --- Parse data ---
const projects = useMemo(() =>
data.filter(d => d.key.startsWith('project:'))
.map(d => ({ id: d.key.replace('project:', ''), ...(d.value as Omit<Project, 'id'>) }))
.sort((a, b) => b.startDate.localeCompare(a.startDate))
, [data])
const allCosts = useMemo(() =>
data.filter(d => d.key.startsWith('cost:'))
.map(d => ({ id: d.key.replace('cost:', ''), ...(d.value as Omit<CostEntry, 'id'>) }))
.sort((a, b) => b.date.localeCompare(a.date))
, [data])
const allRevenues = useMemo(() =>
data.filter(d => d.key.startsWith('revenue:'))
.map(d => ({ id: d.key.replace('revenue:', ''), ...(d.value as Omit<RevenueEntry, 'id'>) }))
.sort((a, b) => b.date.localeCompare(a.date))
, [data])
// Filtered costs/revenues based on date range
const costs = useMemo(() => {
if (!dateRange) return allCosts
return allCosts.filter(c => c.date >= dateRange.start && c.date <= dateRange.end)
}, [allCosts, dateRange])
const revenues = useMemo(() => {
if (!dateRange) return allRevenues
return allRevenues.filter(r => r.date >= dateRange.start && r.date <= dateRange.end)
}, [allRevenues, dateRange])
// --- UI state ---
const [expandedProject, setExpandedProject] = useState<string | null>(null)
const [showNewProject, setShowNewProject] = useState(false)
const [newProjectName, setNewProjectName] = useState('')
const [newProjectBudget, setNewProjectBudget] = useState('')
// Cost/Revenue entry forms
const [costDesc, setCostDesc] = useState('')
const [costAmount, setCostAmount] = useState('')
const [costCategory, setCostCategory] = useState(COST_CATEGORIES[0])
const [revDesc, setRevDesc] = useState('')
const [revAmount, setRevAmount] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
// Delete confirmation state
const [deleteTarget, setDeleteTarget] = useState<{
type: 'cost' | 'revenue' | 'project'
id: string
label: string
} | null>(null)
const [isDeleting, setIsDeleting] = useState(false)
// Edit cost/revenue entry state
const [editEntry, setEditEntry] = useState<{
type: 'cost' | 'revenue'
id: string
projectId: string
description: string
amount: string
date: string
category?: string
} | null>(null)
const [isSavingEdit, setIsSavingEdit] = useState(false)
// Edit project state
const [editProject, setEditProject] = useState<{
id: string
name: string
budget: string
} | null>(null)
const [isSavingProject, setIsSavingProject] = useState(false)
// Complete project confirmation state
const [completeProjectId, setCompleteProjectId] = useState<string | null>(null)
// --- Computed stats ---
const projectStats = useMemo(() => {
return projects.map(p => {
const projectCosts = costs.filter(c => c.projectId === p.id)
const projectRevenues = revenues.filter(r => r.projectId === p.id)
const totalCost = projectCosts.reduce((s, c) => s + c.amount, 0)
const totalRevenue = projectRevenues.reduce((s, r) => s + r.amount, 0)
const margin = totalRevenue > 0 ? Math.round(((totalRevenue - totalCost) / totalRevenue) * 100) : 0
const budgetUsed = p.budget > 0 ? Math.round((totalCost / p.budget) * 100) : 0
const budgetStatus = getBudgetStatus(totalCost, p.budget)
// Cost category breakdown
const categoryTotals = COST_CATEGORIES.map(cat => {
const catTotal = projectCosts
.filter(c => c.category === cat)
.reduce((s, c) => s + c.amount, 0)
return {
category: cat,
total: catTotal,
pct: totalCost > 0 ? Math.round((catTotal / totalCost) * 100) : 0,
}
}).filter(ct => ct.total > 0)
return {
...p,
totalCost,
totalRevenue,
margin,
budgetUsed,
budgetStatus,
costs: projectCosts,
revenues: projectRevenues,
categoryTotals,
}
})
}, [projects, costs, revenues])
const activeProjects = projectStats.filter(p => p.status === 'active')
const completedProjects = projectStats.filter(p => p.status === 'completed')
const totalRevenue = projectStats.reduce((s, p) => s + p.totalRevenue, 0)
const totalCosts = projectStats.reduce((s, p) => s + p.totalCost, 0)
const avgMargin = totalRevenue > 0 ? Math.round(((totalRevenue - totalCosts) / totalRevenue) * 100) : 0
// --- Handlers ---
const handleAddProject = async () => {
if (!newProjectName.trim()) return
const id = crypto.randomUUID()
await save(`project:${id}`, {
name: newProjectName.trim(),
budget: Math.round((parseFloat(newProjectBudget) || 0) * 100) / 100,
status: 'active',
startDate: new Date().toISOString().slice(0, 10),
})
setNewProjectName('')
setNewProjectBudget('')
setShowNewProject(false)
await refresh()
}
const handleAddCost = async (projectId: string) => {
const amt = parseFloat(costAmount)
if (isNaN(amt) || amt <= 0) return
setIsSubmitting(true)
const id = crypto.randomUUID()
await save(`cost:${id}`, {
projectId,
description: costDesc,
amount: Math.round(amt * 100) / 100,
date: new Date().toISOString().slice(0, 10),
category: costCategory,
})
setCostDesc('')
setCostAmount('')
await refresh()
setIsSubmitting(false)
}
const handleAddRevenue = async (projectId: string) => {
const amt = parseFloat(revAmount)
if (isNaN(amt) || amt <= 0) return
setIsSubmitting(true)
const id = crypto.randomUUID()
await save(`revenue:${id}`, {
projectId,
description: revDesc,
amount: Math.round(amt * 100) / 100,
date: new Date().toISOString().slice(0, 10),
})
setRevDesc('')
setRevAmount('')
await refresh()
setIsSubmitting(false)
}
const handleDelete = async () => {
if (!deleteTarget) return
setIsDeleting(true)
if (deleteTarget.type === 'project') {
// Delete all costs and revenues for the project, then the project itself
const projectCosts = allCosts.filter(c => c.projectId === deleteTarget.id)
const projectRevenues = allRevenues.filter(r => r.projectId === deleteTarget.id)
for (const c of projectCosts) {
await remove(`cost:${c.id}`)
}
for (const r of projectRevenues) {
await remove(`revenue:${r.id}`)
}
await remove(`project:${deleteTarget.id}`)
} else if (deleteTarget.type === 'cost') {
await remove(`cost:${deleteTarget.id}`)
} else {
await remove(`revenue:${deleteTarget.id}`)
}
await refresh()
setIsDeleting(false)
setDeleteTarget(null)
}
const handleSaveEditEntry = async () => {
if (!editEntry) return
const amt = parseFloat(editEntry.amount)
if (isNaN(amt) || amt <= 0) return
setIsSavingEdit(true)
if (editEntry.type === 'cost') {
await save(`cost:${editEntry.id}`, {
projectId: editEntry.projectId,
description: editEntry.description,
amount: Math.round(amt * 100) / 100,
date: editEntry.date,
category: editEntry.category || COST_CATEGORIES[0],
})
} else {
await save(`revenue:${editEntry.id}`, {
projectId: editEntry.projectId,
description: editEntry.description,
amount: Math.round(amt * 100) / 100,
date: editEntry.date,
})
}
await refresh()
setIsSavingEdit(false)
setEditEntry(null)
}
const handleSaveEditProject = async () => {
if (!editProject) return
const project = projects.find(p => p.id === editProject.id)
if (!project) return
setIsSavingProject(true)
await save(`project:${editProject.id}`, {
name: editProject.name.trim(),
budget: Math.round((parseFloat(editProject.budget) || 0) * 100) / 100,
status: project.status,
startDate: project.startDate,
})
await refresh()
setIsSavingProject(false)
setEditProject(null)
}
const handleCompleteProject = async () => {
if (!completeProjectId) return
const project = projects.find(p => p.id === completeProjectId)
if (!project) return
await save(`project:${completeProjectId}`, {
name: project.name,
budget: project.budget,
status: 'completed' as const,
startDate: project.startDate,
})
await refresh()
setCompleteProjectId(null)
}
if (isLoading) return <ExtensionLoadingSkeleton />
// --- Render helper for budget alert banner ---
const renderBudgetAlert = (p: (typeof projectStats)[number]) => {
if (p.budget <= 0) return null
if (p.budgetStatus === 'danger') {
return (
<div className="flex items-center gap-2 rounded-md bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-800 px-3 py-2 text-sm text-red-700 dark:text-red-400">
<AlertTriangle className="h-4 w-4 shrink-0" />
<span>Kostnaden overskrider budgeten ({p.budgetUsed}% anvant)</span>
</div>
)
}
if (p.budgetStatus === 'warning') {
return (
<div className="flex items-center gap-2 rounded-md bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800 px-3 py-2 text-sm text-amber-700 dark:text-amber-400">
<AlertTriangle className="h-4 w-4 shrink-0" />
<span>Budgetvarning: {p.budgetUsed}% av budgeten anvand</span>
</div>
)
}
return null
}
// --- Render helper for category breakdown ---
const renderCategoryBreakdown = (categoryTotals: { category: string; total: number; pct: number }[]) => {
if (categoryTotals.length === 0) return null
return (
<div>
<h4 className="text-sm font-medium mb-2">Kostnadsfordelning per kategori</h4>
<div className="rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Kategori</TableHead>
<TableHead className="text-right">Belopp</TableHead>
<TableHead className="text-right">Andel</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{categoryTotals.map(ct => (
<TableRow key={ct.category}>
<TableCell className="font-medium">{ct.category}</TableCell>
<TableCell className="text-right tabular-nums">
{ct.total.toLocaleString('sv-SE')} kr
</TableCell>
<TableCell className="text-right tabular-nums">{ct.pct}%</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)
}
// --- Render project card for the Projects tab ---
const renderProjectCard = (p: (typeof projectStats)[number]) => {
const isExpanded = expandedProject === p.id
return (
<Card key={p.id}>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div
className="flex items-center gap-2 cursor-pointer flex-1"
onClick={() => setExpandedProject(isExpanded ? null : p.id)}
>
<CardTitle className="text-base flex items-center gap-2">
{p.name}
<Badge variant={p.status === 'active' ? 'default' : 'secondary'}>
{p.status === 'active' ? 'Aktiv' : 'Avslutad'}
</Badge>
</CardTitle>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation()
setEditProject({
id: p.id,
name: p.name,
budget: String(p.budget),
})
}}
>
<Pencil className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation()
setDeleteTarget({ type: 'project', id: p.id, label: p.name })
}}
>
<Trash2 className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
<div
className="cursor-pointer p-1"
onClick={() => setExpandedProject(isExpanded ? null : p.id)}
>
{isExpanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</div>
</div>
</div>
</CardHeader>
{isExpanded && (
<CardContent className="space-y-4">
{/* Budget alert banner */}
{renderBudgetAlert(p)}
{/* Stats row */}
<div className="grid grid-cols-3 gap-4 text-sm">
<div>
<p className="text-muted-foreground">Kostnad</p>
<p className="font-semibold tabular-nums">{p.totalCost.toLocaleString('sv-SE')} kr</p>
</div>
<div>
<p className="text-muted-foreground">Intakt</p>
<p className="font-semibold tabular-nums">{p.totalRevenue.toLocaleString('sv-SE')} kr</p>
</div>
<div>
<p className="text-muted-foreground">Marginal</p>
<p className="font-semibold tabular-nums">{p.margin}%</p>
</div>
</div>
{/* Budget progress */}
{p.budget > 0 && (
<div>
<div className="flex justify-between text-xs text-muted-foreground mb-1">
<span>Budget anvand</span>
<span>{p.budgetUsed}% av {p.budget.toLocaleString('sv-SE')} kr</span>
</div>
<Progress
value={Math.min(p.budgetUsed, 100)}
className={cn('h-2', getProgressColor(p.budgetStatus))}
/>
</div>
)}
{/* Category breakdown */}
{renderCategoryBreakdown(p.categoryTotals)}
{/* Cost entries */}
<div>
<h4 className="text-sm font-medium mb-2">Kostnader</h4>
<div className="flex gap-2 mb-2 flex-wrap">
<Input placeholder="Beskrivning" value={costDesc} onChange={e => setCostDesc(e.target.value)} className="max-w-xs" />
<Input type="number" placeholder="Belopp" value={costAmount} onChange={e => setCostAmount(e.target.value)} className="w-28" />
<Select value={costCategory} onValueChange={setCostCategory}>
<SelectTrigger className="w-40"><SelectValue /></SelectTrigger>
<SelectContent>
{COST_CATEGORIES.map(c => <SelectItem key={c} value={c}>{c}</SelectItem>)}
</SelectContent>
</Select>
<Button size="sm" onClick={() => handleAddCost(p.id)} disabled={isSubmitting}>Lagg till</Button>
</div>
{p.costs.length > 0 && (
<div className="rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Datum</TableHead>
<TableHead>Beskrivning</TableHead>
<TableHead>Kategori</TableHead>
<TableHead className="text-right">Belopp</TableHead>
<TableHead className="w-20"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{p.costs.map(c => (
<TableRow key={c.id}>
<TableCell>{c.date}</TableCell>
<TableCell>{c.description}</TableCell>
<TableCell>{c.category}</TableCell>
<TableCell className="text-right tabular-nums">{c.amount.toLocaleString('sv-SE')} kr</TableCell>
<TableCell>
<div className="flex items-center gap-0.5">
<Button
variant="ghost"
size="sm"
onClick={() => setEditEntry({
type: 'cost',
id: c.id,
projectId: c.projectId,
description: c.description,
amount: String(c.amount),
date: c.date,
category: c.category,
})}
>
<Pencil className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteTarget({
type: 'cost',
id: c.id,
label: c.description || 'kostnad',
})}
>
<Trash2 className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
{/* Revenue entries */}
<div>
<h4 className="text-sm font-medium mb-2">Intakter</h4>
<div className="flex gap-2 mb-2 flex-wrap">
<Input placeholder="Beskrivning" value={revDesc} onChange={e => setRevDesc(e.target.value)} className="max-w-xs" />
<Input type="number" placeholder="Belopp" value={revAmount} onChange={e => setRevAmount(e.target.value)} className="w-28" />
<Button size="sm" onClick={() => handleAddRevenue(p.id)} disabled={isSubmitting}>Lagg till</Button>
</div>
{p.revenues.length > 0 && (
<div className="rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Datum</TableHead>
<TableHead>Beskrivning</TableHead>
<TableHead className="text-right">Belopp</TableHead>
<TableHead className="w-20"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{p.revenues.map(r => (
<TableRow key={r.id}>
<TableCell>{r.date}</TableCell>
<TableCell>{r.description}</TableCell>
<TableCell className="text-right tabular-nums">{r.amount.toLocaleString('sv-SE')} kr</TableCell>
<TableCell>
<div className="flex items-center gap-0.5">
<Button
variant="ghost"
size="sm"
onClick={() => setEditEntry({
type: 'revenue',
id: r.id,
projectId: r.projectId,
description: r.description,
amount: String(r.amount),
date: r.date,
})}
>
<Pencil className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteTarget({
type: 'revenue',
id: r.id,
label: r.description || 'intakt',
})}
>
<Trash2 className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
{/* Complete project button (active only) */}
{p.status === 'active' && (
<div className="pt-2 border-t">
<Button
variant="outline"
size="sm"
onClick={() => setCompleteProjectId(p.id)}
className="text-green-700 dark:text-green-400 border-green-300 dark:border-green-700 hover:bg-green-50 dark:hover:bg-green-950/30"
>
<CheckCircle className="h-4 w-4 mr-1" />
Avsluta projekt
</Button>
</div>
)}
</CardContent>
)}
</Card>
)
}
export default function ProjectCostWorkspace() {
return (
<EmptyExtensionState
title="Projektkostnadsuppföljning"
description="Uppföljning av kostnader per byggprojekt kommer snart. Du kommer kunna koppla fakturor och transaktioner till specifika projekt."
icon={<FolderKanban className="h-12 w-12 text-muted-foreground/40 mb-4" />}
/>
<div className="space-y-6">
<Tabs defaultValue="overview">
<TabsList>
<TabsTrigger value="overview">Oversikt</TabsTrigger>
<TabsTrigger value="projects">Projekt</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="space-y-6 mt-4">
<DateRangeFilter onRangeChange={(start, end) => setDateRange({ start, end })} />
<div className="grid grid-cols-1 sm:grid-cols-4 gap-4">
<KPICard label="Aktiva projekt" value={activeProjects.length} />
<KPICard label="Total intakt" value={totalRevenue.toLocaleString('sv-SE')} suffix="kr" />
<KPICard label="Total kostnad" value={totalCosts.toLocaleString('sv-SE')} suffix="kr" />
<KPICard label="Snittmarginal" value={avgMargin} suffix="%" />
</div>
{/* Active projects */}
{activeProjects.length > 0 && (
<div className="space-y-3">
<h3 className="text-sm font-semibold">Aktiva projekt</h3>
{activeProjects.map(p => (
<Card key={p.id}>
<CardContent className="pt-4">
{/* Budget alert */}
{renderBudgetAlert(p)}
<div className="flex items-center justify-between mb-2 mt-1">
<div className="flex items-center gap-2">
<p className="font-medium text-sm">{p.name}</p>
<Badge variant="default">Aktiv</Badge>
</div>
<div className="text-right text-sm">
<span className="text-muted-foreground">Marginal: </span>
<span className="font-medium tabular-nums">{p.margin}%</span>
</div>
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground mb-2">
<span>Kostnad: {p.totalCost.toLocaleString('sv-SE')} kr</span>
<span>Intakt: {p.totalRevenue.toLocaleString('sv-SE')} kr</span>
{p.budget > 0 && <span>Budget: {p.budget.toLocaleString('sv-SE')} kr</span>}
</div>
{p.budget > 0 && (
<Progress
value={Math.min(p.budgetUsed, 100)}
className={cn('h-2', getProgressColor(p.budgetStatus))}
/>
)}
</CardContent>
</Card>
))}
</div>
)}
{/* Completed projects */}
{completedProjects.length > 0 && (
<div className="space-y-3">
<h3 className="text-sm font-semibold">Avslutade projekt</h3>
{completedProjects.map(p => (
<Card key={p.id} className="border-muted">
<CardContent className="pt-4">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<p className="font-medium text-sm">{p.name}</p>
<Badge variant="secondary">Avslutad</Badge>
</div>
<div className="text-right">
<p className={cn(
'text-lg font-bold tabular-nums',
p.margin >= 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'
)}>
{p.margin}% marginal
</p>
</div>
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span>Kostnad: {p.totalCost.toLocaleString('sv-SE')} kr</span>
<span>Intakt: {p.totalRevenue.toLocaleString('sv-SE')} kr</span>
<span>Resultat: {(Math.round((p.totalRevenue - p.totalCost) * 100) / 100).toLocaleString('sv-SE')} kr</span>
</div>
</CardContent>
</Card>
))}
</div>
)}
</TabsContent>
<TabsContent value="projects" className="space-y-6 mt-4">
{!showNewProject ? (
<Button size="sm" variant="outline" onClick={() => setShowNewProject(true)}>
<Plus className="h-4 w-4 mr-1" /> Nytt projekt
</Button>
) : (
<Card>
<CardContent className="pt-4">
<div className="flex gap-2 flex-wrap">
<Input placeholder="Projektnamn" value={newProjectName} onChange={e => setNewProjectName(e.target.value)} className="max-w-xs" />
<Input type="number" placeholder="Budget (kr)" value={newProjectBudget} onChange={e => setNewProjectBudget(e.target.value)} className="max-w-xs" />
<Button size="sm" onClick={handleAddProject} disabled={!newProjectName.trim()}>Skapa</Button>
<Button size="sm" variant="ghost" onClick={() => setShowNewProject(false)}>Avbryt</Button>
</div>
</CardContent>
</Card>
)}
{activeProjects.length > 0 && (
<div className="space-y-4">
<h3 className="text-sm font-semibold">Aktiva projekt</h3>
{activeProjects.map(p => renderProjectCard(p))}
</div>
)}
{completedProjects.length > 0 && (
<div className="space-y-4">
<h3 className="text-sm font-semibold text-muted-foreground">Avslutade projekt</h3>
{completedProjects.map(p => renderProjectCard(p))}
</div>
)}
</TabsContent>
</Tabs>
{/* Delete confirmation dialog */}
<ConfirmDeleteDialog
open={deleteTarget !== null}
onOpenChange={(open) => { if (!open) setDeleteTarget(null) }}
title={
deleteTarget?.type === 'project'
? 'Ta bort projekt'
: deleteTarget?.type === 'cost'
? 'Ta bort kostnad'
: 'Ta bort intakt'
}
description={
deleteTarget?.type === 'project'
? `Vill du ta bort projektet "${deleteTarget?.label}"? Alla kostnader och intakter kopplade till projektet tas ocksa bort. Atgarden kan inte angras.`
: `Vill du ta bort "${deleteTarget?.label}"? Atgarden kan inte angras.`
}
onConfirm={handleDelete}
isDeleting={isDeleting}
/>
{/* Edit cost/revenue entry dialog */}
<EditEntryDialog
open={editEntry !== null}
onOpenChange={(open) => { if (!open) setEditEntry(null) }}
title={editEntry?.type === 'cost' ? 'Redigera kostnad' : 'Redigera intakt'}
description="Andra uppgifterna och klicka Spara."
onSave={handleSaveEditEntry}
isSaving={isSavingEdit}
>
{editEntry && (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="edit-desc">Beskrivning</Label>
<Input
id="edit-desc"
value={editEntry.description}
onChange={e => setEditEntry({ ...editEntry, description: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-amount">Belopp (kr)</Label>
<Input
id="edit-amount"
type="number"
value={editEntry.amount}
onChange={e => setEditEntry({ ...editEntry, amount: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-date">Datum</Label>
<Input
id="edit-date"
type="date"
value={editEntry.date}
onChange={e => setEditEntry({ ...editEntry, date: e.target.value })}
/>
</div>
{editEntry.type === 'cost' && (
<div className="space-y-2">
<Label>Kategori</Label>
<Select
value={editEntry.category || COST_CATEGORIES[0]}
onValueChange={val => setEditEntry({ ...editEntry, category: val })}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{COST_CATEGORIES.map(c => <SelectItem key={c} value={c}>{c}</SelectItem>)}
</SelectContent>
</Select>
</div>
)}
</div>
)}
</EditEntryDialog>
{/* Edit project dialog */}
<EditEntryDialog
open={editProject !== null}
onOpenChange={(open) => { if (!open) setEditProject(null) }}
title="Redigera projekt"
description="Andra projektnamn och budget."
onSave={handleSaveEditProject}
isSaving={isSavingProject}
>
{editProject && (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="edit-proj-name">Projektnamn</Label>
<Input
id="edit-proj-name"
value={editProject.name}
onChange={e => setEditProject({ ...editProject, name: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-proj-budget">Budget (kr)</Label>
<Input
id="edit-proj-budget"
type="number"
value={editProject.budget}
onChange={e => setEditProject({ ...editProject, budget: e.target.value })}
/>
</div>
</div>
)}
</EditEntryDialog>
{/* Complete project confirmation dialog */}
<ConfirmDeleteDialog
open={completeProjectId !== null}
onOpenChange={(open) => { if (!open) setCompleteProjectId(null) }}
title="Avsluta projekt"
description={`Vill du markera projektet som avslutat? Projektet flyttas till "Avslutade" och kan inte ateraktiveras.`}
onConfirm={handleCompleteProject}
/>
</div>
)
}
@@ -1,14 +1,613 @@
'use client'
import { Calculator } from 'lucide-react'
import EmptyExtensionState from '@/components/extensions/shared/EmptyExtensionState'
import { useState, useMemo, useCallback } from 'react'
import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
import { useExtensionData } from '@/lib/extensions/use-extension-data'
import KPICard from '@/components/extensions/shared/KPICard'
import DataEntryForm from '@/components/extensions/shared/DataEntryForm'
import ExtensionLoadingSkeleton from '@/components/extensions/shared/ExtensionLoadingSkeleton'
import ConfirmDeleteDialog from '@/components/extensions/shared/ConfirmDeleteDialog'
import EditEntryDialog from '@/components/extensions/shared/EditEntryDialog'
import { validateSwedishPersonalNumber } from '@/lib/extensions/validation'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Progress } from '@/components/ui/progress'
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select'
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from '@/components/ui/table'
import { Card, CardContent } from '@/components/ui/card'
import { Pencil, Trash2, Plus, Download, Check } from 'lucide-react'
const MAX_ROT_YEARLY = 50000
const ROT_RATE = 0.30
interface Job {
id: string
customerId: string
customerName: string
description: string
total: number
material: number
labor: number
rotDeduction: number
date: string
status: 'draft' | 'completed'
}
interface Customer {
id: string
name: string
personalNumber: string
}
function buildYearOptions(): number[] {
const current = new Date().getFullYear()
const years: number[] = []
for (let y = current; y >= current - 5; y--) {
years.push(y)
}
return years
}
export default function RotCalculatorWorkspace({}: WorkspaceComponentProps) {
const { data, save, remove, refresh, isLoading } = useExtensionData('construction', 'rot-calculator')
const customers = useMemo<Customer[]>(() =>
data.filter(d => d.key.startsWith('customer:'))
.map(d => ({
id: d.key.replace('customer:', ''),
...(d.value as { name: string; personalNumber: string }),
}))
, [data])
const allJobs = useMemo<Job[]>(() =>
data.filter(d => d.key.startsWith('job:'))
.map(d => ({ id: d.key.replace('job:', ''), ...(d.value as Omit<Job, 'id'>) }))
.sort((a, b) => b.date.localeCompare(a.date))
, [data])
// Year filter
const currentYear = new Date().getFullYear()
const [selectedYear, setSelectedYear] = useState(String(currentYear))
const yearOptions = useMemo(() => buildYearOptions(), [])
const jobs = useMemo(() =>
allJobs.filter(j => j.date.startsWith(selectedYear))
, [allJobs, selectedYear])
// Calculator form
const [selectedCustomerId, setCustomerId] = useState('')
const customerId = selectedCustomerId || (customers.length > 0 ? customers[0].id : '')
const [description, setDescription] = useState('')
const [total, setTotal] = useState('')
const [material, setMaterial] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
// New customer form
const [newCustomerName, setNewCustomerName] = useState('')
const [newCustomerPnr, setNewCustomerPnr] = useState('')
const newCustomerPnrError = useMemo(() => {
if (!newCustomerPnr.trim()) return null
return validateSwedishPersonalNumber(newCustomerPnr.trim())
}, [newCustomerPnr])
const canAddCustomer = newCustomerName.trim().length > 0 && !newCustomerPnrError
// Edit customer dialog
const [editingCustomer, setEditingCustomer] = useState<Customer | null>(null)
const [editCustomerName, setEditCustomerName] = useState('')
const [editCustomerPnr, setEditCustomerPnr] = useState('')
const [isSavingCustomer, setIsSavingCustomer] = useState(false)
const editCustomerPnrError = useMemo(() => {
if (!editCustomerPnr.trim()) return null
return validateSwedishPersonalNumber(editCustomerPnr.trim())
}, [editCustomerPnr])
// Edit job dialog
const [editingJob, setEditingJob] = useState<Job | null>(null)
const [editJobCustomerId, setEditJobCustomerId] = useState('')
const [editJobDescription, setEditJobDescription] = useState('')
const [editJobTotal, setEditJobTotal] = useState('')
const [editJobMaterial, setEditJobMaterial] = useState('')
const [isSavingJob, setIsSavingJob] = useState(false)
// Delete job dialog
const [deletingJobId, setDeletingJobId] = useState<string | null>(null)
const [isDeletingJob, setIsDeletingJob] = useState(false)
// Per-customer used quota for selected year (only completed jobs count)
const customerYearlyUsed = useMemo(() => {
const map = new Map<string, number>()
for (const job of jobs) {
if (job.status === 'completed') {
map.set(job.customerId, (map.get(job.customerId) ?? 0) + job.rotDeduction)
}
}
return map
}, [jobs])
// Calculate ROT for current form input
const totalNum = parseFloat(total) || 0
const materialNum = parseFloat(material) || 0
const labor = Math.max(totalNum - materialNum, 0)
const usedQuota = customerYearlyUsed.get(customerId) ?? 0
const remainingQuota = Math.max(MAX_ROT_YEARLY - usedQuota, 0)
const rotDeduction = Math.round(Math.min(labor * ROT_RATE, remainingQuota) * 100) / 100
const customerPays = Math.round((totalNum - rotDeduction) * 100) / 100
// Calculate ROT deduction respecting quota for a specific customer
const calculateRotDeduction = useCallback((custId: string, laborAmount: number, excludeJobId?: string) => {
let used = 0
for (const job of allJobs) {
if (
job.customerId === custId &&
job.status === 'completed' &&
job.date.startsWith(selectedYear) &&
job.id !== excludeJobId
) {
used += job.rotDeduction
}
}
const remaining = Math.max(MAX_ROT_YEARLY - used, 0)
return Math.round(Math.min(laborAmount * ROT_RATE, remaining) * 100) / 100
}, [allJobs, selectedYear])
const handleSubmitJob = async (e: React.FormEvent) => {
e.preventDefault()
if (!customerId || totalNum <= 0) return
setIsSubmitting(true)
const customer = customers.find(c => c.id === customerId)
const id = crypto.randomUUID()
await save(`job:${id}`, {
customerId,
customerName: customer?.name ?? '',
description,
total: totalNum,
material: materialNum,
labor,
rotDeduction,
date: new Date().toISOString().slice(0, 10),
status: 'draft',
})
setDescription('')
setTotal('')
setMaterial('')
await refresh()
setIsSubmitting(false)
}
const handleAddCustomer = async () => {
if (!canAddCustomer) return
const id = crypto.randomUUID()
await save(`customer:${id}`, { name: newCustomerName.trim(), personalNumber: newCustomerPnr.trim() })
setNewCustomerName('')
setNewCustomerPnr('')
await refresh()
}
const openEditCustomer = (cust: Customer) => {
setEditingCustomer(cust)
setEditCustomerName(cust.name)
setEditCustomerPnr(cust.personalNumber)
}
const handleSaveCustomer = async () => {
if (!editingCustomer || !editCustomerName.trim() || editCustomerPnrError) return
setIsSavingCustomer(true)
await save(`customer:${editingCustomer.id}`, {
name: editCustomerName.trim(),
personalNumber: editCustomerPnr.trim(),
})
// Update customerName on all jobs belonging to this customer
const customerJobs = allJobs.filter(j => j.customerId === editingCustomer.id)
for (const job of customerJobs) {
await save(`job:${job.id}`, {
customerId: job.customerId,
customerName: editCustomerName.trim(),
description: job.description,
total: job.total,
material: job.material,
labor: job.labor,
rotDeduction: job.rotDeduction,
date: job.date,
status: job.status,
})
}
await refresh()
setIsSavingCustomer(false)
}
const openEditJob = (job: Job) => {
setEditingJob(job)
setEditJobCustomerId(job.customerId)
setEditJobDescription(job.description)
setEditJobTotal(String(job.total))
setEditJobMaterial(String(job.material))
}
const handleSaveJob = async () => {
if (!editingJob) return
const editTotalNum = parseFloat(editJobTotal) || 0
const editMaterialNum = parseFloat(editJobMaterial) || 0
if (editTotalNum <= 0) return
setIsSavingJob(true)
const editLabor = Math.max(editTotalNum - editMaterialNum, 0)
const newRot = calculateRotDeduction(editJobCustomerId, editLabor, editingJob.id)
const customer = customers.find(c => c.id === editJobCustomerId)
await save(`job:${editingJob.id}`, {
customerId: editJobCustomerId,
customerName: customer?.name ?? editingJob.customerName,
description: editJobDescription,
total: editTotalNum,
material: editMaterialNum,
labor: editLabor,
rotDeduction: newRot,
date: editingJob.date,
status: editingJob.status,
})
await refresh()
setIsSavingJob(false)
}
const handleDeleteJob = async () => {
if (!deletingJobId) return
setIsDeletingJob(true)
await remove(`job:${deletingJobId}`)
await refresh()
setIsDeletingJob(false)
setDeletingJobId(null)
}
const handleMarkCompleted = async (job: Job) => {
const rot = calculateRotDeduction(job.customerId, job.labor, job.id)
await save(`job:${job.id}`, {
customerId: job.customerId,
customerName: job.customerName,
description: job.description,
total: job.total,
material: job.material,
labor: job.labor,
rotDeduction: rot,
date: job.date,
status: 'completed',
})
await refresh()
}
const handleExportCsv = () => {
const completedJobs = jobs.filter(j => j.status === 'completed')
const header = 'Personnummer;Kundnamn;Arbetskostnad;ROTAvdrag;Datum'
const rows = completedJobs.map(job => {
const cust = customers.find(c => c.id === job.customerId)
const pnr = cust?.personalNumber ?? ''
return `${pnr};${job.customerName};${job.labor};${job.rotDeduction};${job.date}`
})
const csv = [header, ...rows].join('\n')
const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `rot-avdrag-${selectedYear}.csv`
link.click()
URL.revokeObjectURL(url)
}
if (isLoading) return <ExtensionLoadingSkeleton />
const completedJobCount = jobs.filter(j => j.status === 'completed').length
const totalRot = jobs.filter(j => j.status === 'completed').reduce((s, j) => s + j.rotDeduction, 0)
const totalRevenue = jobs.reduce((s, j) => s + j.total, 0)
export default function RotCalculatorWorkspace() {
return (
<EmptyExtensionState
title="ROT-avdragsberäkning"
description="Beräkning av ROT-avdrag för hantverkstjänster kommer snart. Du kommer kunna beräkna kundens avdrag och generera underlag till Skatteverket."
icon={<Calculator className="h-12 w-12 text-muted-foreground/40 mb-4" />}
/>
<div className="space-y-6">
<Tabs defaultValue="calculator">
<TabsList>
<TabsTrigger value="calculator">Kalkylator</TabsTrigger>
<TabsTrigger value="customers">Kunder</TabsTrigger>
<TabsTrigger value="jobs">Jobb</TabsTrigger>
</TabsList>
<TabsContent value="calculator" className="space-y-6 mt-4">
<div className="flex items-center gap-2">
<Label className="text-sm">Ar:</Label>
<Select value={selectedYear} onValueChange={setSelectedYear}>
<SelectTrigger className="w-28">
<SelectValue />
</SelectTrigger>
<SelectContent>
{yearOptions.map(y => (
<SelectItem key={y} value={String(y)}>{y}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{customers.length === 0 ? (
<div className="rounded-xl border p-6 text-center">
<p className="text-sm text-muted-foreground">
Lagg till kunder under fliken &quot;Kunder&quot; for att borja berakna ROT-avdrag.
</p>
</div>
) : (
<>
<DataEntryForm
title="Berakna ROT-avdrag"
onSubmit={handleSubmitJob}
submitLabel="Spara jobb"
isSubmitting={isSubmitting}
>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Kund</Label>
<Select value={customerId} onValueChange={setCustomerId}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{customers.map(c => <SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Beskrivning</Label>
<Input placeholder="T.ex. Badrumsrenovering" value={description} onChange={e => setDescription(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Totalt belopp (inkl. moms)</Label>
<Input type="number" min="0" placeholder="0" value={total} onChange={e => setTotal(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Materialkostnad</Label>
<Input type="number" min="0" placeholder="0" value={material} onChange={e => setMaterial(e.target.value)} />
</div>
</div>
{totalNum > 0 && (
<Card className="bg-muted/50">
<CardContent className="pt-4 space-y-2">
<div className="grid grid-cols-2 gap-2 text-sm">
<span className="text-muted-foreground">Arbetskostnad:</span>
<span className="text-right tabular-nums">{labor.toLocaleString('sv-SE')} kr</span>
<span className="text-muted-foreground">ROT-avdrag (30%):</span>
<span className="text-right tabular-nums font-medium text-green-600">-{rotDeduction.toLocaleString('sv-SE')} kr</span>
<span className="text-muted-foreground">Kunden betalar:</span>
<span className="text-right tabular-nums font-semibold">{customerPays.toLocaleString('sv-SE')} kr</span>
<span className="text-muted-foreground">Kvarvarande kvot:</span>
<span className="text-right tabular-nums">{Math.max(remainingQuota - rotDeduction, 0).toLocaleString('sv-SE')} kr</span>
</div>
</CardContent>
</Card>
)}
</DataEntryForm>
</>
)}
</TabsContent>
<TabsContent value="customers" className="space-y-6 mt-4">
<div className="flex gap-2 flex-wrap items-start">
<Input placeholder="Kundnamn" value={newCustomerName} onChange={e => setNewCustomerName(e.target.value)} className="max-w-xs" />
<div className="space-y-1">
<Input
placeholder="Personnummer (YYYYMMDD-XXXX)"
value={newCustomerPnr}
onChange={e => setNewCustomerPnr(e.target.value)}
className="max-w-xs"
/>
{newCustomerPnrError && (
<p className="text-xs text-red-600">{newCustomerPnrError}</p>
)}
</div>
<Button size="sm" onClick={handleAddCustomer} disabled={!canAddCustomer}>
<Plus className="h-4 w-4 mr-1" /> Lagg till
</Button>
</div>
{customers.length === 0 ? (
<p className="text-sm text-muted-foreground">Inga kunder tillagda annu.</p>
) : (
<div className="space-y-3">
{customers.map(cust => {
const used = customerYearlyUsed.get(cust.id) ?? 0
const pct = Math.min(Math.round((used / MAX_ROT_YEARLY) * 100), 100)
return (
<Card key={cust.id}>
<CardContent className="pt-4">
<div className="flex items-center justify-between mb-2">
<div>
<p className="font-medium text-sm">{cust.name}</p>
{cust.personalNumber && (
<p className="text-xs text-muted-foreground">{cust.personalNumber}</p>
)}
</div>
<div className="flex items-center gap-2">
<span className="text-sm tabular-nums">
{used.toLocaleString('sv-SE')} / {MAX_ROT_YEARLY.toLocaleString('sv-SE')} kr
</span>
<Button variant="ghost" size="sm" onClick={() => openEditCustomer(cust)}>
<Pencil className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
</div>
</div>
<Progress value={pct} className="h-2" />
</CardContent>
</Card>
)
})}
</div>
)}
</TabsContent>
<TabsContent value="jobs" className="space-y-6 mt-4">
<div className="flex items-center justify-between flex-wrap gap-2">
<div className="flex items-center gap-2">
<Label className="text-sm">Ar:</Label>
<Select value={selectedYear} onValueChange={setSelectedYear}>
<SelectTrigger className="w-28">
<SelectValue />
</SelectTrigger>
<SelectContent>
{yearOptions.map(y => (
<SelectItem key={y} value={String(y)}>{y}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{completedJobCount > 0 && (
<Button variant="outline" size="sm" onClick={handleExportCsv}>
<Download className="h-4 w-4 mr-1" /> Exportera CSV
</Button>
)}
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<KPICard label="Antal jobb" value={jobs.length} />
<KPICard label="Total ROT" value={totalRot.toLocaleString('sv-SE')} suffix="kr" />
<KPICard label="Total omsattning" value={totalRevenue.toLocaleString('sv-SE')} suffix="kr" />
</div>
{jobs.length === 0 ? (
<p className="text-sm text-muted-foreground">Inga jobb registrerade for {selectedYear}.</p>
) : (
<div className="rounded-xl border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Datum</TableHead>
<TableHead>Kund</TableHead>
<TableHead>Beskrivning</TableHead>
<TableHead className="text-right">Totalt</TableHead>
<TableHead className="text-right">ROT-avdrag</TableHead>
<TableHead>Status</TableHead>
<TableHead className="w-28"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{jobs.map(job => (
<TableRow key={job.id}>
<TableCell>{job.date}</TableCell>
<TableCell className="font-medium">{job.customerName}</TableCell>
<TableCell>{job.description}</TableCell>
<TableCell className="text-right tabular-nums">{job.total.toLocaleString('sv-SE')} kr</TableCell>
<TableCell className="text-right tabular-nums text-green-600">{job.rotDeduction.toLocaleString('sv-SE')} kr</TableCell>
<TableCell>
<Badge variant={job.status === 'completed' ? 'default' : 'secondary'}>
{job.status === 'completed' ? 'Klar' : 'Utkast'}
</Badge>
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
{job.status === 'draft' && (
<Button variant="ghost" size="sm" onClick={() => handleMarkCompleted(job)} title="Markera som klar">
<Check className="h-3.5 w-3.5 text-green-600" />
</Button>
)}
<Button variant="ghost" size="sm" onClick={() => openEditJob(job)} title="Redigera">
<Pencil className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
<Button variant="ghost" size="sm" onClick={() => setDeletingJobId(job.id)} title="Ta bort">
<Trash2 className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</TabsContent>
</Tabs>
{/* Edit Customer Dialog */}
<EditEntryDialog
open={editingCustomer !== null}
onOpenChange={open => { if (!open) setEditingCustomer(null) }}
title="Redigera kund"
description="Uppdatera kunduppgifter."
onSave={handleSaveCustomer}
isSaving={isSavingCustomer}
>
<div className="space-y-2">
<Label>Namn</Label>
<Input value={editCustomerName} onChange={e => setEditCustomerName(e.target.value)} />
</div>
<div className="space-y-1">
<Label>Personnummer</Label>
<Input
placeholder="YYYYMMDD-XXXX"
value={editCustomerPnr}
onChange={e => setEditCustomerPnr(e.target.value)}
/>
{editCustomerPnrError && (
<p className="text-xs text-red-600">{editCustomerPnrError}</p>
)}
</div>
</EditEntryDialog>
{/* Edit Job Dialog */}
<EditEntryDialog
open={editingJob !== null}
onOpenChange={open => { if (!open) setEditingJob(null) }}
title="Redigera jobb"
description="Uppdatera jobbdetaljer. ROT-avdrag beraknas om automatiskt."
onSave={handleSaveJob}
isSaving={isSavingJob}
>
<div className="space-y-2">
<Label>Kund</Label>
<Select value={editJobCustomerId} onValueChange={setEditJobCustomerId}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{customers.map(c => <SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Beskrivning</Label>
<Input value={editJobDescription} onChange={e => setEditJobDescription(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Totalt belopp (inkl. moms)</Label>
<Input type="number" min="0" value={editJobTotal} onChange={e => setEditJobTotal(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Materialkostnad</Label>
<Input type="number" min="0" value={editJobMaterial} onChange={e => setEditJobMaterial(e.target.value)} />
</div>
{(() => {
const editTotalNum = parseFloat(editJobTotal) || 0
const editMaterialNum = parseFloat(editJobMaterial) || 0
const editLabor = Math.max(editTotalNum - editMaterialNum, 0)
const editRot = editingJob
? calculateRotDeduction(editJobCustomerId, editLabor, editingJob.id)
: 0
return editTotalNum > 0 ? (
<div className="rounded-lg border p-3 bg-muted/50 text-sm space-y-1">
<div className="flex justify-between">
<span className="text-muted-foreground">Arbetskostnad:</span>
<span className="tabular-nums">{editLabor.toLocaleString('sv-SE')} kr</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">ROT-avdrag (30%):</span>
<span className="tabular-nums font-medium text-green-600">-{editRot.toLocaleString('sv-SE')} kr</span>
</div>
</div>
) : null
})()}
</EditEntryDialog>
{/* Delete Job Confirmation */}
<ConfirmDeleteDialog
open={deletingJobId !== null}
onOpenChange={open => { if (!open) setDeletingJobId(null) }}
title="Ta bort jobb"
description="Ar du saker pa att du vill ta bort detta jobb? Kundens anvanda kvot minskar om jobbet var slutfort."
onConfirm={handleDeleteJob}
isDeleting={isDeletingJob}
/>
</div>
)
}
@@ -1,14 +1,989 @@
'use client'
import { BarChart3 } from 'lucide-react'
import EmptyExtensionState from '@/components/extensions/shared/EmptyExtensionState'
import { useState, useMemo, useCallback } from 'react'
import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
import { useExtensionData } from '@/lib/extensions/use-extension-data'
import KPICard from '@/components/extensions/shared/KPICard'
import DateRangeFilter from '@/components/extensions/shared/DateRangeFilter'
import SetupPrompt from '@/components/extensions/shared/SetupPrompt'
import DataEntryForm from '@/components/extensions/shared/DataEntryForm'
import ExtensionLoadingSkeleton from '@/components/extensions/shared/ExtensionLoadingSkeleton'
import ConfirmDeleteDialog from '@/components/extensions/shared/ConfirmDeleteDialog'
import EditEntryDialog from '@/components/extensions/shared/EditEntryDialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select'
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from '@/components/ui/table'
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
} from '@/components/ui/dialog'
import { Pencil, Plus, Trash2, ArrowUp, ArrowDown, Minus, TrendingUp } from 'lucide-react'
import { cn } from '@/lib/utils'
export default function MultichannelRevenueWorkspace() {
interface Channel {
name: string
color: string
}
interface RevenueEntry {
id: string
month: string
channel: string
revenue: number
orderCount: number
}
const DEFAULT_COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899']
const COLOR_PRESETS = [
'#3b82f6', '#10b981', '#f59e0b', '#ef4444',
'#8b5cf6', '#ec4899', '#06b6d4', '#84cc16',
]
type SortMode = 'revenue' | 'growth'
function formatCurrency(value: number): string {
return Math.round(value * 100) / 100 === 0
? '0'
: (Math.round(value * 100) / 100).toLocaleString('sv-SE')
}
function formatAOV(revenue: number, orders: number): string {
if (orders <= 0) return '-'
return Math.round(revenue / orders).toLocaleString('sv-SE')
}
function GrowthIndicator({ current, previous }: { current: number; previous: number }) {
if (previous === 0 && current === 0) {
return (
<span className="inline-flex items-center gap-0.5 text-xs text-muted-foreground">
<Minus className="h-3 w-3" />
<span>0%</span>
</span>
)
}
if (previous === 0) {
return (
<span className="inline-flex items-center gap-0.5 text-xs text-green-600">
<ArrowUp className="h-3 w-3" />
<span>Ny</span>
</span>
)
}
const pctChange = Math.round(((current - previous) / previous) * 1000) / 10
if (pctChange === 0) {
return (
<span className="inline-flex items-center gap-0.5 text-xs text-muted-foreground">
<Minus className="h-3 w-3" />
<span>0%</span>
</span>
)
}
const improving = pctChange > 0
return (
<EmptyExtensionState
title="Flerkanalintäkter"
description="Uppföljning av intäkter per försäljningskanal kommer snart. Du kommer kunna jämföra prestanda mellan webshop, marknadsplatser och fysisk butik."
icon={<BarChart3 className="h-12 w-12 text-muted-foreground/40 mb-4" />}
/>
<span className={cn(
'inline-flex items-center gap-0.5 text-xs',
improving ? 'text-green-600' : 'text-red-600'
)}>
{improving
? <ArrowUp className="h-3 w-3" />
: <ArrowDown className="h-3 w-3" />
}
<span>{pctChange > 0 ? '+' : ''}{pctChange}%</span>
</span>
)
}
export default function MultichannelRevenueWorkspace({}: WorkspaceComponentProps) {
const now = new Date()
const [dateRange, setDateRange] = useState({
start: new Date(now.getFullYear(), 0, 1).toISOString().slice(0, 10),
end: new Date(now.getFullYear(), 11, 31).toISOString().slice(0, 10),
})
const { data, save, remove, refresh, isLoading } = useExtensionData('ecommerce', 'multichannel-revenue')
const channels = useMemo(() => {
const s = data.find(d => d.key === 'settings')?.value as { channels?: Channel[] } | undefined
return s?.channels ?? []
}, [data])
// All entries (unfiltered by date, needed for previous period comparison)
const allEntries = useMemo(() =>
data.filter(d => d.key.startsWith('entry:'))
.map(d => ({
id: d.key.replace('entry:', ''),
...(d.value as Omit<RevenueEntry, 'id'>),
}))
, [data])
// Entries filtered to current date range
const entries = useMemo(() =>
allEntries
.filter(e => {
const eStart = e.month + '-01'
const eEnd = e.month + '-31'
return eEnd >= dateRange.start && eStart <= dateRange.end
})
.sort((a, b) => b.month.localeCompare(a.month))
, [allEntries, dateRange])
// Previous year entries for the same period
const prevYearEntries = useMemo(() => {
const startDate = new Date(dateRange.start + 'T00:00:00')
const endDate = new Date(dateRange.end + 'T00:00:00')
const prevStart = new Date(startDate)
prevStart.setFullYear(prevStart.getFullYear() - 1)
const prevEnd = new Date(endDate)
prevEnd.setFullYear(prevEnd.getFullYear() - 1)
const prevStartStr = prevStart.toISOString().slice(0, 10)
const prevEndStr = prevEnd.toISOString().slice(0, 10)
return allEntries.filter(e => {
const eStart = e.month + '-01'
const eEnd = e.month + '-31'
return eEnd >= prevStartStr && eStart <= prevEndStr
})
}, [allEntries, dateRange])
// Form state
const [entryMonth, setEntryMonth] = useState(now.toISOString().slice(0, 7))
const [selectedChannel, setEntryChannel] = useState('')
const entryChannel = selectedChannel || (channels.length > 0 ? channels[0].name : '')
const [entryRevenue, setEntryRevenue] = useState('')
const [entryOrders, setEntryOrders] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
// Channel management
const [newChannelName, setNewChannelName] = useState('')
const [sortMode, setSortMode] = useState<SortMode>('revenue')
// Duplicate confirmation dialog state
const [duplicateDialogOpen, setDuplicateDialogOpen] = useState(false)
const [pendingEntry, setPendingEntry] = useState<{
month: string; channel: string; revenue: number; orderCount: number; existingId: string
} | null>(null)
// Edit entry dialog state
const [editDialogOpen, setEditDialogOpen] = useState(false)
const [editingEntry, setEditingEntry] = useState<RevenueEntry | null>(null)
const [editMonth, setEditMonth] = useState('')
const [editChannel, setEditChannel] = useState('')
const [editRevenue, setEditRevenue] = useState('')
const [editOrders, setEditOrders] = useState('')
const [isSavingEdit, setIsSavingEdit] = useState(false)
// Delete entry dialog state
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [deletingEntryId, setDeletingEntryId] = useState<string | null>(null)
const [isDeleting, setIsDeleting] = useState(false)
// Rename channel dialog state
const [renameDialogOpen, setRenameDialogOpen] = useState(false)
const [renamingChannel, setRenamingChannel] = useState<string | null>(null)
const [newName, setNewName] = useState('')
const [isSavingRename, setIsSavingRename] = useState(false)
// Color picker state
const [colorPickerChannel, setColorPickerChannel] = useState<string | null>(null)
// ---- Computed values ----
const totalRevenue = entries.reduce((s, e) => s + e.revenue, 0)
const totalOrders = entries.reduce((s, e) => s + e.orderCount, 0)
const overallAOV = totalOrders > 0 ? Math.round(totalRevenue / totalOrders) : 0
const prevYearTotalRevenue = prevYearEntries.reduce((s, e) => s + e.revenue, 0)
// Channel totals for current period
const channelTotals = useMemo(() => {
const map = new Map<string, { revenue: number; orders: number }>()
for (const e of entries) {
const existing = map.get(e.channel) ?? { revenue: 0, orders: 0 }
existing.revenue += e.revenue
existing.orders += e.orderCount
map.set(e.channel, existing)
}
return Array.from(map.entries())
.map(([channel, d]) => ({ channel, ...d }))
}, [entries])
// Channel totals for previous year period
const prevYearChannelTotals = useMemo(() => {
const map = new Map<string, { revenue: number; orders: number }>()
for (const e of prevYearEntries) {
const existing = map.get(e.channel) ?? { revenue: 0, orders: 0 }
existing.revenue += e.revenue
existing.orders += e.orderCount
map.set(e.channel, existing)
}
return map
}, [prevYearEntries])
// Growth rate per channel
const channelGrowth = useMemo(() => {
const growth = new Map<string, number>()
for (const ct of channelTotals) {
const prev = prevYearChannelTotals.get(ct.channel)
const prevRev = prev?.revenue ?? 0
if (prevRev > 0) {
growth.set(ct.channel, ((ct.revenue - prevRev) / prevRev) * 100)
} else if (ct.revenue > 0) {
growth.set(ct.channel, Infinity) // New channel
} else {
growth.set(ct.channel, 0)
}
}
return growth
}, [channelTotals, prevYearChannelTotals])
// Sorted channel totals based on sort mode
const sortedChannelTotals = useMemo(() => {
const sorted = [...channelTotals]
if (sortMode === 'growth') {
sorted.sort((a, b) => {
const growthA = channelGrowth.get(a.channel) ?? 0
const growthB = channelGrowth.get(b.channel) ?? 0
// Infinity (new channels) goes to the top
if (growthA === Infinity && growthB !== Infinity) return -1
if (growthB === Infinity && growthA !== Infinity) return 1
return growthB - growthA
})
} else {
sorted.sort((a, b) => b.revenue - a.revenue)
}
return sorted
}, [channelTotals, sortMode, channelGrowth])
const bestChannel = useMemo(() => {
const sorted = [...channelTotals].sort((a, b) => b.revenue - a.revenue)
return sorted[0]?.channel ?? '-'
}, [channelTotals])
// Monthly comparison (months as rows, channels as columns)
const monthlyComparison = useMemo(() => {
const monthMap = new Map<string, Map<string, { revenue: number; orders: number }>>()
for (const e of entries) {
if (!monthMap.has(e.month)) monthMap.set(e.month, new Map())
const channelMap = monthMap.get(e.month)!
const existing = channelMap.get(e.channel) ?? { revenue: 0, orders: 0 }
existing.revenue += e.revenue
existing.orders += e.orderCount
channelMap.set(e.channel, existing)
}
return Array.from(monthMap.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([month, channelData]) => ({
month,
channels: Object.fromEntries(
Array.from(channelData.entries()).map(([ch, d]) => [ch, d])
) as Record<string, { revenue: number; orders: number }>,
total: Array.from(channelData.values()).reduce((s, v) => s + v.revenue, 0),
totalOrders: Array.from(channelData.values()).reduce((s, v) => s + v.orders, 0),
}))
}, [entries])
// Channel bar chart (CSS-based)
const maxChannelRevenue = Math.max(...sortedChannelTotals.map(c => c.revenue), 1)
// ---- Handlers ----
const findDuplicateEntry = useCallback((month: string, channel: string) => {
return allEntries.find(e => e.month === month && e.channel === channel) ?? null
}, [allEntries])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
const rev = parseFloat(entryRevenue)
const orders = parseInt(entryOrders) || 0
if (isNaN(rev) || rev <= 0 || !entryChannel) return
// Check for duplicate
const existing = findDuplicateEntry(entryMonth, entryChannel)
if (existing) {
setPendingEntry({
month: entryMonth,
channel: entryChannel,
revenue: rev,
orderCount: orders,
existingId: existing.id,
})
setDuplicateDialogOpen(true)
return
}
setIsSubmitting(true)
const id = crypto.randomUUID()
await save(`entry:${id}`, {
month: entryMonth,
channel: entryChannel,
revenue: rev,
orderCount: orders,
})
setEntryRevenue('')
setEntryOrders('')
await refresh()
setIsSubmitting(false)
}
const handleDuplicateUpdate = async () => {
if (!pendingEntry) return
setIsSubmitting(true)
await save(`entry:${pendingEntry.existingId}`, {
month: pendingEntry.month,
channel: pendingEntry.channel,
revenue: pendingEntry.revenue,
orderCount: pendingEntry.orderCount,
})
setEntryRevenue('')
setEntryOrders('')
setDuplicateDialogOpen(false)
setPendingEntry(null)
await refresh()
setIsSubmitting(false)
}
const handleDuplicateCreateNew = async () => {
if (!pendingEntry) return
setIsSubmitting(true)
const id = crypto.randomUUID()
await save(`entry:${id}`, {
month: pendingEntry.month,
channel: pendingEntry.channel,
revenue: pendingEntry.revenue,
orderCount: pendingEntry.orderCount,
})
setEntryRevenue('')
setEntryOrders('')
setDuplicateDialogOpen(false)
setPendingEntry(null)
await refresh()
setIsSubmitting(false)
}
const handleAddChannel = async () => {
if (!newChannelName.trim()) return
const color = DEFAULT_COLORS[channels.length % DEFAULT_COLORS.length]
const updated = [...channels, { name: newChannelName.trim(), color }]
await save('settings', { channels: updated })
setNewChannelName('')
}
const handleRemoveChannel = async (name: string) => {
const updated = channels.filter(c => c.name !== name)
await save('settings', { channels: updated })
}
const handleChangeChannelColor = async (channelName: string, color: string) => {
const updated = channels.map(c =>
c.name === channelName ? { ...c, color } : c
)
await save('settings', { channels: updated })
setColorPickerChannel(null)
}
const handleStartRename = (channelName: string) => {
setRenamingChannel(channelName)
setNewName(channelName)
setRenameDialogOpen(true)
}
const handleRenameChannel = async () => {
if (!renamingChannel || !newName.trim() || newName.trim() === renamingChannel) return
setIsSavingRename(true)
const trimmedName = newName.trim()
// Update channel settings
const updatedChannels = channels.map(c =>
c.name === renamingChannel ? { ...c, name: trimmedName } : c
)
await save('settings', { channels: updatedChannels })
// Update all entries that reference the old channel name
const entriesToUpdate = allEntries.filter(e => e.channel === renamingChannel)
for (const entry of entriesToUpdate) {
await save(`entry:${entry.id}`, {
month: entry.month,
channel: trimmedName,
revenue: entry.revenue,
orderCount: entry.orderCount,
})
}
setIsSavingRename(false)
setRenameDialogOpen(false)
setRenamingChannel(null)
setNewName('')
await refresh()
}
const handleStartEdit = (entry: RevenueEntry) => {
setEditingEntry(entry)
setEditMonth(entry.month)
setEditChannel(entry.channel)
setEditRevenue(String(entry.revenue))
setEditOrders(String(entry.orderCount))
setEditDialogOpen(true)
}
const handleSaveEdit = async () => {
if (!editingEntry) return
const rev = parseFloat(editRevenue)
const orders = parseInt(editOrders) || 0
if (isNaN(rev) || rev <= 0 || !editChannel) return
setIsSavingEdit(true)
await save(`entry:${editingEntry.id}`, {
month: editMonth,
channel: editChannel,
revenue: rev,
orderCount: orders,
})
setIsSavingEdit(false)
setEditDialogOpen(false)
setEditingEntry(null)
await refresh()
}
const handleStartDelete = (entryId: string) => {
setDeletingEntryId(entryId)
setDeleteDialogOpen(true)
}
const handleConfirmDelete = async () => {
if (!deletingEntryId) return
setIsDeleting(true)
await remove(`entry:${deletingEntryId}`)
setIsDeleting(false)
setDeleteDialogOpen(false)
setDeletingEntryId(null)
}
const handleSetup = async (values: Record<string, string>) => {
const names = values.channels.split(',').map(n => n.trim()).filter(Boolean)
const channelList = names.map((name, i) => ({
name,
color: DEFAULT_COLORS[i % DEFAULT_COLORS.length],
}))
await save('settings', { channels: channelList })
}
if (isLoading) return <ExtensionLoadingSkeleton />
if (channels.length === 0) {
return (
<SetupPrompt
title="Konfigurera kanaler"
description="Ange dina forsaljningskanaler (kommaseparerade, t.ex. Webshop, Amazon, Fysisk butik)."
fields={[{ key: 'channels', label: 'Kanaler', type: 'text', placeholder: 'Webshop, Amazon, Fysisk butik' }]}
onSave={handleSetup}
/>
)
}
return (
<div className="space-y-6">
<DateRangeFilter onRangeChange={(start, end) => setDateRange({ start, end })} />
{/* KPI Cards */}
<div className="grid grid-cols-1 sm:grid-cols-4 gap-4">
<KPICard
label="Total intakt"
value={formatCurrency(totalRevenue)}
suffix="kr"
trend={prevYearTotalRevenue > 0 ? {
value: Math.round(((totalRevenue - prevYearTotalRevenue) / prevYearTotalRevenue) * 1000) / 10,
label: 'mot fg ar',
} : undefined}
/>
<KPICard label="Basta kanal" value={bestChannel} />
<KPICard
label="Genomsnittligt ordervarde"
value={overallAOV > 0 ? overallAOV.toLocaleString('sv-SE') : '-'}
suffix={overallAOV > 0 ? 'kr' : undefined}
/>
<KPICard label="Antal kanaler" value={channels.length} />
</div>
{/* Channel management */}
<div className="rounded-xl border p-4">
<h3 className="text-sm font-semibold mb-3">Kanaler</h3>
<div className="flex flex-wrap gap-2 mb-3">
{channels.map(ch => (
<div key={ch.name} className="relative flex items-center gap-1.5 rounded-md border px-2 py-1 text-sm">
{/* Color swatch - clickable for color picker */}
<button
type="button"
className="w-3 h-3 rounded-full border border-black/10 cursor-pointer hover:ring-2 hover:ring-offset-1 hover:ring-primary/30"
style={{ backgroundColor: ch.color }}
onClick={() => setColorPickerChannel(
colorPickerChannel === ch.name ? null : ch.name
)}
title="Byt farg"
/>
<span>{ch.name}</span>
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0"
onClick={() => handleStartRename(ch.name)}
title="Byt namn"
>
<Pencil className="h-3 w-3 text-muted-foreground" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0"
onClick={() => handleRemoveChannel(ch.name)}
title="Ta bort kanal"
>
<Trash2 className="h-3 w-3 text-muted-foreground" />
</Button>
{/* Color picker dropdown */}
{colorPickerChannel === ch.name && (
<div className="absolute top-full left-0 mt-1 z-10 rounded-md border bg-popover p-2 shadow-md">
<div className="grid grid-cols-4 gap-1.5">
{COLOR_PRESETS.map(color => (
<button
key={color}
type="button"
className={cn(
'w-6 h-6 rounded-full border-2 cursor-pointer hover:scale-110 transition-transform',
ch.color === color ? 'border-foreground' : 'border-transparent'
)}
style={{ backgroundColor: color }}
onClick={() => handleChangeChannelColor(ch.name, color)}
/>
))}
</div>
</div>
)}
</div>
))}
</div>
<div className="flex gap-2">
<Input
placeholder="Ny kanal"
value={newChannelName}
onChange={e => setNewChannelName(e.target.value)}
className="max-w-xs"
onKeyDown={e => {
if (e.key === 'Enter') {
e.preventDefault()
handleAddChannel()
}
}}
/>
<Button size="sm" variant="outline" onClick={handleAddChannel} disabled={!newChannelName.trim()}>
<Plus className="h-4 w-4 mr-1" /> Lagg till
</Button>
</div>
</div>
{/* Entry form */}
<DataEntryForm
title="Registrera manadsdata"
onSubmit={handleSubmit}
submitLabel="Registrera"
isSubmitting={isSubmitting}
>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Manad</Label>
<Input type="month" value={entryMonth} onChange={e => setEntryMonth(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Kanal</Label>
<Select value={entryChannel} onValueChange={setEntryChannel}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{channels.map(c => <SelectItem key={c.name} value={c.name}>{c.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Intakt (kr)</Label>
<Input type="number" min="0" placeholder="0" value={entryRevenue} onChange={e => setEntryRevenue(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Antal ordrar</Label>
<Input type="number" min="0" placeholder="0" value={entryOrders} onChange={e => setEntryOrders(e.target.value)} />
</div>
</div>
</DataEntryForm>
{/* Duplicate confirmation dialog */}
<Dialog open={duplicateDialogOpen} onOpenChange={setDuplicateDialogOpen}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Post finns redan</DialogTitle>
<DialogDescription>
Det finns redan en post for {pendingEntry?.channel} i {pendingEntry?.month}.
Vill du uppdatera den befintliga posten eller skapa en ny?
</DialogDescription>
</DialogHeader>
<DialogFooter className="flex-col gap-2 sm:flex-row">
<Button variant="outline" onClick={() => setDuplicateDialogOpen(false)} disabled={isSubmitting}>
Avbryt
</Button>
<Button variant="secondary" onClick={handleDuplicateCreateNew} disabled={isSubmitting}>
Skapa ny
</Button>
<Button onClick={handleDuplicateUpdate} disabled={isSubmitting}>
{isSubmitting ? 'Sparar...' : 'Uppdatera befintlig'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Channel comparison bar chart */}
{sortedChannelTotals.length > 0 && (
<div>
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold">Kanaljamforelse</h3>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">Sortera:</span>
<Button
size="sm"
variant={sortMode === 'revenue' ? 'default' : 'outline'}
className="h-7 text-xs px-2"
onClick={() => setSortMode('revenue')}
>
Intakt
</Button>
<Button
size="sm"
variant={sortMode === 'growth' ? 'default' : 'outline'}
className="h-7 text-xs px-2"
onClick={() => setSortMode('growth')}
>
<TrendingUp className="h-3 w-3 mr-1" />
Tillvaxt
</Button>
</div>
</div>
<div className="rounded-xl border p-4 space-y-3">
{sortedChannelTotals.map(ct => {
const channelConfig = channels.find(c => c.name === ct.channel)
const barWidth = Math.round((ct.revenue / maxChannelRevenue) * 100)
const prevData = prevYearChannelTotals.get(ct.channel)
const prevRev = prevData?.revenue ?? 0
const aov = ct.orders > 0 ? Math.round(ct.revenue / ct.orders) : 0
return (
<div key={ct.channel} className="space-y-1">
<div className="flex justify-between text-sm">
<span className="font-medium">{ct.channel}</span>
<div className="flex items-center gap-3">
<span className="text-xs text-muted-foreground">
AOV: {aov > 0 ? aov.toLocaleString('sv-SE') + ' kr' : '-'}
</span>
<GrowthIndicator current={ct.revenue} previous={prevRev} />
<span className="tabular-nums">{formatCurrency(ct.revenue)} kr</span>
</div>
</div>
<div className="h-6 w-full rounded bg-muted overflow-hidden">
<div
className="h-full rounded transition-all"
style={{
width: `${barWidth}%`,
backgroundColor: channelConfig?.color ?? '#3b82f6',
}}
/>
</div>
</div>
)
})}
</div>
</div>
)}
{/* Entries table with edit/delete */}
{entries.length > 0 && (
<div>
<h3 className="text-sm font-semibold mb-3">Registrerade poster</h3>
<div className="rounded-xl border overflow-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Manad</TableHead>
<TableHead>Kanal</TableHead>
<TableHead className="text-right">Intakt</TableHead>
<TableHead className="text-right">Ordrar</TableHead>
<TableHead className="text-right">AOV</TableHead>
<TableHead className="text-right w-[80px]">Atgarder</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{entries.map(entry => {
const channelConfig = channels.find(c => c.name === entry.channel)
return (
<TableRow
key={entry.id}
className="cursor-pointer hover:bg-muted/50"
onClick={() => handleStartEdit(entry)}
>
<TableCell className="font-medium">{entry.month}</TableCell>
<TableCell>
<div className="flex items-center gap-1.5">
<div
className="w-2.5 h-2.5 rounded-full"
style={{ backgroundColor: channelConfig?.color ?? '#3b82f6' }}
/>
{entry.channel}
</div>
</TableCell>
<TableCell className="text-right tabular-nums">
{formatCurrency(entry.revenue)} kr
</TableCell>
<TableCell className="text-right tabular-nums">
{entry.orderCount}
</TableCell>
<TableCell className="text-right tabular-nums">
{formatAOV(entry.revenue, entry.orderCount)} {entry.orderCount > 0 ? 'kr' : ''}
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1" onClick={e => e.stopPropagation()}>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={() => handleStartEdit(entry)}
title="Redigera"
>
<Pencil className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={() => handleStartDelete(entry.id)}
title="Ta bort"
>
<Trash2 className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
</div>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
</div>
)}
{/* Monthly comparison table */}
{monthlyComparison.length > 0 && (
<div>
<h3 className="text-sm font-semibold mb-3">Manadsjamforelse</h3>
<div className="rounded-xl border overflow-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Manad</TableHead>
{channels.map(ch => (
<TableHead key={ch.name} className="text-right">{ch.name}</TableHead>
))}
<TableHead className="text-right font-semibold">Total</TableHead>
<TableHead className="text-right">AOV</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{monthlyComparison.map(row => (
<TableRow key={row.month}>
<TableCell className="font-medium">{row.month}</TableCell>
{channels.map(ch => {
const chData = row.channels[ch.name]
return (
<TableCell key={ch.name} className="text-right tabular-nums">
{chData ? formatCurrency(chData.revenue) : '0'}
</TableCell>
)
})}
<TableCell className="text-right tabular-nums font-semibold">
{formatCurrency(row.total)}
</TableCell>
<TableCell className="text-right tabular-nums">
{row.totalOrders > 0
? Math.round(row.total / row.totalOrders).toLocaleString('sv-SE') + ' kr'
: '-'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)}
{/* Period comparison: previous year */}
{(prevYearEntries.length > 0 || channelTotals.length > 0) && (
<div>
<h3 className="text-sm font-semibold mb-3">Arsjamforelse per kanal</h3>
<div className="rounded-xl border overflow-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Kanal</TableHead>
<TableHead className="text-right">Nuvarande period</TableHead>
<TableHead className="text-right">Foregaende ar</TableHead>
<TableHead className="text-right">Tillvaxt</TableHead>
<TableHead className="text-right">AOV (nu)</TableHead>
<TableHead className="text-right">AOV (fg ar)</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sortedChannelTotals.map(ct => {
const prevData = prevYearChannelTotals.get(ct.channel)
const prevRev = prevData?.revenue ?? 0
const prevOrd = prevData?.orders ?? 0
return (
<TableRow key={ct.channel}>
<TableCell className="font-medium">
<div className="flex items-center gap-1.5">
<div
className="w-2.5 h-2.5 rounded-full"
style={{ backgroundColor: channels.find(c => c.name === ct.channel)?.color ?? '#3b82f6' }}
/>
{ct.channel}
</div>
</TableCell>
<TableCell className="text-right tabular-nums">
{formatCurrency(ct.revenue)} kr
</TableCell>
<TableCell className="text-right tabular-nums">
{prevRev > 0 ? formatCurrency(prevRev) + ' kr' : '-'}
</TableCell>
<TableCell className="text-right">
<GrowthIndicator current={ct.revenue} previous={prevRev} />
</TableCell>
<TableCell className="text-right tabular-nums">
{formatAOV(ct.revenue, ct.orders)} {ct.orders > 0 ? 'kr' : ''}
</TableCell>
<TableCell className="text-right tabular-nums">
{formatAOV(prevRev, prevOrd)} {prevOrd > 0 ? 'kr' : ''}
</TableCell>
</TableRow>
)
})}
{/* Totals row */}
<TableRow className="border-t-2 font-semibold">
<TableCell>Totalt</TableCell>
<TableCell className="text-right tabular-nums">
{formatCurrency(totalRevenue)} kr
</TableCell>
<TableCell className="text-right tabular-nums">
{prevYearTotalRevenue > 0 ? formatCurrency(prevYearTotalRevenue) + ' kr' : '-'}
</TableCell>
<TableCell className="text-right">
<GrowthIndicator current={totalRevenue} previous={prevYearTotalRevenue} />
</TableCell>
<TableCell className="text-right tabular-nums">
{overallAOV > 0 ? overallAOV.toLocaleString('sv-SE') + ' kr' : '-'}
</TableCell>
<TableCell className="text-right tabular-nums">
{(() => {
const prevTotalOrders = prevYearEntries.reduce((s, e) => s + e.orderCount, 0)
return prevTotalOrders > 0
? Math.round(prevYearTotalRevenue / prevTotalOrders).toLocaleString('sv-SE') + ' kr'
: '-'
})()}
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</div>
)}
{/* Edit entry dialog */}
<EditEntryDialog
open={editDialogOpen}
onOpenChange={setEditDialogOpen}
title="Redigera post"
description={editingEntry ? `${editingEntry.channel} - ${editingEntry.month}` : undefined}
onSave={handleSaveEdit}
isSaving={isSavingEdit}
>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="edit-month">Manad</Label>
<Input
id="edit-month"
type="month"
value={editMonth}
onChange={e => setEditMonth(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-channel">Kanal</Label>
<Select value={editChannel} onValueChange={setEditChannel}>
<SelectTrigger id="edit-channel"><SelectValue /></SelectTrigger>
<SelectContent>
{channels.map(c => (
<SelectItem key={c.name} value={c.name}>{c.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="edit-revenue">Intakt (kr)</Label>
<Input
id="edit-revenue"
type="number"
min="0"
value={editRevenue}
onChange={e => setEditRevenue(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-orders">Antal ordrar</Label>
<Input
id="edit-orders"
type="number"
min="0"
value={editOrders}
onChange={e => setEditOrders(e.target.value)}
/>
</div>
</div>
</EditEntryDialog>
{/* Delete confirmation dialog */}
<ConfirmDeleteDialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
title="Ta bort post"
description="Ar du saker pa att du vill ta bort denna intaktspost? Atgarden kan inte angras."
onConfirm={handleConfirmDelete}
isDeleting={isDeleting}
/>
{/* Rename channel dialog */}
<EditEntryDialog
open={renameDialogOpen}
onOpenChange={setRenameDialogOpen}
title="Byt namn pa kanal"
description={`Nuvarande namn: ${renamingChannel ?? ''}. Alla registrerade poster uppdateras automatiskt.`}
onSave={handleRenameChannel}
isSaving={isSavingRename}
>
<div className="space-y-2">
<Label htmlFor="rename-channel">Nytt namn</Label>
<Input
id="rename-channel"
value={newName}
onChange={e => setNewName(e.target.value)}
placeholder="Kanalnamn"
/>
</div>
</EditEntryDialog>
</div>
)
}
@@ -1,14 +1,875 @@
'use client'
import { ShoppingBag } from 'lucide-react'
import EmptyExtensionState from '@/components/extensions/shared/EmptyExtensionState'
import { useState, useMemo } from 'react'
import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
import { useExtensionData } from '@/lib/extensions/use-extension-data'
import KPICard from '@/components/extensions/shared/KPICard'
import CsvImportWizard from '@/components/extensions/shared/CsvImportWizard'
import MonthlyTrendTable from '@/components/extensions/shared/MonthlyTrendTable'
import ExtensionLoadingSkeleton from '@/components/extensions/shared/ExtensionLoadingSkeleton'
import ConfirmDeleteDialog from '@/components/extensions/shared/ConfirmDeleteDialog'
import EditEntryDialog from '@/components/extensions/shared/EditEntryDialog'
import DataEntryForm from '@/components/extensions/shared/DataEntryForm'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select'
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from '@/components/ui/table'
import { Badge } from '@/components/ui/badge'
import { Pencil, Trash2, ChevronLeft, ChevronRight } from 'lucide-react'
interface ShopifyOrder {
id: string
name: string
createdAt: string
total: number
subtotal: number
shipping: number
taxes: number
paymentMethod: string
fulfillmentStatus: string
}
interface ImportRecord {
id: string
date: string
rowCount: number
}
const TARGET_FIELDS = [
{ key: 'name', label: 'Order', required: true },
{ key: 'createdAt', label: 'Datum', required: true },
{ key: 'total', label: 'Total', required: true },
{ key: 'subtotal', label: 'Subtotal' },
{ key: 'shipping', label: 'Frakt' },
{ key: 'taxes', label: 'Moms' },
{ key: 'paymentMethod', label: 'Betalmetod' },
{ key: 'fulfillmentStatus', label: 'Leveransstatus' },
]
const DEFAULT_MAPPINGS: Record<string, string> = {
name: 'Name',
createdAt: 'Created at',
total: 'Total',
subtotal: 'Subtotal',
shipping: 'Shipping',
taxes: 'Taxes',
paymentMethod: 'Payment Method',
fulfillmentStatus: 'Fulfillment Status',
}
const PAGES_SIZE = 20
export default function ShopifyImportWorkspace({}: WorkspaceComponentProps) {
const { data, save, remove, refresh, isLoading } = useExtensionData('ecommerce', 'shopify-import')
const orders = useMemo(() =>
data.filter(d => d.key.startsWith('order:'))
.map(d => ({ id: d.key.replace('order:', ''), ...(d.value as Omit<ShopifyOrder, 'id'>) }))
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
, [data])
const imports = useMemo(() =>
data.filter(d => d.key.startsWith('import:'))
.map(d => ({ id: d.key, ...(d.value as Omit<ImportRecord, 'id'>) }))
.sort((a, b) => b.date.localeCompare(a.date))
, [data])
// ---------------------------------------------------------------------------
// Filter state
// ---------------------------------------------------------------------------
const [searchQuery, setSearchQuery] = useState('')
const [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = useState('')
const [paymentFilter, setPaymentFilter] = useState('__all__')
const [fulfillmentFilter, setFulfillmentFilter] = useState('__all__')
// Pagination state
const [currentPage, setCurrentPage] = useState(1)
// Edit order dialog state
const [editOrder, setEditOrder] = useState<ShopifyOrder | null>(null)
const [editName, setEditName] = useState('')
const [editDate, setEditDate] = useState('')
const [editTotal, setEditTotal] = useState('')
const [editSubtotal, setEditSubtotal] = useState('')
const [editShipping, setEditShipping] = useState('')
const [editTaxes, setEditTaxes] = useState('')
const [editPaymentMethod, setEditPaymentMethod] = useState('')
const [editFulfillmentStatus, setEditFulfillmentStatus] = useState('')
const [isSavingEdit, setIsSavingEdit] = useState(false)
// Delete order dialog state
const [deleteOrderId, setDeleteOrderId] = useState<string | null>(null)
const [isDeleting, setIsDeleting] = useState(false)
// Manual entry form state
const [manualName, setManualName] = useState('')
const [manualDate, setManualDate] = useState(new Date().toISOString().slice(0, 10))
const [manualTotal, setManualTotal] = useState('')
const [manualSubtotal, setManualSubtotal] = useState('')
const [manualShipping, setManualShipping] = useState('')
const [manualTaxes, setManualTaxes] = useState('')
const [manualPaymentMethod, setManualPaymentMethod] = useState('')
const [manualFulfillmentStatus, setManualFulfillmentStatus] = useState('')
const [isSubmittingManual, setIsSubmittingManual] = useState(false)
// ---------------------------------------------------------------------------
// Distinct values for filter dropdowns
// ---------------------------------------------------------------------------
const paymentMethods = useMemo(() => {
const set = new Set<string>()
for (const o of orders) {
if (o.paymentMethod) set.add(o.paymentMethod)
}
return Array.from(set).sort()
}, [orders])
const fulfillmentStatuses = useMemo(() => {
const set = new Set<string>()
for (const o of orders) {
if (o.fulfillmentStatus) set.add(o.fulfillmentStatus)
}
return Array.from(set).sort()
}, [orders])
// ---------------------------------------------------------------------------
// Active filter count
// ---------------------------------------------------------------------------
const activeFilterCount = useMemo(() => {
let count = 0
if (searchQuery.trim()) count++
if (dateFrom) count++
if (dateTo) count++
if (paymentFilter !== '__all__') count++
if (fulfillmentFilter !== '__all__') count++
return count
}, [searchQuery, dateFrom, dateTo, paymentFilter, fulfillmentFilter])
// ---------------------------------------------------------------------------
// CSV import handler
// ---------------------------------------------------------------------------
const handleImport = async (rows: Record<string, string>[]) => {
const importId = crypto.randomUUID()
let count = 0
for (const row of rows) {
const parseNum = (v?: string) => {
if (!v) return 0
return Math.round(parseFloat(v.replace(/\s/g, '').replace(',', '.')) * 100) / 100 || 0
}
const orderId = crypto.randomUUID()
await save(`order:${orderId}`, {
name: row.name ?? '',
createdAt: row.createdAt ?? new Date().toISOString().slice(0, 10),
total: parseNum(row.total),
subtotal: parseNum(row.subtotal),
shipping: parseNum(row.shipping),
taxes: parseNum(row.taxes),
paymentMethod: row.paymentMethod ?? '',
fulfillmentStatus: row.fulfillmentStatus ?? '',
})
count++
}
await save(`import:${importId}`, {
date: new Date().toISOString().slice(0, 10),
rowCount: count,
})
await refresh()
}
// ---------------------------------------------------------------------------
// Manual order entry handler
// ---------------------------------------------------------------------------
const handleManualSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!manualName.trim()) return
const parseNum = (v: string) => Math.round(parseFloat(v.replace(/\s/g, '').replace(',', '.')) * 100) / 100 || 0
setIsSubmittingManual(true)
const orderId = crypto.randomUUID()
await save(`order:${orderId}`, {
name: manualName.trim(),
createdAt: manualDate || new Date().toISOString().slice(0, 10),
total: parseNum(manualTotal),
subtotal: parseNum(manualSubtotal),
shipping: parseNum(manualShipping),
taxes: parseNum(manualTaxes),
paymentMethod: manualPaymentMethod,
fulfillmentStatus: manualFulfillmentStatus,
})
setManualName('')
setManualDate(new Date().toISOString().slice(0, 10))
setManualTotal('')
setManualSubtotal('')
setManualShipping('')
setManualTaxes('')
setManualPaymentMethod('')
setManualFulfillmentStatus('')
await refresh()
setIsSubmittingManual(false)
}
// ---------------------------------------------------------------------------
// Edit order handlers
// ---------------------------------------------------------------------------
const openEditOrder = (order: ShopifyOrder) => {
setEditOrder(order)
setEditName(order.name)
setEditDate(order.createdAt)
setEditTotal(String(order.total))
setEditSubtotal(String(order.subtotal))
setEditShipping(String(order.shipping))
setEditTaxes(String(order.taxes))
setEditPaymentMethod(order.paymentMethod)
setEditFulfillmentStatus(order.fulfillmentStatus)
}
const handleSaveEdit = async () => {
if (!editOrder) return
const parseNum = (v: string) => Math.round(parseFloat(v.replace(/\s/g, '').replace(',', '.')) * 100) / 100 || 0
setIsSavingEdit(true)
await save(`order:${editOrder.id}`, {
name: editName,
createdAt: editDate || new Date().toISOString().slice(0, 10),
total: parseNum(editTotal),
subtotal: parseNum(editSubtotal),
shipping: parseNum(editShipping),
taxes: parseNum(editTaxes),
paymentMethod: editPaymentMethod,
fulfillmentStatus: editFulfillmentStatus,
})
await refresh()
setIsSavingEdit(false)
}
// ---------------------------------------------------------------------------
// Delete order handler
// ---------------------------------------------------------------------------
const handleConfirmDelete = async () => {
if (!deleteOrderId) return
setIsDeleting(true)
await remove(`order:${deleteOrderId}`)
await refresh()
setIsDeleting(false)
}
// ---------------------------------------------------------------------------
// Stats
// ---------------------------------------------------------------------------
const totalRevenue = orders.reduce((s, o) => s + o.total, 0)
const aov = orders.length > 0 ? Math.round(totalRevenue / orders.length) : 0
const totalTaxes = orders.reduce((s, o) => s + o.taxes, 0)
const totalSubtotal = orders.reduce((s, o) => s + o.subtotal, 0)
const avgVatRate = totalSubtotal > 0
? Math.round((totalTaxes / totalSubtotal) * 10000) / 100
: 0
// Monthly trend
const monthlyTrend = useMemo(() => {
const map = new Map<string, { revenue: number; count: number }>()
for (const o of orders) {
const month = o.createdAt.slice(0, 7)
const existing = map.get(month) ?? { revenue: 0, count: 0 }
existing.revenue += o.total
existing.count++
map.set(month, existing)
}
return Array.from(map.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([month, data]) => ({ month, value: data.revenue }))
}, [orders])
// Monthly VAT breakdown
const monthlyVat = useMemo(() => {
const map = new Map<string, { taxes: number; subtotal: number }>()
for (const o of orders) {
const month = o.createdAt.slice(0, 7)
const existing = map.get(month) ?? { taxes: 0, subtotal: 0 }
existing.taxes += o.taxes
existing.subtotal += o.subtotal
map.set(month, existing)
}
return Array.from(map.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([month, d]) => ({
month,
taxes: Math.round(d.taxes * 100) / 100,
subtotal: Math.round(d.subtotal * 100) / 100,
rate: d.subtotal > 0 ? Math.round((d.taxes / d.subtotal) * 10000) / 100 : 0,
}))
}, [orders])
// Payment method breakdown
const paymentBreakdown = useMemo(() => {
const map = new Map<string, { count: number; total: number }>()
for (const o of orders) {
const method = o.paymentMethod || 'Okant'
const existing = map.get(method) ?? { count: 0, total: 0 }
existing.count++
existing.total += o.total
map.set(method, existing)
}
return Array.from(map.entries())
.map(([method, data]) => ({ method, ...data }))
.sort((a, b) => b.total - a.total)
}, [orders])
// Fulfillment breakdown
const fulfillmentBreakdown = useMemo(() => {
const map = new Map<string, number>()
for (const o of orders) {
const status = o.fulfillmentStatus || 'Okant'
map.set(status, (map.get(status) ?? 0) + 1)
}
return Array.from(map.entries())
.map(([status, count]) => ({ status, count }))
.sort((a, b) => b.count - a.count)
}, [orders])
// ---------------------------------------------------------------------------
// Filtered & paginated orders
// ---------------------------------------------------------------------------
const filteredOrders = useMemo(() => {
let result = orders
if (searchQuery.trim()) {
const q = searchQuery.toLowerCase()
result = result.filter(o =>
o.name.toLowerCase().includes(q) ||
o.paymentMethod.toLowerCase().includes(q) ||
o.fulfillmentStatus.toLowerCase().includes(q)
)
}
if (dateFrom) {
result = result.filter(o => o.createdAt >= dateFrom)
}
if (dateTo) {
result = result.filter(o => o.createdAt <= dateTo)
}
if (paymentFilter !== '__all__') {
result = result.filter(o => o.paymentMethod === paymentFilter)
}
if (fulfillmentFilter !== '__all__') {
result = result.filter(o => o.fulfillmentStatus === fulfillmentFilter)
}
return result
}, [orders, searchQuery, dateFrom, dateTo, paymentFilter, fulfillmentFilter])
const totalPages = Math.max(1, Math.ceil(filteredOrders.length / PAGES_SIZE))
const safePage = Math.min(currentPage, totalPages)
const paginatedOrders = filteredOrders.slice(
(safePage - 1) * PAGES_SIZE,
safePage * PAGES_SIZE
)
// Reset page when filters change
const resetPage = () => setCurrentPage(1)
// ---------------------------------------------------------------------------
// Clear filters
// ---------------------------------------------------------------------------
const clearFilters = () => {
setSearchQuery('')
setDateFrom('')
setDateTo('')
setPaymentFilter('__all__')
setFulfillmentFilter('__all__')
setCurrentPage(1)
}
// ---------------------------------------------------------------------------
// Render
// ---------------------------------------------------------------------------
if (isLoading) return <ExtensionLoadingSkeleton />
export default function ShopifyImportWorkspace() {
return (
<EmptyExtensionState
title="Shopify-import"
description="Import av ordrar och transaktioner från Shopify kommer snart. Du kommer kunna synkronisera din Shopify-butik automatiskt."
icon={<ShoppingBag className="h-12 w-12 text-muted-foreground/40 mb-4" />}
/>
<div className="space-y-6">
{/* Edit order dialog */}
<EditEntryDialog
open={editOrder !== null}
onOpenChange={open => { if (!open) setEditOrder(null) }}
title="Redigera order"
description="Andra uppgifterna for denna order."
onSave={handleSaveEdit}
isSaving={isSavingEdit}
>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Order</Label>
<Input value={editName} onChange={e => setEditName(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Datum</Label>
<Input type="date" value={editDate} onChange={e => setEditDate(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Total</Label>
<Input type="number" step="0.01" min="0" value={editTotal} onChange={e => setEditTotal(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Subtotal</Label>
<Input type="number" step="0.01" min="0" value={editSubtotal} onChange={e => setEditSubtotal(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Frakt</Label>
<Input type="number" step="0.01" min="0" value={editShipping} onChange={e => setEditShipping(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Moms</Label>
<Input type="number" step="0.01" min="0" value={editTaxes} onChange={e => setEditTaxes(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Betalmetod</Label>
<Input value={editPaymentMethod} onChange={e => setEditPaymentMethod(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Leveransstatus</Label>
<Input value={editFulfillmentStatus} onChange={e => setEditFulfillmentStatus(e.target.value)} />
</div>
</div>
</EditEntryDialog>
{/* Delete order dialog */}
<ConfirmDeleteDialog
open={deleteOrderId !== null}
onOpenChange={open => { if (!open) setDeleteOrderId(null) }}
title="Ta bort order"
description="Ar du saker pa att du vill ta bort denna order? Atgarden kan inte angras."
onConfirm={handleConfirmDelete}
isDeleting={isDeleting}
/>
<Tabs defaultValue="import">
<TabsList>
<TabsTrigger value="import">Import</TabsTrigger>
<TabsTrigger value="orders">Ordrar</TabsTrigger>
<TabsTrigger value="stats">Statistik</TabsTrigger>
</TabsList>
{/* ------------------------------------------------------------------ */}
{/* Import tab */}
{/* ------------------------------------------------------------------ */}
<TabsContent value="import" className="space-y-6 mt-4">
<CsvImportWizard
targetFields={TARGET_FIELDS}
defaultMappings={DEFAULT_MAPPINGS}
onImport={handleImport}
/>
{/* Manual order entry */}
<DataEntryForm
title="Lagg till order manuellt"
onSubmit={handleManualSubmit}
submitLabel="Lagg till"
isSubmitting={isSubmittingManual}
>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="manual-name">Order *</Label>
<Input
id="manual-name"
value={manualName}
onChange={e => setManualName(e.target.value)}
placeholder="t.ex. #1001"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="manual-date">Datum *</Label>
<Input
id="manual-date"
type="date"
value={manualDate}
onChange={e => setManualDate(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="manual-total">Total</Label>
<Input
id="manual-total"
type="number"
step="0.01"
min="0"
placeholder="0"
value={manualTotal}
onChange={e => setManualTotal(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="manual-subtotal">Subtotal</Label>
<Input
id="manual-subtotal"
type="number"
step="0.01"
min="0"
placeholder="0"
value={manualSubtotal}
onChange={e => setManualSubtotal(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="manual-shipping">Frakt</Label>
<Input
id="manual-shipping"
type="number"
step="0.01"
min="0"
placeholder="0"
value={manualShipping}
onChange={e => setManualShipping(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="manual-taxes">Moms</Label>
<Input
id="manual-taxes"
type="number"
step="0.01"
min="0"
placeholder="0"
value={manualTaxes}
onChange={e => setManualTaxes(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="manual-payment">Betalmetod</Label>
<Input
id="manual-payment"
value={manualPaymentMethod}
onChange={e => setManualPaymentMethod(e.target.value)}
placeholder="t.ex. Stripe"
/>
</div>
<div className="space-y-2">
<Label htmlFor="manual-fulfillment">Leveransstatus</Label>
<Input
id="manual-fulfillment"
value={manualFulfillmentStatus}
onChange={e => setManualFulfillmentStatus(e.target.value)}
placeholder="t.ex. fulfilled"
/>
</div>
</div>
</DataEntryForm>
{imports.length > 0 && (
<div>
<h3 className="text-sm font-semibold mb-3">Importhistorik</h3>
<div className="rounded-xl border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Datum</TableHead>
<TableHead className="text-right">Ordrar</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{imports.map(imp => (
<TableRow key={imp.id}>
<TableCell>{imp.date}</TableCell>
<TableCell className="text-right tabular-nums">{imp.rowCount}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)}
</TabsContent>
{/* ------------------------------------------------------------------ */}
{/* Orders tab */}
{/* ------------------------------------------------------------------ */}
<TabsContent value="orders" className="space-y-6 mt-4">
{/* Filters */}
<div className="space-y-3">
<div className="flex flex-wrap items-end gap-3">
<div className="space-y-1">
<Label className="text-xs">Sok</Label>
<Input
placeholder="Sok ordrar..."
value={searchQuery}
onChange={e => { setSearchQuery(e.target.value); resetPage() }}
className="w-48"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Fran datum</Label>
<Input
type="date"
value={dateFrom}
onChange={e => { setDateFrom(e.target.value); resetPage() }}
className="w-40"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Till datum</Label>
<Input
type="date"
value={dateTo}
onChange={e => { setDateTo(e.target.value); resetPage() }}
className="w-40"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Betalmetod</Label>
<Select value={paymentFilter} onValueChange={v => { setPaymentFilter(v); resetPage() }}>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">Alla</SelectItem>
{paymentMethods.map(m => (
<SelectItem key={m} value={m}>{m}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs">Leveransstatus</Label>
<Select value={fulfillmentFilter} onValueChange={v => { setFulfillmentFilter(v); resetPage() }}>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">Alla</SelectItem>
{fulfillmentStatuses.map(s => (
<SelectItem key={s} value={s}>{s}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{activeFilterCount > 0 && (
<div className="flex items-center gap-2">
<Badge variant="secondary">{activeFilterCount} aktiva filter</Badge>
<Button variant="ghost" size="sm" className="text-xs" onClick={clearFilters}>
Rensa filter
</Button>
</div>
)}
</div>
{filteredOrders.length === 0 ? (
<p className="text-sm text-muted-foreground">Inga ordrar hittades.</p>
) : (
<>
<div className="rounded-xl border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Order</TableHead>
<TableHead>Datum</TableHead>
<TableHead className="text-right">Total</TableHead>
<TableHead className="text-right">Frakt</TableHead>
<TableHead className="text-right">Moms</TableHead>
<TableHead>Betalning</TableHead>
<TableHead>Status</TableHead>
<TableHead className="w-20"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{paginatedOrders.map(o => (
<TableRow key={o.id}>
<TableCell className="font-medium">{o.name}</TableCell>
<TableCell>{o.createdAt}</TableCell>
<TableCell className="text-right tabular-nums">{o.total.toLocaleString('sv-SE')} kr</TableCell>
<TableCell className="text-right tabular-nums">{o.shipping.toLocaleString('sv-SE')} kr</TableCell>
<TableCell className="text-right tabular-nums">{o.taxes.toLocaleString('sv-SE')} kr</TableCell>
<TableCell>{o.paymentMethod}</TableCell>
<TableCell>
<Badge variant={o.fulfillmentStatus === 'fulfilled' ? 'default' : 'secondary'}>
{o.fulfillmentStatus || 'Okant'}
</Badge>
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" onClick={() => openEditOrder(o)}>
<Pencil className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
<Button variant="ghost" size="sm" onClick={() => setDeleteOrderId(o.id)}>
<Trash2 className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{/* Pagination */}
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
{filteredOrders.length} ordrar totalt
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={safePage <= 1}
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
>
<ChevronLeft className="h-4 w-4 mr-1" />
Foregaende
</Button>
<span className="text-sm tabular-nums">
Sida {safePage} av {totalPages}
</span>
<Button
variant="outline"
size="sm"
disabled={safePage >= totalPages}
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
>
Nasta
<ChevronRight className="h-4 w-4 ml-1" />
</Button>
</div>
</div>
</>
)}
</TabsContent>
{/* ------------------------------------------------------------------ */}
{/* Stats tab */}
{/* ------------------------------------------------------------------ */}
<TabsContent value="stats" className="space-y-6 mt-4">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<KPICard label="Antal ordrar" value={orders.length} />
<KPICard label="Total intakt" value={totalRevenue.toLocaleString('sv-SE')} suffix="kr" />
<KPICard label="AOV" value={aov.toLocaleString('sv-SE')} suffix="kr" />
</div>
{/* VAT analytics */}
<div>
<h3 className="text-sm font-semibold mb-3">Momsanalys</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-4">
<KPICard
label="Total moms"
value={(Math.round(totalTaxes * 100) / 100).toLocaleString('sv-SE')}
suffix="kr"
/>
<KPICard
label="Genomsnittlig momssats"
value={avgVatRate}
suffix="%"
/>
</div>
{monthlyVat.length > 0 && (
<div className="rounded-xl border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Manad</TableHead>
<TableHead className="text-right">Subtotal</TableHead>
<TableHead className="text-right">Moms</TableHead>
<TableHead className="text-right">Momssats</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{monthlyVat.map(m => (
<TableRow key={m.month}>
<TableCell className="font-medium">{m.month}</TableCell>
<TableCell className="text-right tabular-nums">{m.subtotal.toLocaleString('sv-SE')} kr</TableCell>
<TableCell className="text-right tabular-nums">{m.taxes.toLocaleString('sv-SE')} kr</TableCell>
<TableCell className="text-right tabular-nums">{m.rate}%</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
{monthlyTrend.length > 0 && (
<div>
<h3 className="text-sm font-semibold mb-3">Intakt per manad</h3>
<MonthlyTrendTable rows={monthlyTrend} valueLabel="Intakt" />
</div>
)}
{paymentBreakdown.length > 0 && (
<div>
<h3 className="text-sm font-semibold mb-3">Per betalmetod</h3>
<div className="rounded-xl border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Betalmetod</TableHead>
<TableHead className="text-right">Ordrar</TableHead>
<TableHead className="text-right">Total</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{paymentBreakdown.map(p => (
<TableRow key={p.method}>
<TableCell className="font-medium">{p.method}</TableCell>
<TableCell className="text-right tabular-nums">{p.count}</TableCell>
<TableCell className="text-right tabular-nums">{p.total.toLocaleString('sv-SE')} kr</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)}
{fulfillmentBreakdown.length > 0 && (
<div>
<h3 className="text-sm font-semibold mb-3">Per leveransstatus</h3>
<div className="rounded-xl border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Status</TableHead>
<TableHead className="text-right">Ordrar</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{fulfillmentBreakdown.map(f => (
<TableRow key={f.status}>
<TableCell className="font-medium">{f.status}</TableCell>
<TableCell className="text-right tabular-nums">{f.count}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)}
</TabsContent>
</Tabs>
</div>
)
}
@@ -1,14 +1,578 @@
'use client'
import { DoorOpen } from 'lucide-react'
import EmptyExtensionState from '@/components/extensions/shared/EmptyExtensionState'
import { useState, useMemo } from 'react'
import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
import { useExtensionData } from '@/lib/extensions/use-extension-data'
import KPICard from '@/components/extensions/shared/KPICard'
import DateRangeFilter from '@/components/extensions/shared/DateRangeFilter'
import DataEntryForm from '@/components/extensions/shared/DataEntryForm'
import SetupPrompt from '@/components/extensions/shared/SetupPrompt'
import ExtensionLoadingSkeleton from '@/components/extensions/shared/ExtensionLoadingSkeleton'
import ConfirmDeleteDialog from '@/components/extensions/shared/ConfirmDeleteDialog'
import EditEntryDialog from '@/components/extensions/shared/EditEntryDialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select'
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from '@/components/ui/table'
import { cn } from '@/lib/utils'
import { Pencil, Trash2, ArrowUp, ArrowDown, Minus, Settings } from 'lucide-react'
export default function OccupancyWorkspace() {
const OOO_REASONS = ['Underhall', 'Renovering', 'Blockerat', 'Ovrigt'] as const
type OooReason = typeof OOO_REASONS[number]
function getOccupancyColor(pct: number): string {
if (pct >= 80) return 'bg-green-500'
if (pct >= 50) return 'bg-yellow-500'
if (pct > 0) return 'bg-red-500'
return 'bg-muted'
}
function computePreviousPeriod(start: string, end: string): { start: string; end: string } {
const startDate = new Date(start + 'T00:00:00')
const endDate = new Date(end + 'T00:00:00')
const durationMs = endDate.getTime() - startDate.getTime()
const prevEnd = new Date(startDate.getTime() - 1)
const prevStart = new Date(prevEnd.getTime() - durationMs)
return {
start: prevStart.toISOString().slice(0, 10),
end: prevEnd.toISOString().slice(0, 10),
}
}
function DeltaArrow({ current, previous }: { current: number; previous: number }) {
const delta = Math.round((current - previous) * 100) / 100
if (delta === 0 || (previous === 0 && current === 0)) {
return (
<span className="inline-flex items-center gap-0.5 text-xs text-muted-foreground">
<Minus className="h-3 w-3" />
<span>0 pp</span>
</span>
)
}
// For occupancy: higher is better, so positive delta = green (improving)
const improving = delta > 0
return (
<EmptyExtensionState
title="Beläggningsgrad"
description="Uppföljning av rumsbeläggning kommer snart. Du kommer kunna registrera beläggning och se trender över tid."
icon={<DoorOpen className="h-12 w-12 text-muted-foreground/40 mb-4" />}
/>
<span className={cn(
'inline-flex items-center gap-0.5 text-xs',
improving ? 'text-green-600' : 'text-red-600'
)}>
{delta > 0
? <ArrowUp className="h-3 w-3" />
: <ArrowDown className="h-3 w-3" />
}
<span>{delta > 0 ? '+' : ''}{delta} pp</span>
</span>
)
}
interface DailyEntry {
date: string
roomsOccupied: number
roomsOutOfOrder: number
reason?: OooReason
}
export default function OccupancyWorkspace({}: WorkspaceComponentProps) {
const now = new Date()
const [dateRange, setDateRange] = useState({
start: new Date(now.getFullYear(), now.getMonth(), 1).toISOString().slice(0, 10),
end: new Date(now.getFullYear(), now.getMonth() + 1, 0).toISOString().slice(0, 10),
})
const prevPeriod = useMemo(
() => computePreviousPeriod(dateRange.start, dateRange.end),
[dateRange.start, dateRange.end]
)
const { data, save, remove, refresh, isLoading } = useExtensionData('hotel', 'occupancy')
const settings = data.find(d => d.key === 'settings')?.value as { totalRooms?: number } | undefined
const totalRooms = settings?.totalRooms ?? 0
const allDailyEntries = useMemo(() =>
data.filter(d => d.key.startsWith('daily:'))
.map(d => ({
date: d.key.replace('daily:', ''),
...(d.value as { roomsOccupied: number; roomsOutOfOrder: number; reason?: OooReason }),
}))
, [data])
const entries = useMemo(() =>
allDailyEntries
.filter(e => e.date >= dateRange.start && e.date <= dateRange.end)
.sort((a, b) => b.date.localeCompare(a.date))
, [allDailyEntries, dateRange])
const prevEntries = useMemo(() =>
allDailyEntries
.filter(e => e.date >= prevPeriod.start && e.date <= prevPeriod.end)
, [allDailyEntries, prevPeriod])
// Form state
const [entryDate, setEntryDate] = useState(now.toISOString().slice(0, 10))
const [roomsOccupied, setRoomsOccupied] = useState('')
const [roomsOutOfOrder, setRoomsOutOfOrder] = useState('')
const [oooReason, setOooReason] = useState<OooReason>('Underhall')
const [isSubmitting, setIsSubmitting] = useState(false)
// Edit dialog state
const [editDialogOpen, setEditDialogOpen] = useState(false)
const [editDate, setEditDate] = useState('')
const [editOccupied, setEditOccupied] = useState('')
const [editOoo, setEditOoo] = useState('')
const [editReason, setEditReason] = useState<OooReason>('Underhall')
const [isSavingEdit, setIsSavingEdit] = useState(false)
// Delete dialog state
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [deleteDate, setDeleteDate] = useState('')
const [isDeleting, setIsDeleting] = useState(false)
// Settings dialog state
const [settingsDialogOpen, setSettingsDialogOpen] = useState(false)
const [newTotalRooms, setNewTotalRooms] = useState('')
const [isSavingSettings, setIsSavingSettings] = useState(false)
// --- Validation ---
const formOccupied = parseInt(roomsOccupied) || 0
const formOoo = parseInt(roomsOutOfOrder) || 0
const formExceedsTotal = totalRooms > 0 && (formOccupied + formOoo) > totalRooms
const formIsValid = roomsOccupied !== '' && !isNaN(parseInt(roomsOccupied)) && !formExceedsTotal
const editOccupiedNum = parseInt(editOccupied) || 0
const editOooNum = parseInt(editOoo) || 0
const editExceedsTotal = totalRooms > 0 && (editOccupiedNum + editOooNum) > totalRooms
const editIsValid = editOccupied !== '' && !isNaN(parseInt(editOccupied)) && !editExceedsTotal
// --- Current period KPIs ---
const totalOccupied = entries.reduce((s, e) => s + e.roomsOccupied, 0)
const totalOutOfOrder = entries.reduce((s, e) => s + e.roomsOutOfOrder, 0)
const daysInRange = entries.length
const totalAvailable = totalRooms * daysInRange
const occupancyPct = totalAvailable > 0
? Math.round((totalOccupied / totalAvailable) * 10000) / 100
: 0
const avgOccupied = daysInRange > 0 ? Math.round(totalOccupied / daysInRange) : 0
const avgOutOfOrder = daysInRange > 0 ? Math.round((totalOutOfOrder / daysInRange) * 10) / 10 : 0
const avgAvailable = daysInRange > 0
? Math.round(((totalRooms * daysInRange - totalOccupied - totalOutOfOrder) / daysInRange) * 10) / 10
: totalRooms
// --- Previous period KPIs ---
const prevTotalOccupied = prevEntries.reduce((s, e) => s + e.roomsOccupied, 0)
const prevDaysInRange = prevEntries.length
const prevTotalAvailable = totalRooms * prevDaysInRange
const prevOccupancyPct = prevTotalAvailable > 0
? Math.round((prevTotalOccupied / prevTotalAvailable) * 10000) / 100
: 0
// Calendar heatmap for current month view
const calendarData = useMemo(() => {
const entryMap = new Map(entries.map(e => [e.date, e]))
const start = new Date(dateRange.start)
const end = new Date(dateRange.end)
const days: { date: string; occupancyPct: number; dayOfWeek: number }[] = []
const current = new Date(start)
while (current <= end) {
const dateStr = current.toISOString().slice(0, 10)
const entry = entryMap.get(dateStr)
const pct = entry && totalRooms > 0
? Math.round((entry.roomsOccupied / totalRooms) * 100)
: 0
days.push({ date: dateStr, occupancyPct: pct, dayOfWeek: current.getDay() })
current.setDate(current.getDate() + 1)
}
return days
}, [entries, dateRange, totalRooms])
// --- Handlers ---
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
const occupied = parseInt(roomsOccupied)
const outOfOrder = parseInt(roomsOutOfOrder) || 0
if (isNaN(occupied)) return
if (totalRooms > 0 && (occupied + outOfOrder) > totalRooms) return
setIsSubmitting(true)
await save(`daily:${entryDate}`, {
roomsOccupied: occupied,
roomsOutOfOrder: outOfOrder,
reason: outOfOrder > 0 ? oooReason : undefined,
})
setRoomsOccupied('')
setRoomsOutOfOrder('')
setOooReason('Underhall')
await refresh()
setIsSubmitting(false)
}
const openEditDialog = (entry: DailyEntry) => {
setEditDate(entry.date)
setEditOccupied(String(entry.roomsOccupied))
setEditOoo(String(entry.roomsOutOfOrder))
setEditReason(entry.reason ?? 'Underhall')
setEditDialogOpen(true)
}
const handleSaveEdit = async () => {
const occupied = parseInt(editOccupied)
const outOfOrder = parseInt(editOoo) || 0
if (isNaN(occupied)) return
if (totalRooms > 0 && (occupied + outOfOrder) > totalRooms) return
setIsSavingEdit(true)
await save(`daily:${editDate}`, {
roomsOccupied: occupied,
roomsOutOfOrder: outOfOrder,
reason: outOfOrder > 0 ? editReason : undefined,
})
await refresh()
setIsSavingEdit(false)
}
const openDeleteDialog = (date: string) => {
setDeleteDate(date)
setDeleteDialogOpen(true)
}
const handleDelete = async () => {
setIsDeleting(true)
await remove(`daily:${deleteDate}`)
setIsDeleting(false)
}
const handleSetup = async (values: Record<string, string>) => {
await save('settings', { totalRooms: parseInt(values.totalRooms) || 0 })
}
const openSettingsDialog = () => {
setNewTotalRooms(String(totalRooms))
setSettingsDialogOpen(true)
}
const handleSaveSettings = async () => {
const rooms = parseInt(newTotalRooms)
if (isNaN(rooms) || rooms <= 0) return
setIsSavingSettings(true)
await save('settings', { totalRooms: rooms })
await refresh()
setIsSavingSettings(false)
}
if (isLoading) return <ExtensionLoadingSkeleton />
if (!totalRooms) {
return (
<SetupPrompt
title="Konfigurera belaggning"
description="Ange antal rum pa hotellet for att borja spara belaggning."
fields={[{ key: 'totalRooms', label: 'Antal rum', type: 'number', placeholder: 'T.ex. 50' }]}
onSave={handleSetup}
/>
)
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<DateRangeFilter onRangeChange={(start, end) => setDateRange({ start, end })} />
<Button variant="outline" size="sm" onClick={openSettingsDialog}>
<Settings className="h-4 w-4 mr-1.5" />
{totalRooms} rum
</Button>
</div>
{/* KPI Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<KPICard label="Belaggning" value={occupancyPct} suffix="%" />
<KPICard label="Snitt belagda rum" value={avgOccupied} suffix={`/ ${totalRooms}`} />
<KPICard label="Snitt ur drift" value={avgOutOfOrder} suffix="rum" />
<KPICard label="Snitt lediga rum" value={avgAvailable} suffix="rum" />
</div>
{/* Period comparison */}
<div className="rounded-xl border p-4">
<h3 className="text-sm font-semibold mb-3">Periodjamforelse</h3>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="space-y-1">
<p className="text-xs text-muted-foreground">Belaggning (nuvarande)</p>
<div className="flex items-center gap-2">
<span className="text-lg font-semibold tabular-nums">{occupancyPct}%</span>
<DeltaArrow current={occupancyPct} previous={prevOccupancyPct} />
</div>
</div>
<div className="space-y-1">
<p className="text-xs text-muted-foreground">Belaggning (foregaende)</p>
<span className="text-lg font-semibold tabular-nums">{prevOccupancyPct}%</span>
</div>
<div className="space-y-1">
<p className="text-xs text-muted-foreground">Foregaende period</p>
<span className="text-sm tabular-nums">{prevPeriod.start} {prevPeriod.end}</span>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mt-3 pt-3 border-t">
<div className="space-y-1">
<p className="text-xs text-muted-foreground">Belagda rum (foregaende)</p>
<span className="text-sm tabular-nums">
{prevDaysInRange > 0
? Math.round(prevTotalOccupied / prevDaysInRange)
: 0} snitt / dag
</span>
</div>
<div className="space-y-1">
<p className="text-xs text-muted-foreground">Dagar med data (foregaende)</p>
<span className="text-sm tabular-nums">{prevDaysInRange} dagar</span>
</div>
</div>
</div>
{/* Entry form */}
<DataEntryForm
title="Registrera daglig belaggning"
onSubmit={handleSubmit}
submitLabel="Registrera"
isSubmitting={isSubmitting || !formIsValid}
>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="space-y-2">
<Label htmlFor="occ-date">Datum</Label>
<Input id="occ-date" type="date" value={entryDate} onChange={e => setEntryDate(e.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="occ-occupied">Belagda rum</Label>
<Input
id="occ-occupied"
type="number"
min="0"
max={totalRooms}
placeholder="0"
value={roomsOccupied}
onChange={e => setRoomsOccupied(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="occ-ooo">Ur drift</Label>
<Input
id="occ-ooo"
type="number"
min="0"
placeholder="0"
value={roomsOutOfOrder}
onChange={e => setRoomsOutOfOrder(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="occ-reason">Orsak (ur drift)</Label>
<Select value={oooReason} onValueChange={(val) => setOooReason(val as OooReason)}>
<SelectTrigger id="occ-reason">
<SelectValue placeholder="Valj orsak" />
</SelectTrigger>
<SelectContent>
{OOO_REASONS.map(r => (
<SelectItem key={r} value={r}>{r}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{formExceedsTotal && (
<p className="text-sm text-red-600">
Belagda rum ({formOccupied}) + ur drift ({formOoo}) = {formOccupied + formOoo} overstiger totalt antal rum ({totalRooms}).
</p>
)}
</DataEntryForm>
{/* Calendar heatmap */}
{calendarData.length > 0 && (
<div>
<h3 className="text-sm font-semibold mb-3">Belaggningskalender</h3>
<div className="rounded-xl border p-4">
<div className="grid grid-cols-7 gap-1 text-xs text-muted-foreground mb-2">
{['Man', 'Tis', 'Ons', 'Tor', 'Fre', 'Lor', 'Son'].map(d => (
<div key={d} className="text-center">{d}</div>
))}
</div>
<div className="grid grid-cols-7 gap-1">
{/* Offset for first day of month */}
{calendarData.length > 0 && Array.from({ length: (calendarData[0].dayOfWeek + 6) % 7 }).map((_, i) => (
<div key={`empty-${i}`} className="aspect-square" />
))}
{calendarData.map(day => (
<div
key={day.date}
className={cn(
'aspect-square rounded-sm flex items-center justify-center text-xs',
getOccupancyColor(day.occupancyPct),
day.occupancyPct > 0 ? 'text-white' : 'text-muted-foreground'
)}
title={`${day.date}: ${day.occupancyPct}%`}
>
{parseInt(day.date.slice(-2))}
</div>
))}
</div>
<div className="flex items-center gap-4 mt-3 text-xs text-muted-foreground">
<div className="flex items-center gap-1">
<div className="w-3 h-3 rounded-sm bg-green-500" /> 80%+
</div>
<div className="flex items-center gap-1">
<div className="w-3 h-3 rounded-sm bg-yellow-500" /> 50-79%
</div>
<div className="flex items-center gap-1">
<div className="w-3 h-3 rounded-sm bg-red-500" /> 1-49%
</div>
<div className="flex items-center gap-1">
<div className="w-3 h-3 rounded-sm bg-muted" /> Ingen data
</div>
</div>
</div>
</div>
)}
{/* Daily data table */}
<div>
<h3 className="text-sm font-semibold mb-3">Daglig data</h3>
{entries.length === 0 ? (
<p className="text-sm text-muted-foreground">Ingen data registrerad i vald period.</p>
) : (
<div className="rounded-xl border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Datum</TableHead>
<TableHead className="text-right">Belagda</TableHead>
<TableHead className="text-right">Ur drift</TableHead>
<TableHead>Orsak</TableHead>
<TableHead className="text-right">Lediga</TableHead>
<TableHead className="text-right">Belaggning</TableHead>
<TableHead className="w-20"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{entries.map(e => {
const pct = totalRooms > 0 ? Math.round((e.roomsOccupied / totalRooms) * 100) : 0
const available = totalRooms - e.roomsOccupied - e.roomsOutOfOrder
return (
<TableRow key={e.date}>
<TableCell className="font-medium">{e.date}</TableCell>
<TableCell className="text-right tabular-nums">{e.roomsOccupied} / {totalRooms}</TableCell>
<TableCell className="text-right tabular-nums">{e.roomsOutOfOrder}</TableCell>
<TableCell className="text-muted-foreground text-sm">
{e.roomsOutOfOrder > 0 ? (e.reason ?? '-') : '-'}
</TableCell>
<TableCell className="text-right tabular-nums">{available}</TableCell>
<TableCell className="text-right tabular-nums">{pct}%</TableCell>
<TableCell>
<div className="flex items-center gap-0.5">
<Button variant="ghost" size="sm" onClick={() => openEditDialog(e)}>
<Pencil className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
<Button variant="ghost" size="sm" onClick={() => openDeleteDialog(e.date)}>
<Trash2 className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
</div>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
)}
</div>
{/* Edit entry dialog */}
<EditEntryDialog
open={editDialogOpen}
onOpenChange={setEditDialogOpen}
title="Redigera belaggning"
description={`Andrar data for ${editDate}`}
onSave={handleSaveEdit}
isSaving={isSavingEdit}
>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="edit-date">Datum</Label>
<Input id="edit-date" type="date" value={editDate} disabled />
</div>
<div className="space-y-2">
<Label htmlFor="edit-occupied">Belagda rum</Label>
<Input
id="edit-occupied"
type="number"
min="0"
max={totalRooms}
value={editOccupied}
onChange={e => setEditOccupied(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-ooo">Ur drift</Label>
<Input
id="edit-ooo"
type="number"
min="0"
value={editOoo}
onChange={e => setEditOoo(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-reason">Orsak (ur drift)</Label>
<Select value={editReason} onValueChange={(val) => setEditReason(val as OooReason)}>
<SelectTrigger id="edit-reason">
<SelectValue placeholder="Valj orsak" />
</SelectTrigger>
<SelectContent>
{OOO_REASONS.map(r => (
<SelectItem key={r} value={r}>{r}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{editExceedsTotal && (
<p className="text-sm text-red-600">
Belagda rum ({editOccupiedNum}) + ur drift ({editOooNum}) = {editOccupiedNum + editOooNum} overstiger totalt antal rum ({totalRooms}).
</p>
)}
</div>
</EditEntryDialog>
{/* Confirm delete dialog */}
<ConfirmDeleteDialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
title="Ta bort belaggningsdata"
description={`Vill du ta bort belaggningsdata for ${deleteDate}? Atgarden kan inte angras.`}
onConfirm={handleDelete}
isDeleting={isDeleting}
/>
{/* Settings dialog */}
<EditEntryDialog
open={settingsDialogOpen}
onOpenChange={setSettingsDialogOpen}
title="Andra antal rum"
description={`Nuvarande antal rum: ${totalRooms}`}
onSave={handleSaveSettings}
isSaving={isSavingSettings}
>
<div className="space-y-2">
<Label htmlFor="settings-rooms">Totalt antal rum</Label>
<Input
id="settings-rooms"
type="number"
min="1"
value={newTotalRooms}
onChange={e => setNewTotalRooms(e.target.value)}
/>
</div>
</EditEntryDialog>
</div>
)
}
+582 -8
View File
@@ -1,14 +1,588 @@
'use client'
import { BedDouble } from 'lucide-react'
import EmptyExtensionState from '@/components/extensions/shared/EmptyExtensionState'
import { useState, useMemo } from 'react'
import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
import { useExtensionData } from '@/lib/extensions/use-extension-data'
import KPICard from '@/components/extensions/shared/KPICard'
import DateRangeFilter from '@/components/extensions/shared/DateRangeFilter'
import DataEntryForm from '@/components/extensions/shared/DataEntryForm'
import SetupPrompt from '@/components/extensions/shared/SetupPrompt'
import ExtensionLoadingSkeleton from '@/components/extensions/shared/ExtensionLoadingSkeleton'
import ConfirmDeleteDialog from '@/components/extensions/shared/ConfirmDeleteDialog'
import EditEntryDialog from '@/components/extensions/shared/EditEntryDialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from '@/components/ui/table'
import { Pencil, Trash2, ArrowUp, ArrowDown, Minus, Settings } from 'lucide-react'
import { validateMaxNumber, validatePositiveNumber } from '@/lib/extensions/validation'
import { cn } from '@/lib/utils'
export default function RevparWorkspace() {
function computePreviousPeriod(start: string, end: string): { start: string; end: string } {
const startDate = new Date(start + 'T00:00:00')
const endDate = new Date(end + 'T00:00:00')
const durationMs = endDate.getTime() - startDate.getTime()
const prevEnd = new Date(startDate.getTime() - 1)
const prevStart = new Date(prevEnd.getTime() - durationMs)
return {
start: prevStart.toISOString().slice(0, 10),
end: prevEnd.toISOString().slice(0, 10),
}
}
function DeltaArrow({ current, previous, higherIsBetter = true }: {
current: number
previous: number
higherIsBetter?: boolean
}) {
const delta = Math.round((current - previous) * 100) / 100
if (delta === 0 || (previous === 0 && current === 0)) {
return (
<span className="inline-flex items-center gap-0.5 text-xs text-muted-foreground">
<Minus className="h-3 w-3" />
<span>0</span>
</span>
)
}
const improving = higherIsBetter ? delta > 0 : delta < 0
return (
<EmptyExtensionState
title="RevPAR-beräkning"
description="Beräkning av intäkt per tillgängligt rum (RevPAR) kommer snart. Du kommer kunna följa upp RevPAR per dag, vecka och månad."
icon={<BedDouble className="h-12 w-12 text-muted-foreground/40 mb-4" />}
/>
<span className={cn(
'inline-flex items-center gap-0.5 text-xs',
improving ? 'text-green-600' : 'text-red-600'
)}>
{delta > 0
? <ArrowUp className="h-3 w-3" />
: <ArrowDown className="h-3 w-3" />
}
<span>{delta > 0 ? '+' : ''}{delta.toLocaleString('sv-SE')}</span>
</span>
)
}
interface DailyEntry {
date: string
roomsSold: number
roomRevenue: number
}
function computeKPIs(entries: DailyEntry[], totalRooms: number) {
const totalRevenue = entries.reduce((s, e) => s + e.roomRevenue, 0)
const totalRoomsSold = entries.reduce((s, e) => s + e.roomsSold, 0)
const daysInRange = entries.length
const totalAvailableRooms = totalRooms * daysInRange
const revpar = totalAvailableRooms > 0
? Math.round((totalRevenue / totalAvailableRooms) * 100) / 100
: 0
const adr = totalRoomsSold > 0
? Math.round((totalRevenue / totalRoomsSold) * 100) / 100
: 0
const occupancyPct = totalAvailableRooms > 0
? Math.round((totalRoomsSold / totalAvailableRooms) * 10000) / 100
: 0
return { totalRevenue, totalRoomsSold, revpar, adr, occupancyPct }
}
export default function RevparWorkspace({}: WorkspaceComponentProps) {
const now = new Date()
const [dateRange, setDateRange] = useState({
start: new Date(now.getFullYear(), now.getMonth(), 1).toISOString().slice(0, 10),
end: new Date(now.getFullYear(), now.getMonth() + 1, 0).toISOString().slice(0, 10),
})
const prevPeriod = useMemo(
() => computePreviousPeriod(dateRange.start, dateRange.end),
[dateRange.start, dateRange.end]
)
const { data, save, remove, refresh, isLoading } = useExtensionData('hotel', 'revpar')
const settings = data.find(d => d.key === 'settings')?.value as { totalRooms?: number } | undefined
const totalRooms = settings?.totalRooms ?? 0
// All daily entries (unfiltered by date, for period comparison)
const allEntries = useMemo(() =>
data.filter(d => d.key.startsWith('daily:'))
.map(d => ({
date: d.key.replace('daily:', ''),
...(d.value as { roomsSold: number; roomRevenue: number }),
}))
, [data])
// Current period entries
const entries = useMemo(() =>
allEntries
.filter(e => e.date >= dateRange.start && e.date <= dateRange.end)
.sort((a, b) => b.date.localeCompare(a.date))
, [allEntries, dateRange])
// Previous period entries
const prevEntries = useMemo(() =>
allEntries
.filter(e => e.date >= prevPeriod.start && e.date <= prevPeriod.end)
, [allEntries, prevPeriod])
// Form state
const [entryDate, setEntryDate] = useState(now.toISOString().slice(0, 10))
const [roomsSold, setRoomsSold] = useState('')
const [roomRevenue, setRoomRevenue] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
// Edit dialog state
const [editDialogOpen, setEditDialogOpen] = useState(false)
const [editDate, setEditDate] = useState('')
const [editRoomsSold, setEditRoomsSold] = useState('')
const [editRoomRevenue, setEditRoomRevenue] = useState('')
const [isSavingEdit, setIsSavingEdit] = useState(false)
// Delete dialog state
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [deleteDate, setDeleteDate] = useState('')
const [isDeleting, setIsDeleting] = useState(false)
// Settings dialog state
const [settingsDialogOpen, setSettingsDialogOpen] = useState(false)
const [settingsRoomCount, setSettingsRoomCount] = useState('')
const [isSavingSettings, setIsSavingSettings] = useState(false)
// Current period KPIs
const current = computeKPIs(entries, totalRooms)
// Previous period KPIs
const prev = computeKPIs(prevEntries, totalRooms)
// --- Input validation ---
const roomsSoldNum = parseInt(roomsSold)
const roomRevenueNum = parseFloat(roomRevenue)
const roomsSoldError = roomsSold !== ''
? validateMaxNumber(roomsSold, totalRooms)
? `Kan inte overskrida ${totalRooms} rum`
: null
: null
const roomRevenueError = roomRevenue !== ''
? validatePositiveNumber(roomRevenue)
: null
const formValid = !isNaN(roomsSoldNum)
&& roomsSoldNum >= 0
&& roomsSoldNum <= totalRooms
&& !isNaN(roomRevenueNum)
&& roomRevenueNum > 0
// Edit dialog validation
const editRoomsSoldNum = parseInt(editRoomsSold)
const editRoomRevenueNum = parseFloat(editRoomRevenue)
const editRoomsSoldError = editRoomsSold !== ''
? validateMaxNumber(editRoomsSold, totalRooms)
? `Kan inte overskrida ${totalRooms} rum`
: null
: null
const editRoomRevenueError = editRoomRevenue !== ''
? validatePositiveNumber(editRoomRevenue)
: null
const editFormValid = !isNaN(editRoomsSoldNum)
&& editRoomsSoldNum >= 0
&& editRoomsSoldNum <= totalRooms
&& !isNaN(editRoomRevenueNum)
&& editRoomRevenueNum > 0
// Settings validation
const settingsRoomCountNum = parseInt(settingsRoomCount)
const settingsValid = !isNaN(settingsRoomCountNum) && settingsRoomCountNum > 0
// Monthly trend with all three metrics
const monthlyTrend = useMemo(() => {
const map = new Map<string, { revenue: number; rooms: number; days: number }>()
for (const e of entries) {
const month = e.date.slice(0, 7)
const existing = map.get(month) ?? { revenue: 0, rooms: 0, days: 0 }
existing.revenue += e.roomRevenue
existing.rooms += e.roomsSold
existing.days++
map.set(month, existing)
}
return Array.from(map.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([month, d]) => {
const available = totalRooms * d.days
return {
month,
revpar: available > 0 ? Math.round((d.revenue / available) * 100) / 100 : 0,
adr: d.rooms > 0 ? Math.round((d.revenue / d.rooms) * 100) / 100 : 0,
occupancy: available > 0 ? Math.round((d.rooms / available) * 10000) / 100 : 0,
}
})
}, [entries, totalRooms])
// --- Handlers ---
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!formValid) return
setIsSubmitting(true)
await save(`daily:${entryDate}`, { roomsSold: roomsSoldNum, roomRevenue: roomRevenueNum })
setRoomsSold('')
setRoomRevenue('')
await refresh()
setIsSubmitting(false)
}
const openEditDialog = (entry: DailyEntry) => {
setEditDate(entry.date)
setEditRoomsSold(String(entry.roomsSold))
setEditRoomRevenue(String(entry.roomRevenue))
setEditDialogOpen(true)
}
const handleSaveEdit = async () => {
if (!editFormValid) return
setIsSavingEdit(true)
await save(`daily:${editDate}`, {
roomsSold: editRoomsSoldNum,
roomRevenue: editRoomRevenueNum,
})
await refresh()
setIsSavingEdit(false)
}
const openDeleteDialog = (date: string) => {
setDeleteDate(date)
setDeleteDialogOpen(true)
}
const handleConfirmDelete = async () => {
setIsDeleting(true)
await remove(`daily:${deleteDate}`)
setIsDeleting(false)
}
const openSettingsDialog = () => {
setSettingsRoomCount(String(totalRooms))
setSettingsDialogOpen(true)
}
const handleSaveSettings = async () => {
if (!settingsValid) return
setIsSavingSettings(true)
await save('settings', { totalRooms: settingsRoomCountNum })
await refresh()
setIsSavingSettings(false)
}
const handleSetup = async (values: Record<string, string>) => {
await save('settings', { totalRooms: parseInt(values.totalRooms) || 0 })
}
if (isLoading) return <ExtensionLoadingSkeleton />
if (!totalRooms) {
return (
<SetupPrompt
title="Konfigurera RevPAR"
description="Ange antal rum pa hotellet for att borja berakna RevPAR."
fields={[{ key: 'totalRooms', label: 'Antal rum', type: 'number', placeholder: 'T.ex. 50' }]}
onSave={handleSetup}
/>
)
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<DateRangeFilter onRangeChange={(start, end) => setDateRange({ start, end })} />
<Button variant="ghost" size="sm" onClick={openSettingsDialog}>
<Settings className="h-4 w-4 mr-1.5" />
{totalRooms} rum
</Button>
</div>
{/* KPI Cards with delta indicators */}
<div className="grid grid-cols-1 sm:grid-cols-4 gap-4">
<KPICard label="RevPAR" value={current.revpar.toLocaleString('sv-SE')} suffix="kr" />
<KPICard label="ADR" value={current.adr.toLocaleString('sv-SE')} suffix="kr" />
<KPICard label="Belaggning" value={current.occupancyPct} suffix="%" />
<KPICard label="Total intakt" value={current.totalRevenue.toLocaleString('sv-SE')} suffix="kr" />
</div>
{/* Period comparison */}
<div className="rounded-xl border p-4">
<h3 className="text-sm font-semibold mb-3">Periodjamforelse</h3>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="space-y-1">
<p className="text-xs text-muted-foreground">RevPAR</p>
<div className="flex items-center gap-2">
<span className="text-lg font-semibold tabular-nums">
{current.revpar.toLocaleString('sv-SE')} kr
</span>
<DeltaArrow current={current.revpar} previous={prev.revpar} higherIsBetter />
</div>
<p className="text-xs text-muted-foreground">
Foregaende: {prev.revpar.toLocaleString('sv-SE')} kr
</p>
</div>
<div className="space-y-1">
<p className="text-xs text-muted-foreground">ADR</p>
<div className="flex items-center gap-2">
<span className="text-lg font-semibold tabular-nums">
{current.adr.toLocaleString('sv-SE')} kr
</span>
<DeltaArrow current={current.adr} previous={prev.adr} higherIsBetter />
</div>
<p className="text-xs text-muted-foreground">
Foregaende: {prev.adr.toLocaleString('sv-SE')} kr
</p>
</div>
<div className="space-y-1">
<p className="text-xs text-muted-foreground">Belaggning</p>
<div className="flex items-center gap-2">
<span className="text-lg font-semibold tabular-nums">
{current.occupancyPct}%
</span>
<DeltaArrow current={current.occupancyPct} previous={prev.occupancyPct} higherIsBetter />
</div>
<p className="text-xs text-muted-foreground">
Foregaende: {prev.occupancyPct}%
</p>
</div>
</div>
<div className="mt-3 pt-3 border-t">
<p className="text-xs text-muted-foreground">
Foregaende period: {prevPeriod.start} {prevPeriod.end}
</p>
</div>
</div>
{/* Data entry form with validation */}
<DataEntryForm
title="Registrera daglig data"
onSubmit={handleSubmit}
submitLabel="Registrera"
isSubmitting={isSubmitting || !formValid}
>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="revpar-date">Datum</Label>
<Input
id="revpar-date"
type="date"
value={entryDate}
onChange={e => setEntryDate(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="revpar-rooms">Rum salda</Label>
<Input
id="revpar-rooms"
type="number"
min="0"
max={totalRooms}
placeholder="0"
value={roomsSold}
onChange={e => setRoomsSold(e.target.value)}
className={cn(roomsSoldError && 'border-red-500')}
/>
{roomsSoldError && (
<p className="text-xs text-red-600">{roomsSoldError}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="revpar-revenue">Rumsintakt (kr)</Label>
<Input
id="revpar-revenue"
type="number"
min="0"
step="0.01"
placeholder="0"
value={roomRevenue}
onChange={e => setRoomRevenue(e.target.value)}
className={cn(roomRevenueError && 'border-red-500')}
/>
{roomRevenueError && (
<p className="text-xs text-red-600">{roomRevenueError}</p>
)}
</div>
</div>
</DataEntryForm>
{/* Monthly trend with RevPAR, ADR, Occupancy */}
{monthlyTrend.length > 0 && (
<div>
<h3 className="text-sm font-semibold mb-3">Manadstrend</h3>
<div className="rounded-xl border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Period</TableHead>
<TableHead className="text-right">RevPAR</TableHead>
<TableHead className="text-right">ADR</TableHead>
<TableHead className="text-right">Belaggning</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{monthlyTrend.map(row => (
<TableRow key={row.month}>
<TableCell className="font-medium">{row.month}</TableCell>
<TableCell className="text-right tabular-nums">
{row.revpar.toLocaleString('sv-SE')} kr
</TableCell>
<TableCell className="text-right tabular-nums">
{row.adr.toLocaleString('sv-SE')} kr
</TableCell>
<TableCell className="text-right tabular-nums">
{row.occupancy}%
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)}
{/* Daily data table with edit and delete */}
<div>
<h3 className="text-sm font-semibold mb-3">Daglig data</h3>
{entries.length === 0 ? (
<p className="text-sm text-muted-foreground">Ingen data registrerad i vald period.</p>
) : (
<div className="rounded-xl border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Datum</TableHead>
<TableHead className="text-right">Rum salda</TableHead>
<TableHead className="text-right">Intakt</TableHead>
<TableHead className="text-right">ADR</TableHead>
<TableHead className="w-20"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{entries.map(e => (
<TableRow key={e.date}>
<TableCell className="font-medium">{e.date}</TableCell>
<TableCell className="text-right tabular-nums">
{e.roomsSold} / {totalRooms}
</TableCell>
<TableCell className="text-right tabular-nums">
{e.roomRevenue.toLocaleString('sv-SE')} kr
</TableCell>
<TableCell className="text-right tabular-nums">
{e.roomsSold > 0
? Math.round(e.roomRevenue / e.roomsSold).toLocaleString('sv-SE')
: 0} kr
</TableCell>
<TableCell>
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => openEditDialog(e)}
>
<Pencil className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => openDeleteDialog(e.date)}
>
<Trash2 className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
{/* Edit entry dialog */}
<EditEntryDialog
open={editDialogOpen}
onOpenChange={setEditDialogOpen}
title="Redigera daglig data"
description={`Redigera data for ${editDate}`}
onSave={handleSaveEdit}
isSaving={isSavingEdit}
>
<div className="grid grid-cols-1 gap-4">
<div className="space-y-2">
<Label htmlFor="edit-date">Datum</Label>
<Input
id="edit-date"
type="date"
value={editDate}
disabled
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-rooms">Rum salda</Label>
<Input
id="edit-rooms"
type="number"
min="0"
max={totalRooms}
value={editRoomsSold}
onChange={e => setEditRoomsSold(e.target.value)}
className={cn(editRoomsSoldError && 'border-red-500')}
/>
{editRoomsSoldError && (
<p className="text-xs text-red-600">{editRoomsSoldError}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="edit-revenue">Rumsintakt (kr)</Label>
<Input
id="edit-revenue"
type="number"
min="0"
step="0.01"
value={editRoomRevenue}
onChange={e => setEditRoomRevenue(e.target.value)}
className={cn(editRoomRevenueError && 'border-red-500')}
/>
{editRoomRevenueError && (
<p className="text-xs text-red-600">{editRoomRevenueError}</p>
)}
</div>
</div>
</EditEntryDialog>
{/* Confirm delete dialog */}
<ConfirmDeleteDialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
title="Ta bort registrering"
description={`Vill du ta bort data for ${deleteDate}? Atgarden kan inte angras.`}
onConfirm={handleConfirmDelete}
isDeleting={isDeleting}
/>
{/* Settings dialog */}
<EditEntryDialog
open={settingsDialogOpen}
onOpenChange={setSettingsDialogOpen}
title="Installningar"
description="Andra antal rum pa hotellet."
onSave={handleSaveSettings}
isSaving={isSavingSettings}
>
<div className="space-y-2">
<Label htmlFor="settings-rooms">Antal rum</Label>
<Input
id="settings-rooms"
type="number"
min="1"
value={settingsRoomCount}
onChange={e => setSettingsRoomCount(e.target.value)}
/>
{settingsRoomCount !== '' && !settingsValid && (
<p className="text-xs text-red-600">Ange ett giltigt antal rum (minst 1)</p>
)}
</div>
</EditEntryDialog>
</div>
)
}
@@ -1,109 +1,817 @@
'use client'
import { useState, useEffect } from 'react'
import { useState, useMemo } from 'react'
import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
import { useAccountTotals } from '@/lib/extensions/use-account-totals'
import { useExtensionData } from '@/lib/extensions/use-extension-data'
import KPICard from '@/components/extensions/shared/KPICard'
import DateRangeFilter from '@/components/extensions/shared/DateRangeFilter'
import MonthlyTrendTable from '@/components/extensions/shared/MonthlyTrendTable'
import DataEntryForm from '@/components/extensions/shared/DataEntryForm'
import ExtensionLoadingSkeleton from '@/components/extensions/shared/ExtensionLoadingSkeleton'
import ConfirmDeleteDialog from '@/components/extensions/shared/ConfirmDeleteDialog'
import EditEntryDialog from '@/components/extensions/shared/EditEntryDialog'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select'
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from '@/components/ui/table'
import { Trash2, Pencil, ArrowUp, ArrowDown, Minus, Settings, Plus, X } from 'lucide-react'
export default function EarningsPerLiterWorkspace({ userId }: WorkspaceComponentProps) {
const [isLoading, setIsLoading] = useState(true)
const [earningsPerLiter, setEarningsPerLiter] = useState<number>(0)
const [totalLiters, setTotalLiters] = useState<number>(0)
const [totalRevenue, setTotalRevenue] = useState<number>(0)
const DEFAULT_CATEGORIES = ['Ol', 'Vin', 'Sprit']
// Data entry state
const [liters, setLiters] = useState('')
const [entryDate, setEntryDate] = useState(new Date().toISOString().slice(0, 10))
const [isSubmitting, setIsSubmitting] = useState(false)
const DEFAULT_PRICING: Record<string, number> = {
Ol: 80,
Vin: 120,
Sprit: 200,
}
interface LiterEntry {
id: string
date: string
category: string
liters: number
}
interface Pricing {
[category: string]: number
}
function DeltaIndicator({ current, previous }: { current: number; previous: number }) {
if (previous === 0) return <Minus className="h-3.5 w-3.5 text-muted-foreground inline" />
const delta = Math.round(((current - previous) / previous) * 10000) / 100
if (delta > 0) {
return (
<span className="inline-flex items-center gap-0.5 text-xs text-green-600">
<ArrowUp className="h-3 w-3" />+{delta}%
</span>
)
}
if (delta < 0) {
return (
<span className="inline-flex items-center gap-0.5 text-xs text-red-600">
<ArrowDown className="h-3 w-3" />{delta}%
</span>
)
}
return (
<span className="inline-flex items-center gap-0.5 text-xs text-muted-foreground">
<Minus className="h-3 w-3" />0%
</span>
)
}
export default function EarningsPerLiterWorkspace({}: WorkspaceComponentProps) {
const now = new Date()
const [dateRange, setDateRange] = useState({
start: new Date(now.getFullYear(), now.getMonth(), 1).toISOString().slice(0, 10),
end: new Date(now.getFullYear(), now.getMonth() + 1, 0).toISOString().slice(0, 10),
})
useEffect(() => {
// In a real implementation, this would fetch liter entries and revenue data
setIsLoading(false)
}, [dateRange, userId])
const { data, save, remove, refresh, isLoading: dataLoading } = useExtensionData('restaurant', 'earnings-per-liter')
// Settings: categories
const settings = data.find(d => d.key === 'settings')?.value as { categories?: string[] } | undefined
const categories = settings?.categories ?? DEFAULT_CATEGORIES
// Pricing per category (kr per liter)
const pricingData = data.find(d => d.key === 'pricing')?.value as Pricing | undefined
const pricing: Pricing = useMemo(() => {
const base: Pricing = {}
for (const cat of categories) {
base[cat] = pricingData?.[cat] ?? DEFAULT_PRICING[cat] ?? 100
}
return base
}, [categories, pricingData])
// Entries filtered by date range
const entries: LiterEntry[] = useMemo(() =>
data.filter(d => d.key.startsWith('entry:'))
.map(d => ({
id: d.key,
...(d.value as { date: string; category: string; liters: number }),
}))
.filter(e => e.date >= dateRange.start && e.date <= dateRange.end)
.sort((a, b) => b.date.localeCompare(a.date))
, [data, dateRange])
// Previous period entries for comparison
const prevPeriodEntries: LiterEntry[] = useMemo(() => {
const startDate = new Date(dateRange.start)
const endDate = new Date(dateRange.end)
const durationMs = endDate.getTime() - startDate.getTime()
const prevStart = new Date(startDate.getTime() - durationMs - 86400000)
const prevEnd = new Date(startDate.getTime() - 86400000)
const prevStartStr = prevStart.toISOString().slice(0, 10)
const prevEndStr = prevEnd.toISOString().slice(0, 10)
return data.filter(d => d.key.startsWith('entry:'))
.map(d => ({
id: d.key,
...(d.value as { date: string; category: string; liters: number }),
}))
.filter(e => e.date >= prevStartStr && e.date <= prevEndStr)
}, [data, dateRange])
// Form state - single entry
const [entryDate, setEntryDate] = useState(now.toISOString().slice(0, 10))
const [category, setCategory] = useState(categories[0])
const [liters, setLiters] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
// Batch entry mode
const [batchMode, setBatchMode] = useState(false)
const [batchDate, setBatchDate] = useState(now.toISOString().slice(0, 10))
const [batchLiters, setBatchLiters] = useState<Record<string, string>>({})
const [isBatchSubmitting, setIsBatchSubmitting] = useState(false)
// Edit state
const [editingEntry, setEditingEntry] = useState<LiterEntry | null>(null)
const [editDate, setEditDate] = useState('')
const [editCategory, setEditCategory] = useState('')
const [editLiters, setEditLiters] = useState('')
const [isSavingEdit, setIsSavingEdit] = useState(false)
// Delete confirmation state
const [deletingEntry, setDeletingEntry] = useState<LiterEntry | null>(null)
const [isDeleting, setIsDeleting] = useState(false)
// Category management state
const [newCategoryName, setNewCategoryName] = useState('')
const [editingPricing, setEditingPricing] = useState(false)
const [pricingInputs, setPricingInputs] = useState<Record<string, string>>({})
// Alcohol revenue from bookkeeping (accounts 3000-3999)
const { totalCredit: alcoholRevenue, isLoading: revenueLoading } = useAccountTotals({
from: '3000', to: '3999',
dateFrom: dateRange.start, dateTo: dateRange.end,
})
// Calculations
const totalLiters = entries.reduce((s, e) => s + e.liters, 0)
const earningsPerLiter = totalLiters > 0
? Math.round((alcoholRevenue / totalLiters) * 100) / 100
: 0
// Estimated revenue based on pricing
const estimatedRevenue = useMemo(() => {
let total = 0
for (const e of entries) {
const price = pricing[e.category] ?? 100
total += e.liters * price
}
return Math.round(total * 100) / 100
}, [entries, pricing])
// Previous period calculations
const prevTotalLiters = prevPeriodEntries.reduce((s, e) => s + e.liters, 0)
const prevEstimatedRevenue = useMemo(() => {
let total = 0
for (const e of prevPeriodEntries) {
const price = pricing[e.category] ?? 100
total += e.liters * price
}
return Math.round(total * 100) / 100
}, [prevPeriodEntries, pricing])
const prevEarningsPerLiter = prevTotalLiters > 0
? Math.round((prevEstimatedRevenue / prevTotalLiters) * 100) / 100
: 0
// Category breakdown with per-category revenue = liters x avg price
const categoryBreakdown = useMemo(() => {
const map = new Map<string, number>()
for (const e of entries) {
map.set(e.category, (map.get(e.category) ?? 0) + e.liters)
}
return categories.map(cat => {
const catLiters = map.get(cat) ?? 0
const avgPrice = pricing[cat] ?? 100
const estimatedRev = Math.round(catLiters * avgPrice * 100) / 100
const eplCategory = catLiters > 0
? Math.round((estimatedRev / catLiters) * 100) / 100
: 0
return {
category: cat,
liters: catLiters,
avgPrice,
estimatedRevenue: estimatedRev,
earningsPerLiter: eplCategory,
}
})
}, [entries, categories, pricing])
// Monthly trend
const monthlyTrend = useMemo(() => {
const map = new Map<string, number>()
for (const e of entries) {
const month = e.date.slice(0, 7)
map.set(month, (map.get(month) ?? 0) + e.liters)
}
return Array.from(map.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([month, liters]) => ({ month, value: liters }))
}, [entries])
// --- Handlers ---
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
const val = parseFloat(liters)
if (isNaN(val) || val <= 0) return
setIsSubmitting(true)
// In a real implementation, this would save the liter entry via API
setIsSubmitting(false)
const id = crypto.randomUUID()
await save(`entry:${id}`, { date: entryDate, category, liters: val })
setLiters('')
await refresh()
setIsSubmitting(false)
}
if (isLoading) return <ExtensionLoadingSkeleton />
const handleBatchSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setIsBatchSubmitting(true)
for (const cat of categories) {
const val = parseFloat(batchLiters[cat] ?? '')
if (!isNaN(val) && val > 0) {
const id = crypto.randomUUID()
await save(`entry:${id}`, { date: batchDate, category: cat, liters: val })
}
}
setBatchLiters({})
await refresh()
setIsBatchSubmitting(false)
}
const openEdit = (entry: LiterEntry) => {
setEditingEntry(entry)
setEditDate(entry.date)
setEditCategory(entry.category)
setEditLiters(String(entry.liters))
}
const handleSaveEdit = async () => {
if (!editingEntry) return
const val = parseFloat(editLiters)
if (isNaN(val) || val <= 0) return
setIsSavingEdit(true)
await save(editingEntry.id, { date: editDate, category: editCategory, liters: val })
await refresh()
setIsSavingEdit(false)
setEditingEntry(null)
}
const handleConfirmDelete = async () => {
if (!deletingEntry) return
setIsDeleting(true)
await remove(deletingEntry.id)
setIsDeleting(false)
setDeletingEntry(null)
}
// Category management
const handleAddCategory = async () => {
const name = newCategoryName.trim()
if (!name || categories.includes(name)) return
const updated = [...categories, name]
await save('settings', { ...settings, categories: updated })
setNewCategoryName('')
await refresh()
}
const handleRemoveCategory = async (cat: string) => {
const updated = categories.filter(c => c !== cat)
if (updated.length === 0) return
await save('settings', { ...settings, categories: updated })
// Update pricing to remove the category
if (pricingData) {
const updatedPricing = { ...pricingData }
delete updatedPricing[cat]
await save('pricing', updatedPricing)
}
await refresh()
}
const handleRenameCategory = async (oldName: string, newName: string) => {
if (!newName.trim() || oldName === newName.trim()) return
const trimmed = newName.trim()
const updated = categories.map(c => c === oldName ? trimmed : c)
await save('settings', { ...settings, categories: updated })
// Update pricing key
if (pricingData) {
const updatedPricing = { ...pricingData }
if (updatedPricing[oldName] !== undefined) {
updatedPricing[trimmed] = updatedPricing[oldName]
delete updatedPricing[oldName]
}
await save('pricing', updatedPricing)
}
await refresh()
}
const handleSavePricing = async () => {
const updated: Pricing = {}
for (const cat of categories) {
const val = parseFloat(pricingInputs[cat] ?? '')
updated[cat] = !isNaN(val) && val > 0 ? Math.round(val * 100) / 100 : (pricing[cat] ?? 100)
}
await save('pricing', updated)
setEditingPricing(false)
await refresh()
}
const startEditPricing = () => {
const inputs: Record<string, string> = {}
for (const cat of categories) {
inputs[cat] = String(pricing[cat] ?? 100)
}
setPricingInputs(inputs)
setEditingPricing(true)
}
if (dataLoading || revenueLoading) return <ExtensionLoadingSkeleton />
return (
<div className="space-y-6">
<DateRangeFilter
onRangeChange={(start, end) => setDateRange({ start, end })}
/>
<Tabs defaultValue="register">
<TabsList>
<TabsTrigger value="register">Registrera</TabsTrigger>
<TabsTrigger value="overview">Oversikt</TabsTrigger>
<TabsTrigger value="settings">Installningar</TabsTrigger>
</TabsList>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<KPICard
label="Intäkt per liter"
value={earningsPerLiter.toLocaleString('sv-SE')}
suffix="kr/l"
/>
<KPICard
label="Totalt liter"
value={totalLiters.toLocaleString('sv-SE')}
suffix="l"
/>
<KPICard
label="Alkoholintäkter"
value={totalRevenue.toLocaleString('sv-SE')}
suffix="kr"
/>
</div>
{/* ---- REGISTER TAB ---- */}
<TabsContent value="register" className="space-y-6 mt-4">
<DateRangeFilter onRangeChange={(start, end) => setDateRange({ start, end })} />
<DataEntryForm
title="Registrera daglig literförsäljning"
onSubmit={handleSubmit}
submitLabel="Registrera"
isSubmitting={isSubmitting}
>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="entry-date">Datum</Label>
<Input
id="entry-date"
type="date"
value={entryDate}
onChange={e => setEntryDate(e.target.value)}
{/* KPI row */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<KPICard
label="Intakt per liter"
value={earningsPerLiter.toLocaleString('sv-SE')}
suffix="kr/l"
trend={prevEarningsPerLiter > 0 ? {
value: Math.round(((earningsPerLiter - prevEarningsPerLiter) / prevEarningsPerLiter) * 10000) / 100,
label: 'mot foreg. period',
} : undefined}
/>
<KPICard
label="Totalt liter"
value={(Math.round(totalLiters * 100) / 100).toLocaleString('sv-SE')}
suffix="l"
trend={prevTotalLiters > 0 ? {
value: Math.round(((totalLiters - prevTotalLiters) / prevTotalLiters) * 10000) / 100,
label: 'mot foreg. period',
} : undefined}
/>
<KPICard
label="Uppskattad intakt"
value={estimatedRevenue.toLocaleString('sv-SE')}
suffix="kr"
/>
<KPICard
label="Bokford intakt"
value={alcoholRevenue.toLocaleString('sv-SE')}
suffix="kr"
/>
</div>
{/* Revenue comparison */}
{estimatedRevenue > 0 && alcoholRevenue > 0 && (
<div className="rounded-xl border p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">Uppskattad vs bokford intakt</p>
<p className="text-xs text-muted-foreground">
Differens baserat pa snittkr/liter per kategori
</p>
</div>
<div className="text-right">
<p className="text-lg font-semibold tabular-nums">
{(Math.round((estimatedRevenue - alcoholRevenue) * 100) / 100).toLocaleString('sv-SE')} kr
</p>
<DeltaIndicator current={estimatedRevenue} previous={alcoholRevenue} />
</div>
</div>
</div>
)}
{/* Single entry form / batch mode toggle */}
<div className="flex items-center gap-2 justify-end">
<Button
variant={batchMode ? 'default' : 'outline'}
size="sm"
onClick={() => setBatchMode(!batchMode)}
>
{batchMode ? 'Enkel registrering' : 'Registrera flera'}
</Button>
</div>
{batchMode ? (
<DataEntryForm
title="Registrera flera kategorier"
onSubmit={handleBatchSubmit}
submitLabel="Registrera alla"
isSubmitting={isBatchSubmitting}
>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="epl-batch-date">Datum</Label>
<Input
id="epl-batch-date"
type="date"
value={batchDate}
onChange={e => setBatchDate(e.target.value)}
className="max-w-xs"
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{categories.map(cat => (
<div key={cat} className="space-y-2">
<Label htmlFor={`epl-batch-${cat}`}>
{cat} (liter)
</Label>
<Input
id={`epl-batch-${cat}`}
type="number"
step="0.01"
min="0"
placeholder="0.00"
value={batchLiters[cat] ?? ''}
onChange={e => setBatchLiters(prev => ({ ...prev, [cat]: e.target.value }))}
/>
</div>
))}
</div>
</div>
</DataEntryForm>
) : (
<DataEntryForm
title="Registrera literforbrukning"
onSubmit={handleSubmit}
submitLabel="Registrera"
isSubmitting={isSubmitting}
>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="epl-date">Datum</Label>
<Input id="epl-date" type="date" value={entryDate} onChange={e => setEntryDate(e.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="epl-category">Kategori</Label>
<Select value={category} onValueChange={setCategory}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{categories.map(c => <SelectItem key={c} value={c}>{c}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="epl-liters">Antal liter</Label>
<Input id="epl-liters" type="number" step="0.01" min="0" placeholder="0.00" value={liters} onChange={e => setLiters(e.target.value)} />
</div>
</div>
</DataEntryForm>
)}
{/* Entry history */}
<div>
<h3 className="text-sm font-semibold mb-3">Senaste registreringar</h3>
{entries.length === 0 ? (
<p className="text-sm text-muted-foreground">Inga registreringar i vald period.</p>
) : (
<div className="rounded-xl border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Datum</TableHead>
<TableHead>Kategori</TableHead>
<TableHead className="text-right">Liter</TableHead>
<TableHead className="text-right">Uppsk. intakt</TableHead>
<TableHead className="w-20"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{entries.slice(0, 20).map(e => {
const entryRevenue = Math.round(e.liters * (pricing[e.category] ?? 100) * 100) / 100
return (
<TableRow key={e.id}>
<TableCell>{e.date}</TableCell>
<TableCell>{e.category}</TableCell>
<TableCell className="text-right tabular-nums">{e.liters.toLocaleString('sv-SE')} l</TableCell>
<TableCell className="text-right tabular-nums">{entryRevenue.toLocaleString('sv-SE')} kr</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" onClick={() => openEdit(e)}>
<Pencil className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
<Button variant="ghost" size="sm" onClick={() => setDeletingEntry(e)}>
<Trash2 className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
</div>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
)}
</div>
</TabsContent>
{/* ---- OVERVIEW TAB ---- */}
<TabsContent value="overview" className="space-y-6 mt-4">
<DateRangeFilter onRangeChange={(start, end) => setDateRange({ start, end })} />
{/* Period comparison KPIs */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="rounded-xl border p-4 space-y-1">
<p className="text-xs text-muted-foreground">Intakt/liter (nuvarande)</p>
<p className="text-2xl font-semibold tabular-nums">{earningsPerLiter.toLocaleString('sv-SE')} kr/l</p>
{prevEarningsPerLiter > 0 && (
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">Foreg: {prevEarningsPerLiter.toLocaleString('sv-SE')} kr/l</span>
<DeltaIndicator current={earningsPerLiter} previous={prevEarningsPerLiter} />
</div>
)}
</div>
<div className="rounded-xl border p-4 space-y-1">
<p className="text-xs text-muted-foreground">Liter (nuvarande)</p>
<p className="text-2xl font-semibold tabular-nums">{(Math.round(totalLiters * 100) / 100).toLocaleString('sv-SE')} l</p>
{prevTotalLiters > 0 && (
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">Foreg: {(Math.round(prevTotalLiters * 100) / 100).toLocaleString('sv-SE')} l</span>
<DeltaIndicator current={totalLiters} previous={prevTotalLiters} />
</div>
)}
</div>
<div className="rounded-xl border p-4 space-y-1">
<p className="text-xs text-muted-foreground">Uppsk. intakt (nuvarande)</p>
<p className="text-2xl font-semibold tabular-nums">{estimatedRevenue.toLocaleString('sv-SE')} kr</p>
{prevEstimatedRevenue > 0 && (
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">Foreg: {prevEstimatedRevenue.toLocaleString('sv-SE')} kr</span>
<DeltaIndicator current={estimatedRevenue} previous={prevEstimatedRevenue} />
</div>
)}
</div>
</div>
{/* Category breakdown */}
{categoryBreakdown.some(c => c.liters > 0) && (
<div>
<h3 className="text-sm font-semibold mb-3">Per kategori</h3>
<div className="rounded-xl border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Kategori</TableHead>
<TableHead className="text-right">Liter</TableHead>
<TableHead className="text-right">Snittpris/l</TableHead>
<TableHead className="text-right">Uppsk. intakt</TableHead>
<TableHead className="text-right">Kr/liter</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{categoryBreakdown.map(c => (
<TableRow key={c.category}>
<TableCell className="font-medium">{c.category}</TableCell>
<TableCell className="text-right tabular-nums">{c.liters.toLocaleString('sv-SE')} l</TableCell>
<TableCell className="text-right tabular-nums">{c.avgPrice.toLocaleString('sv-SE')} kr</TableCell>
<TableCell className="text-right tabular-nums">{c.estimatedRevenue.toLocaleString('sv-SE')} kr</TableCell>
<TableCell className="text-right tabular-nums">{c.earningsPerLiter.toLocaleString('sv-SE')} kr/l</TableCell>
</TableRow>
))}
<TableRow className="font-semibold">
<TableCell>Totalt</TableCell>
<TableCell className="text-right tabular-nums">{(Math.round(totalLiters * 100) / 100).toLocaleString('sv-SE')} l</TableCell>
<TableCell className="text-right"></TableCell>
<TableCell className="text-right tabular-nums">{estimatedRevenue.toLocaleString('sv-SE')} kr</TableCell>
<TableCell className="text-right tabular-nums">{earningsPerLiter.toLocaleString('sv-SE')} kr/l</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</div>
)}
{/* Monthly trend */}
{monthlyTrend.length > 0 && (
<div>
<h3 className="text-sm font-semibold mb-3">Manadstrend</h3>
<MonthlyTrendTable rows={monthlyTrend} valueLabel="Liter" valueSuffix="l" />
</div>
)}
</TabsContent>
{/* ---- SETTINGS TAB ---- */}
<TabsContent value="settings" className="space-y-6 mt-4">
{/* Category management */}
<div className="rounded-xl border p-4 space-y-4">
<div>
<h3 className="text-sm font-semibold">Kategorier</h3>
<p className="text-xs text-muted-foreground">Lagg till, ta bort eller byt namn pa dryckkategorier.</p>
</div>
<div className="flex gap-2">
<Input
placeholder="Ny kategori"
value={newCategoryName}
onChange={e => setNewCategoryName(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleAddCategory()}
className="max-w-xs"
/>
<Button size="sm" onClick={handleAddCategory} disabled={!newCategoryName.trim()}>
<Plus className="h-4 w-4 mr-1" /> Lagg till
</Button>
</div>
{categories.length > 0 && (
<div className="rounded-xl border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Namn</TableHead>
<TableHead className="w-24"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{categories.map(cat => (
<CategoryRow
key={cat}
name={cat}
canRemove={categories.length > 1}
onRename={(newName) => handleRenameCategory(cat, newName)}
onRemove={() => handleRemoveCategory(cat)}
/>
))}
</TableBody>
</Table>
</div>
)}
</div>
{/* Pricing settings */}
<div className="rounded-xl border p-4 space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold">Snittpris per liter</h3>
<p className="text-xs text-muted-foreground">
Anvands for att berakna uppskattad intakt per kategori.
</p>
</div>
{!editingPricing && (
<Button size="sm" variant="ghost" onClick={startEditPricing}>
<Settings className="h-4 w-4 mr-1" /> Andra
</Button>
)}
</div>
{editingPricing ? (
<div className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{categories.map(cat => (
<div key={cat} className="space-y-2">
<Label htmlFor={`pricing-${cat}`}>{cat} (kr/liter)</Label>
<Input
id={`pricing-${cat}`}
type="number"
step="0.01"
min="0"
value={pricingInputs[cat] ?? ''}
onChange={e => setPricingInputs(prev => ({ ...prev, [cat]: e.target.value }))}
/>
</div>
))}
</div>
<div className="flex gap-2">
<Button size="sm" onClick={handleSavePricing}>Spara</Button>
<Button size="sm" variant="ghost" onClick={() => setEditingPricing(false)}>Avbryt</Button>
</div>
</div>
) : (
<div className="rounded-xl border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Kategori</TableHead>
<TableHead className="text-right">Pris (kr/l)</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{categories.map(cat => (
<TableRow key={cat}>
<TableCell className="font-medium">{cat}</TableCell>
<TableCell className="text-right tabular-nums">{(pricing[cat] ?? 100).toLocaleString('sv-SE')} kr</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
</TabsContent>
</Tabs>
{/* Edit entry dialog */}
<EditEntryDialog
open={editingEntry !== null}
onOpenChange={(open) => { if (!open) setEditingEntry(null) }}
title="Redigera registrering"
description="Andra datum, kategori eller antal liter."
onSave={handleSaveEdit}
isSaving={isSavingEdit}
>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="liters">Antal liter</Label>
<Input
id="liters"
type="number"
step="0.01"
min="0"
placeholder="0.00"
value={liters}
onChange={e => setLiters(e.target.value)}
/>
<Label htmlFor="edit-date">Datum</Label>
<Input id="edit-date" type="date" value={editDate} onChange={e => setEditDate(e.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="edit-category">Kategori</Label>
<Select value={editCategory} onValueChange={setEditCategory}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{categories.map(c => <SelectItem key={c} value={c}>{c}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="edit-liters">Antal liter</Label>
<Input id="edit-liters" type="number" step="0.01" min="0" value={editLiters} onChange={e => setEditLiters(e.target.value)} />
</div>
</div>
</DataEntryForm>
</EditEntryDialog>
<div className="rounded-xl border p-6">
<h3 className="text-sm font-semibold mb-4"> fungerar det</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
Intäkt per liter beräknas genom att dividera alkoholintäkter med totalt antal sålda
liter. Registrera daglig literförsäljning ovan. Alkoholintäkter hämtas automatiskt
från bokföringen.
</p>
</div>
{/* Delete confirmation dialog */}
<ConfirmDeleteDialog
open={deletingEntry !== null}
onOpenChange={(open) => { if (!open) setDeletingEntry(null) }}
title="Ta bort registrering"
description={deletingEntry ? `Vill du ta bort ${deletingEntry.liters} l ${deletingEntry.category} fran ${deletingEntry.date}?` : ''}
onConfirm={handleConfirmDelete}
isDeleting={isDeleting}
/>
</div>
)
}
// Inline sub-component for category row with rename support
function CategoryRow({
name,
canRemove,
onRename,
onRemove,
}: {
name: string
canRemove: boolean
onRename: (newName: string) => Promise<void>
onRemove: () => Promise<void>
}) {
const [isRenaming, setIsRenaming] = useState(false)
const [newName, setNewName] = useState(name)
const handleRename = async () => {
await onRename(newName)
setIsRenaming(false)
}
return (
<TableRow>
<TableCell>
{isRenaming ? (
<div className="flex items-center gap-2">
<Input
value={newName}
onChange={e => setNewName(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleRename()}
className="h-8 max-w-[200px]"
autoFocus
/>
<Button size="sm" variant="outline" onClick={handleRename}>Spara</Button>
<Button size="sm" variant="ghost" onClick={() => { setIsRenaming(false); setNewName(name) }}>Avbryt</Button>
</div>
) : (
<span className="font-medium">{name}</span>
)}
</TableCell>
<TableCell>
<div className="flex items-center gap-1 justify-end">
{!isRenaming && (
<Button variant="ghost" size="sm" onClick={() => setIsRenaming(true)}>
<Pencil className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
)}
{canRemove && !isRenaming && (
<Button variant="ghost" size="sm" onClick={onRemove}>
<X className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
)}
</div>
</TableCell>
</TableRow>
)
}
@@ -1,64 +1,552 @@
'use client'
import { useState, useEffect } from 'react'
import { useState, useMemo, useCallback } from 'react'
import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
import { useAccountTotals } from '@/lib/extensions/use-account-totals'
import { useExtensionData } from '@/lib/extensions/use-extension-data'
import KPICard from '@/components/extensions/shared/KPICard'
import DateRangeFilter from '@/components/extensions/shared/DateRangeFilter'
import MonthlyTrendTable from '@/components/extensions/shared/MonthlyTrendTable'
import ExtensionLoadingSkeleton from '@/components/extensions/shared/ExtensionLoadingSkeleton'
import ConfirmDeleteDialog from '@/components/extensions/shared/ConfirmDeleteDialog'
import EditEntryDialog from '@/components/extensions/shared/EditEntryDialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select'
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from '@/components/ui/table'
import { ArrowUp, ArrowDown, Minus } from 'lucide-react'
import { cn } from '@/lib/utils'
export default function FoodCostWorkspace({ userId }: WorkspaceComponentProps) {
const [isLoading, setIsLoading] = useState(true)
const [foodCost, setFoodCost] = useState<number>(0)
const [revenue, setRevenue] = useState<number>(0)
const [purchases, setPurchases] = useState<number>(0)
const FOOD_CATEGORIES = ['Kott', 'Fisk', 'Gronsaker', 'Mejeri', 'Drycker', 'Ovrigt'] as const
type FoodCategory = typeof FOOD_CATEGORIES[number]
// Set initial date range to current month
function computePreviousPeriod(start: string, end: string): { start: string; end: string } {
const startDate = new Date(start + 'T00:00:00')
const endDate = new Date(end + 'T00:00:00')
const durationMs = endDate.getTime() - startDate.getTime()
const prevEnd = new Date(startDate.getTime() - 1)
const prevStart = new Date(prevEnd.getTime() - durationMs)
return {
start: prevStart.toISOString().slice(0, 10),
end: prevEnd.toISOString().slice(0, 10),
}
}
function DeltaArrow({ current, previous }: { current: number; previous: number }) {
const delta = Math.round((current - previous) * 100) / 100
if (delta === 0 || (previous === 0 && current === 0)) {
return (
<span className="inline-flex items-center gap-0.5 text-xs text-muted-foreground">
<Minus className="h-3 w-3" />
<span>0 pp</span>
</span>
)
}
// For food cost: lower is better, so negative delta = green (improving)
const improving = delta < 0
return (
<span className={cn(
'inline-flex items-center gap-0.5 text-xs',
improving ? 'text-green-600' : 'text-red-600'
)}>
{delta < 0
? <ArrowDown className="h-3 w-3" />
: <ArrowUp className="h-3 w-3" />
}
<span>{delta > 0 ? '+' : ''}{delta} pp</span>
</span>
)
}
export default function FoodCostWorkspace({}: WorkspaceComponentProps) {
const now = new Date()
const [dateRange, setDateRange] = useState({
start: new Date(now.getFullYear(), now.getMonth(), 1).toISOString().slice(0, 10),
end: new Date(now.getFullYear(), now.getMonth() + 1, 0).toISOString().slice(0, 10),
})
useEffect(() => {
// In a real implementation, this would fetch journal_entry_lines
// and calculate food cost via the API
setIsLoading(false)
}, [dateRange, userId])
const prevPeriod = useMemo(
() => computePreviousPeriod(dateRange.start, dateRange.end),
[dateRange.start, dateRange.end]
)
// Yearly range for monthly trend
const yearStart = `${now.getFullYear()}-01-01`
const yearEnd = `${now.getFullYear()}-12-31`
const { data: extData, save, remove, isLoading: settingsLoading } = useExtensionData('restaurant', 'food-cost')
const settings = extData.find(d => d.key === 'settings')?.value as { targetPct?: number } | undefined
const [targetPctInput, setTargetPct] = useState<string | null>(null)
const [editingTarget, setEditingTarget] = useState(false)
const targetPct = targetPctInput ?? (settings?.targetPct != null ? String(settings.targetPct) : '')
// Category assignments from extension_data (key = "category:{accountNumber}")
const categoryMap = useMemo(() => {
const map: Record<string, FoodCategory> = {}
for (const d of extData) {
if (d.key.startsWith('category:')) {
const account = d.key.replace('category:', '')
map[account] = (d.value as { category: FoodCategory }).category
}
}
return map
}, [extData])
// Notes from extension_data (key = "note:YYYY-MM")
const currentMonth = dateRange.start.slice(0, 7)
const currentNote = extData.find(d => d.key === `note:${currentMonth}`)?.value as { text: string } | undefined
const [noteText, setNoteText] = useState('')
const [editingNote, setEditingNote] = useState(false)
const [savingNote, setSavingNote] = useState(false)
const [deleteNoteOpen, setDeleteNoteOpen] = useState(false)
const [deletingNote, setDeletingNote] = useState(false)
// Target edit dialog state
const [editTargetDialogOpen, setEditTargetDialogOpen] = useState(false)
const [newTargetInput, setNewTargetInput] = useState('')
const [savingTarget, setSavingTarget] = useState(false)
// --- Current period account totals ---
const { totals: purchaseTotals, isLoading: purchasesLoading } = useAccountTotals({
from: '4000', to: '4999',
dateFrom: dateRange.start, dateTo: dateRange.end,
})
const { totals: revenueTotals, isLoading: revenueLoading } = useAccountTotals({
from: '3000', to: '3999',
dateFrom: dateRange.start, dateTo: dateRange.end,
})
// --- Previous period account totals ---
const { totals: prevPurchaseTotals, isLoading: prevPurchasesLoading } = useAccountTotals({
from: '4000', to: '4999',
dateFrom: prevPeriod.start, dateTo: prevPeriod.end,
})
const { totals: prevRevenueTotals, isLoading: prevRevenueLoading } = useAccountTotals({
from: '3000', to: '3999',
dateFrom: prevPeriod.start, dateTo: prevPeriod.end,
})
// Monthly trend data
const { monthly: purchaseMonthly } = useAccountTotals({
from: '4000', to: '4999',
dateFrom: yearStart, dateTo: yearEnd,
groupBy: 'month',
})
const { monthly: revenueMonthly } = useAccountTotals({
from: '3000', to: '3999',
dateFrom: yearStart, dateTo: yearEnd,
groupBy: 'month',
})
// Current period calculations
const totalPurchases = purchaseTotals.reduce((sum, t) => sum + t.debit, 0)
const totalRevenue = revenueTotals.reduce((sum, t) => sum + t.credit, 0)
const foodCostPct = totalRevenue > 0
? Math.round((totalPurchases / totalRevenue) * 10000) / 100
: 0
// Previous period calculations
const prevTotalPurchases = prevPurchaseTotals.reduce((sum, t) => sum + t.debit, 0)
const prevTotalRevenue = prevRevenueTotals.reduce((sum, t) => sum + t.credit, 0)
const prevFoodCostPct = prevTotalRevenue > 0
? Math.round((prevTotalPurchases / prevTotalRevenue) * 10000) / 100
: 0
const target = settings?.targetPct ?? 30
// Monthly trend rows
const monthlyTrend = useMemo(() => {
const months = new Set([
...purchaseMonthly.map(m => m.month),
...revenueMonthly.map(m => m.month),
])
return Array.from(months).sort().map(month => {
const purch = purchaseMonthly.filter(m => m.month === month).reduce((s, m) => s + m.debit, 0)
const rev = revenueMonthly.filter(m => m.month === month).reduce((s, m) => s + m.credit, 0)
const pct = rev > 0 ? Math.round((purch / rev) * 10000) / 100 : 0
return { month, value: pct }
})
}, [purchaseMonthly, revenueMonthly])
// Category breakdown
const categoryBreakdown = useMemo(() => {
const groups: Record<string, { category: FoodCategory; total: number; accounts: string[] }> = {}
for (const cat of FOOD_CATEGORIES) {
groups[cat] = { category: cat, total: 0, accounts: [] }
}
let uncategorizedTotal = 0
const uncategorizedAccounts: string[] = []
for (const t of purchaseTotals) {
const cat = categoryMap[t.account_number]
if (cat && groups[cat]) {
groups[cat].total += t.debit
groups[cat].accounts.push(t.account_number)
} else {
uncategorizedTotal += t.debit
uncategorizedAccounts.push(t.account_number)
}
}
const result = FOOD_CATEGORIES
.map(cat => groups[cat])
.filter(g => g.total > 0 || g.accounts.length > 0)
if (uncategorizedTotal > 0) {
result.push({ category: 'Ovrigt' as FoodCategory, total: uncategorizedTotal, accounts: uncategorizedAccounts })
}
return result
}, [purchaseTotals, categoryMap])
// --- Handlers ---
const saveTarget = async () => {
const val = parseFloat(targetPct)
if (!isNaN(val)) {
// Save target history before changing
const oldTarget = settings?.targetPct
if (oldTarget != null && oldTarget !== val) {
await save(`target-history:${Date.now()}`, {
previousTarget: oldTarget,
newTarget: val,
changedAt: new Date().toISOString(),
})
}
await save('settings', { targetPct: val })
setEditingTarget(false)
}
}
const handleSaveTargetDialog = async () => {
setSavingTarget(true)
const val = parseFloat(newTargetInput)
if (!isNaN(val)) {
const oldTarget = settings?.targetPct
if (oldTarget != null && oldTarget !== val) {
await save(`target-history:${Date.now()}`, {
previousTarget: oldTarget,
newTarget: val,
changedAt: new Date().toISOString(),
})
}
await save('settings', { targetPct: val })
}
setSavingTarget(false)
}
const handleCategoryChange = useCallback(async (accountNumber: string, category: string) => {
if (category === '__none__') {
await remove(`category:${accountNumber}`)
} else {
await save(`category:${accountNumber}`, { category })
}
}, [save, remove])
const handleSaveNote = async () => {
setSavingNote(true)
await save(`note:${currentMonth}`, { text: noteText })
setEditingNote(false)
setSavingNote(false)
}
const handleDeleteNote = async () => {
setDeletingNote(true)
await remove(`note:${currentMonth}`)
setNoteText('')
setEditingNote(false)
setDeletingNote(false)
}
const startEditNote = () => {
setNoteText(currentNote?.text ?? '')
setEditingNote(true)
}
const isLoading = purchasesLoading || revenueLoading || prevPurchasesLoading || prevRevenueLoading || settingsLoading
if (isLoading) return <ExtensionLoadingSkeleton />
return (
<div className="space-y-6">
<DateRangeFilter
onRangeChange={(start, end) => setDateRange({ start, end })}
/>
<DateRangeFilter onRangeChange={(start, end) => setDateRange({ start, end })} />
{/* KPI Cards with period comparison */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<KPICard
label="Food Cost %"
value={foodCost}
value={foodCostPct}
suffix="%"
className={cn(
foodCostPct <= target ? 'border-green-200' : 'border-red-200'
)}
/>
<KPICard
label="Varuinköp"
value={purchases.toLocaleString('sv-SE')}
label="Varuinkop"
value={totalPurchases.toLocaleString('sv-SE')}
suffix="kr"
/>
<KPICard
label="Livsmedelsintäkter"
value={revenue.toLocaleString('sv-SE')}
label="Livsmedelsintakter"
value={totalRevenue.toLocaleString('sv-SE')}
suffix="kr"
/>
</div>
<div className="rounded-xl border p-6">
<h3 className="text-sm font-semibold mb-4"> fungerar det</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
Food Cost % beräknas automatiskt utifrån din bokföring. Varuinköp (konton 4000-4999)
divideras med livsmedelsintäkter (konton 3000-3999). En bra riktvärde för restauranger
är 25-35%.
</p>
{/* Period comparison */}
<div className="rounded-xl border p-4">
<h3 className="text-sm font-semibold mb-3">Periodjamforelse</h3>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="space-y-1">
<p className="text-xs text-muted-foreground">Food Cost % (nuvarande)</p>
<div className="flex items-center gap-2">
<span className="text-lg font-semibold tabular-nums">{foodCostPct}%</span>
<DeltaArrow current={foodCostPct} previous={prevFoodCostPct} />
</div>
</div>
<div className="space-y-1">
<p className="text-xs text-muted-foreground">Food Cost % (foregaende)</p>
<span className="text-lg font-semibold tabular-nums">{prevFoodCostPct}%</span>
</div>
<div className="space-y-1">
<p className="text-xs text-muted-foreground">Foregaende period</p>
<span className="text-sm tabular-nums">{prevPeriod.start} {prevPeriod.end}</span>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mt-3 pt-3 border-t">
<div className="space-y-1">
<p className="text-xs text-muted-foreground">Varuinkop (foregaende)</p>
<span className="text-sm tabular-nums">{prevTotalPurchases.toLocaleString('sv-SE')} kr</span>
</div>
<div className="space-y-1">
<p className="text-xs text-muted-foreground">Intakter (foregaende)</p>
<span className="text-sm tabular-nums">{prevTotalRevenue.toLocaleString('sv-SE')} kr</span>
</div>
</div>
</div>
{/* Target setting */}
<div className="rounded-xl border p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">Malvarde</p>
<p className="text-xs text-muted-foreground">
Riktvarde for food cost (vanligtvis 25-35%)
</p>
</div>
{editingTarget ? (
<div className="flex items-center gap-2">
<Input
type="number"
step="0.1"
value={targetPct}
onChange={e => setTargetPct(e.target.value)}
className="w-20 h-8 text-sm"
/>
<Label className="text-sm">%</Label>
<Button size="sm" variant="outline" onClick={saveTarget}>Spara</Button>
<Button size="sm" variant="ghost" onClick={() => setEditingTarget(false)}>Avbryt</Button>
</div>
) : (
<Button
size="sm"
variant="ghost"
onClick={() => {
setNewTargetInput(String(target))
setEditTargetDialogOpen(true)
}}
>
{target}% Andra
</Button>
)}
</div>
</div>
{/* Edit target dialog (saves history) */}
<EditEntryDialog
open={editTargetDialogOpen}
onOpenChange={setEditTargetDialogOpen}
title="Andra malvarde"
description={`Nuvarande malvarde: ${target}%. Andringshistorik sparas automatiskt.`}
onSave={handleSaveTargetDialog}
isSaving={savingTarget}
>
<div className="space-y-2">
<Label htmlFor="new-target">Nytt malvarde (%)</Label>
<Input
id="new-target"
type="number"
step="0.1"
value={newTargetInput}
onChange={e => setNewTargetInput(e.target.value)}
className="w-32"
/>
</div>
</EditEntryDialog>
{/* Notes per period */}
<div className="rounded-xl border p-4">
<div className="flex items-center justify-between mb-2">
<div>
<p className="text-sm font-medium">Anteckningar for {currentMonth}</p>
<p className="text-xs text-muted-foreground">
Notera avvikelser och forklaringar for perioden
</p>
</div>
{!editingNote && (
<Button size="sm" variant="ghost" onClick={startEditNote}>
{currentNote?.text ? 'Redigera' : 'Lagg till'}
</Button>
)}
</div>
{editingNote ? (
<div className="space-y-3">
<Textarea
value={noteText}
onChange={e => setNoteText(e.target.value)}
placeholder="Beskriv avvikelser, t.ex. menyandring, leverantorsbyte..."
rows={3}
/>
<div className="flex items-center gap-2">
<Button size="sm" onClick={handleSaveNote} disabled={savingNote}>
{savingNote ? 'Sparar...' : 'Spara'}
</Button>
<Button size="sm" variant="ghost" onClick={() => setEditingNote(false)}>
Avbryt
</Button>
{currentNote?.text && (
<Button
size="sm"
variant="ghost"
className="text-red-600 hover:text-red-700"
onClick={() => setDeleteNoteOpen(true)}
>
Ta bort
</Button>
)}
</div>
</div>
) : currentNote?.text ? (
<p className="text-sm whitespace-pre-wrap">{currentNote.text}</p>
) : (
<p className="text-sm text-muted-foreground">Inga anteckningar for denna period.</p>
)}
</div>
<ConfirmDeleteDialog
open={deleteNoteOpen}
onOpenChange={setDeleteNoteOpen}
title="Ta bort anteckning"
description={`Vill du ta bort anteckningen for ${currentMonth}?`}
onConfirm={handleDeleteNote}
isDeleting={deletingNote}
/>
{/* Monthly trend */}
<div>
<h3 className="text-sm font-semibold mb-3">Manadstrend {now.getFullYear()}</h3>
<MonthlyTrendTable
rows={monthlyTrend}
valueLabel="Food Cost %"
valueSuffix="%"
/>
</div>
{/* Category breakdown */}
{categoryBreakdown.length > 0 && (
<div>
<h3 className="text-sm font-semibold mb-3">Varuinkop per kategori</h3>
<div className="rounded-xl border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Kategori</TableHead>
<TableHead className="text-right">Belopp</TableHead>
<TableHead className="text-right">Andel av inkop</TableHead>
<TableHead className="text-right">Andel av intakter</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{categoryBreakdown.map(g => (
<TableRow key={g.category}>
<TableCell className="font-medium">{g.category}</TableCell>
<TableCell className="text-right tabular-nums">
{(Math.round(g.total * 100) / 100).toLocaleString('sv-SE')} kr
</TableCell>
<TableCell className="text-right tabular-nums">
{totalPurchases > 0
? Math.round((g.total / totalPurchases) * 100)
: 0}%
</TableCell>
<TableCell className="text-right tabular-nums">
{totalRevenue > 0
? Math.round((g.total / totalRevenue) * 10000) / 100
: 0}%
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)}
{/* Account breakdown with category assignment */}
{purchaseTotals.length > 0 && (
<div>
<h3 className="text-sm font-semibold mb-3">Varuinkop per konto</h3>
<div className="rounded-xl border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Konto</TableHead>
<TableHead>Kategori</TableHead>
<TableHead className="text-right">Debet</TableHead>
<TableHead className="text-right">Andel</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{purchaseTotals.map(t => (
<TableRow key={t.account_number}>
<TableCell className="font-medium">{t.account_number}</TableCell>
<TableCell>
<Select
value={categoryMap[t.account_number] ?? '__none__'}
onValueChange={(val) => handleCategoryChange(t.account_number, val)}
>
<SelectTrigger className="h-8 w-[130px] text-xs">
<SelectValue placeholder="Valj kategori" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">Ingen</SelectItem>
{FOOD_CATEGORIES.map(cat => (
<SelectItem key={cat} value={cat}>{cat}</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
<TableCell className="text-right tabular-nums">
{t.debit.toLocaleString('sv-SE')} kr
</TableCell>
<TableCell className="text-right tabular-nums">
{totalPurchases > 0
? Math.round((t.debit / totalPurchases) * 100)
: 0}%
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)}
</div>
)
}
@@ -1,14 +1,696 @@
'use client'
import { Receipt } from 'lucide-react'
import EmptyExtensionState from '@/components/extensions/shared/EmptyExtensionState'
import { useState, useMemo } from 'react'
import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
import { useExtensionData } from '@/lib/extensions/use-extension-data'
import KPICard from '@/components/extensions/shared/KPICard'
import CsvImportWizard from '@/components/extensions/shared/CsvImportWizard'
import ExtensionLoadingSkeleton from '@/components/extensions/shared/ExtensionLoadingSkeleton'
import ConfirmDeleteDialog from '@/components/extensions/shared/ConfirmDeleteDialog'
import EditEntryDialog from '@/components/extensions/shared/EditEntryDialog'
import DataEntryForm from '@/components/extensions/shared/DataEntryForm'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from '@/components/ui/table'
import { Pencil, Trash2, AlertTriangle, ChevronLeft, ChevronRight } from 'lucide-react'
interface DailySale {
date: string
total: number
cash: number
card: number
swish: number
vat: number
}
interface ImportRecord {
id: string
date: string
fileName: string
rowCount: number
}
const PAGE_SIZE = 20
const TARGET_FIELDS = [
{ key: 'date', label: 'Datum', required: true },
{ key: 'total', label: 'Totalt', required: true },
{ key: 'cash', label: 'Kontant' },
{ key: 'card', label: 'Kort' },
{ key: 'swish', label: 'Swish' },
{ key: 'vat', label: 'Moms' },
]
const DEFAULT_MAPPINGS: Record<string, string> = {
date: 'Datum',
total: 'Total',
cash: 'Kontant',
card: 'Kort',
swish: 'Swish',
vat: 'Moms',
}
const parseNum = (v?: string) => {
if (!v) return 0
return Math.round(parseFloat(v.replace(/\s/g, '').replace(',', '.')) * 100) / 100 || 0
}
export default function PosImportWorkspace({}: WorkspaceComponentProps) {
const { data, save, remove, refresh, isLoading } = useExtensionData('restaurant', 'pos-import')
// Pagination
const [page, setPage] = useState(0)
// Manual entry form state
const now = new Date()
const [entryDate, setEntryDate] = useState(now.toISOString().slice(0, 10))
const [entryTotal, setEntryTotal] = useState('')
const [entryCash, setEntryCash] = useState('')
const [entryCard, setEntryCard] = useState('')
const [entrySwish, setEntrySwish] = useState('')
const [entryVat, setEntryVat] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
// Edit dialog state
const [editEntry, setEditEntry] = useState<DailySale | null>(null)
const [editTotal, setEditTotal] = useState('')
const [editCash, setEditCash] = useState('')
const [editCard, setEditCard] = useState('')
const [editSwish, setEditSwish] = useState('')
const [editVat, setEditVat] = useState('')
const [isSaving, setIsSaving] = useState(false)
// Delete dialog state
const [deleteEntry, setDeleteEntry] = useState<DailySale | null>(null)
const [isDeleting, setIsDeleting] = useState(false)
const dailySales = useMemo(() =>
data.filter(d => d.key.startsWith('daily:'))
.map(d => ({ date: d.key.replace('daily:', ''), ...(d.value as Omit<DailySale, 'date'>) }))
.sort((a, b) => b.date.localeCompare(a.date))
, [data])
const imports = useMemo(() =>
data.filter(d => d.key.startsWith('import:'))
.map(d => ({ id: d.key, ...(d.value as Omit<ImportRecord, 'id'>) }))
.sort((a, b) => b.date.localeCompare(a.date))
, [data])
// Pagination calculations
const totalPages = Math.max(1, Math.ceil(dailySales.length / PAGE_SIZE))
const paginatedSales = dailySales.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE)
const handleImport = async (rows: Record<string, string>[]) => {
const importId = crypto.randomUUID()
let count = 0
for (const row of rows) {
const date = row.date
if (!date) continue
await save(`daily:${date}`, {
total: parseNum(row.total),
cash: parseNum(row.cash),
card: parseNum(row.card),
swish: parseNum(row.swish),
vat: parseNum(row.vat),
})
count++
}
await save(`import:${importId}`, {
date: new Date().toISOString().slice(0, 10),
fileName: `CSV-import`,
rowCount: count,
})
await refresh()
setPage(0)
}
// Manual entry validation
const manualPaymentSum = Math.round(
((parseFloat(entryCash) || 0) + (parseFloat(entryCard) || 0) + (parseFloat(entrySwish) || 0)) * 100
) / 100
const manualTotal = Math.round((parseFloat(entryTotal) || 0) * 100) / 100
const manualDiffPct = manualTotal > 0
? Math.round(Math.abs(manualPaymentSum - manualTotal) / manualTotal * 10000) / 100
: 0
const showManualWarning = manualTotal > 0 && manualPaymentSum > 0 && manualDiffPct > 5
const handleManualSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!entryDate || !entryTotal) return
setIsSubmitting(true)
const total = Math.round((parseFloat(entryTotal) || 0) * 100) / 100
const cash = Math.round((parseFloat(entryCash) || 0) * 100) / 100
const card = Math.round((parseFloat(entryCard) || 0) * 100) / 100
const swish = Math.round((parseFloat(entrySwish) || 0) * 100) / 100
const vat = Math.round((parseFloat(entryVat) || 0) * 100) / 100
await save(`daily:${entryDate}`, { total, cash, card, swish, vat })
await refresh()
setEntryTotal('')
setEntryCash('')
setEntryCard('')
setEntrySwish('')
setEntryVat('')
setIsSubmitting(false)
setPage(0)
}
// Edit handlers
const openEdit = (entry: DailySale) => {
setEditEntry(entry)
setEditTotal(String(entry.total))
setEditCash(String(entry.cash))
setEditCard(String(entry.card))
setEditSwish(String(entry.swish))
setEditVat(String(entry.vat))
}
const handleEditSave = async () => {
if (!editEntry) return
setIsSaving(true)
const total = Math.round((parseFloat(editTotal) || 0) * 100) / 100
const cash = Math.round((parseFloat(editCash) || 0) * 100) / 100
const card = Math.round((parseFloat(editCard) || 0) * 100) / 100
const swish = Math.round((parseFloat(editSwish) || 0) * 100) / 100
const vat = Math.round((parseFloat(editVat) || 0) * 100) / 100
await save(`daily:${editEntry.date}`, { total, cash, card, swish, vat })
await refresh()
setIsSaving(false)
}
// Edit dialog validation
const editPaymentSum = Math.round(
((parseFloat(editCash) || 0) + (parseFloat(editCard) || 0) + (parseFloat(editSwish) || 0)) * 100
) / 100
const editTotalVal = Math.round((parseFloat(editTotal) || 0) * 100) / 100
const editDiffPct = editTotalVal > 0
? Math.round(Math.abs(editPaymentSum - editTotalVal) / editTotalVal * 10000) / 100
: 0
const showEditWarning = editTotalVal > 0 && editPaymentSum > 0 && editDiffPct > 5
// Delete handler
const handleDelete = async () => {
if (!deleteEntry) return
setIsDeleting(true)
await remove(`daily:${deleteEntry.date}`)
await refresh()
setIsDeleting(false)
}
// KPI calculations
const totals = useMemo(() => {
const total = dailySales.reduce((s, d) => s + d.total, 0)
const cash = dailySales.reduce((s, d) => s + d.cash, 0)
const card = dailySales.reduce((s, d) => s + d.card, 0)
const swish = dailySales.reduce((s, d) => s + d.swish, 0)
const vat = dailySales.reduce((s, d) => s + d.vat, 0)
const avg = dailySales.length > 0 ? Math.round(total / dailySales.length) : 0
return { total, cash, card, swish, vat, avg }
}, [dailySales])
// VAT analytics
const vatPct = totals.total > 0
? Math.round(totals.vat / totals.total * 10000) / 100
: 0
const vatOutOfRange = vatPct > 0 && (vatPct < 20 || vatPct > 30)
// Payment method monthly breakdown
const paymentMonthly = useMemo(() => {
const map = new Map<string, { cash: number; card: number; swish: number; total: number }>()
for (const d of dailySales) {
const month = d.date.slice(0, 7)
const existing = map.get(month) ?? { cash: 0, card: 0, swish: 0, total: 0 }
existing.cash += d.cash
existing.card += d.card
existing.swish += d.swish
existing.total += d.total
map.set(month, existing)
}
return Array.from(map.entries())
.sort(([a], [b]) => b.localeCompare(a))
.map(([month, vals]) => ({
month,
cashPct: vals.total > 0 ? Math.round(vals.cash / vals.total * 10000) / 100 : 0,
cardPct: vals.total > 0 ? Math.round(vals.card / vals.total * 10000) / 100 : 0,
swishPct: vals.total > 0 ? Math.round(vals.swish / vals.total * 10000) / 100 : 0,
cash: vals.cash,
card: vals.card,
swish: vals.swish,
total: vals.total,
}))
}, [dailySales])
// VAT per month
const vatMonthly = useMemo(() => {
const map = new Map<string, { vat: number; total: number }>()
for (const d of dailySales) {
const month = d.date.slice(0, 7)
const existing = map.get(month) ?? { vat: 0, total: 0 }
existing.vat += d.vat
existing.total += d.total
map.set(month, existing)
}
return Array.from(map.entries())
.sort(([a], [b]) => b.localeCompare(a))
.map(([month, vals]) => ({
month,
vat: Math.round(vals.vat * 100) / 100,
total: Math.round(vals.total * 100) / 100,
vatPct: vals.total > 0 ? Math.round(vals.vat / vals.total * 10000) / 100 : 0,
}))
}, [dailySales])
if (isLoading) return <ExtensionLoadingSkeleton />
export default function PosImportWorkspace() {
return (
<EmptyExtensionState
title="Kassa Z-rapport import"
description="Stöd för import av Z-rapporter kommer snart. Du kommer kunna importera dagliga kassarapporter direkt från ditt kassasystem."
icon={<Receipt className="h-12 w-12 text-muted-foreground/40 mb-4" />}
/>
<div className="space-y-6">
<Tabs defaultValue="import">
<TabsList>
<TabsTrigger value="import">Import</TabsTrigger>
<TabsTrigger value="register">Registrera</TabsTrigger>
<TabsTrigger value="history">Historik</TabsTrigger>
</TabsList>
{/* CSV Import tab */}
<TabsContent value="import" className="space-y-6 mt-4">
<CsvImportWizard
targetFields={TARGET_FIELDS}
defaultMappings={DEFAULT_MAPPINGS}
onImport={handleImport}
/>
</TabsContent>
{/* Manual entry tab */}
<TabsContent value="register" className="space-y-6 mt-4">
<DataEntryForm
title="Registrera dagskassa"
onSubmit={handleManualSubmit}
submitLabel="Registrera"
isSubmitting={isSubmitting}
>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="pos-date">Datum</Label>
<Input
id="pos-date"
type="date"
value={entryDate}
onChange={e => setEntryDate(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="pos-total">Totalt (kr)</Label>
<Input
id="pos-total"
type="number"
step="0.01"
min="0"
placeholder="0"
value={entryTotal}
onChange={e => setEntryTotal(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="pos-cash">Kontant (kr)</Label>
<Input
id="pos-cash"
type="number"
step="0.01"
min="0"
placeholder="0"
value={entryCash}
onChange={e => setEntryCash(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="pos-card">Kort (kr)</Label>
<Input
id="pos-card"
type="number"
step="0.01"
min="0"
placeholder="0"
value={entryCard}
onChange={e => setEntryCard(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="pos-swish">Swish (kr)</Label>
<Input
id="pos-swish"
type="number"
step="0.01"
min="0"
placeholder="0"
value={entrySwish}
onChange={e => setEntrySwish(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="pos-vat">Moms (kr)</Label>
<Input
id="pos-vat"
type="number"
step="0.01"
min="0"
placeholder="0"
value={entryVat}
onChange={e => setEntryVat(e.target.value)}
/>
</div>
</div>
{showManualWarning && (
<div className="flex items-center gap-2 rounded-lg border border-yellow-300 bg-yellow-50 p-3 text-sm text-yellow-800 dark:border-yellow-700 dark:bg-yellow-950/30 dark:text-yellow-200">
<AlertTriangle className="h-4 w-4 shrink-0" />
<span>
Kontant + Kort + Swish ({manualPaymentSum.toLocaleString('sv-SE')} kr) avviker {manualDiffPct}% fran Totalt ({manualTotal.toLocaleString('sv-SE')} kr).
</span>
</div>
)}
</DataEntryForm>
</TabsContent>
{/* History tab */}
<TabsContent value="history" className="space-y-6 mt-4">
{/* KPI cards */}
<div className="grid grid-cols-1 sm:grid-cols-5 gap-4">
<KPICard label="Total forsaljning" value={totals.total.toLocaleString('sv-SE')} suffix="kr" />
<KPICard label="Snitt per dag" value={totals.avg.toLocaleString('sv-SE')} suffix="kr" />
<KPICard label="Kort" value={totals.card.toLocaleString('sv-SE')} suffix="kr" />
<KPICard label="Swish" value={totals.swish.toLocaleString('sv-SE')} suffix="kr" />
<KPICard
label="Snitt momsandel"
value={vatPct}
suffix="%"
className={vatOutOfRange ? 'border-yellow-300 dark:border-yellow-700' : ''}
/>
</div>
{/* VAT alert */}
{vatOutOfRange && dailySales.length > 0 && (
<div className="flex items-center gap-2 rounded-lg border border-yellow-300 bg-yellow-50 p-3 text-sm text-yellow-800 dark:border-yellow-700 dark:bg-yellow-950/30 dark:text-yellow-200">
<AlertTriangle className="h-4 w-4 shrink-0" />
<span>
Momsandelen ({vatPct}%) ligger utanfor forvantat intervall (20-30%) for svenska restauranger. Kontrollera att moms registreras korrekt.
</span>
</div>
)}
{/* Import history */}
{imports.length > 0 && (
<div>
<h3 className="text-sm font-semibold mb-3">Importer</h3>
<div className="rounded-xl border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Datum</TableHead>
<TableHead>Fil</TableHead>
<TableHead className="text-right">Rader</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{imports.map(imp => (
<TableRow key={imp.id}>
<TableCell>{imp.date}</TableCell>
<TableCell>{imp.fileName}</TableCell>
<TableCell className="text-right tabular-nums">{imp.rowCount}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)}
{/* VAT per month */}
{vatMonthly.length > 0 && (
<div>
<h3 className="text-sm font-semibold mb-3">Moms per manad</h3>
<div className="rounded-xl border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Period</TableHead>
<TableHead className="text-right">Forsaljning</TableHead>
<TableHead className="text-right">Moms</TableHead>
<TableHead className="text-right">Momsandel</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{vatMonthly.map(row => (
<TableRow key={row.month}>
<TableCell className="font-medium">{row.month}</TableCell>
<TableCell className="text-right tabular-nums">{row.total.toLocaleString('sv-SE')} kr</TableCell>
<TableCell className="text-right tabular-nums">{row.vat.toLocaleString('sv-SE')} kr</TableCell>
<TableCell className="text-right tabular-nums">
<span className={row.vatPct > 0 && (row.vatPct < 20 || row.vatPct > 30) ? 'text-yellow-600 dark:text-yellow-400' : ''}>
{row.vatPct}%
</span>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)}
{/* Payment method trend */}
{paymentMonthly.length > 0 && (
<div>
<h3 className="text-sm font-semibold mb-3">Betalmetoder per manad</h3>
<div className="rounded-xl border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Period</TableHead>
<TableHead className="text-right">Totalt</TableHead>
<TableHead className="text-right">Kontant</TableHead>
<TableHead className="text-right">% Kontant</TableHead>
<TableHead className="text-right">Kort</TableHead>
<TableHead className="text-right">% Kort</TableHead>
<TableHead className="text-right">Swish</TableHead>
<TableHead className="text-right">% Swish</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{paymentMonthly.map(row => (
<TableRow key={row.month}>
<TableCell className="font-medium">{row.month}</TableCell>
<TableCell className="text-right tabular-nums">{row.total.toLocaleString('sv-SE')} kr</TableCell>
<TableCell className="text-right tabular-nums">{row.cash.toLocaleString('sv-SE')} kr</TableCell>
<TableCell className="text-right tabular-nums">{row.cashPct}%</TableCell>
<TableCell className="text-right tabular-nums">{row.card.toLocaleString('sv-SE')} kr</TableCell>
<TableCell className="text-right tabular-nums">{row.cardPct}%</TableCell>
<TableCell className="text-right tabular-nums">{row.swish.toLocaleString('sv-SE')} kr</TableCell>
<TableCell className="text-right tabular-nums">{row.swishPct}%</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)}
{/* Daily sales table with pagination */}
<div>
<h3 className="text-sm font-semibold mb-3">Daglig forsaljning</h3>
{dailySales.length === 0 ? (
<p className="text-sm text-muted-foreground">Ingen data importerad annu.</p>
) : (
<>
<div className="rounded-xl border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Datum</TableHead>
<TableHead className="text-right">Totalt</TableHead>
<TableHead className="text-right">Kontant</TableHead>
<TableHead className="text-right">Kort</TableHead>
<TableHead className="text-right">Swish</TableHead>
<TableHead className="text-right">Moms</TableHead>
<TableHead className="text-right">Moms %</TableHead>
<TableHead className="w-20"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{paginatedSales.map(d => {
const rowVatPct = d.total > 0
? Math.round(d.vat / d.total * 10000) / 100
: 0
return (
<TableRow key={d.date}>
<TableCell className="font-medium">{d.date}</TableCell>
<TableCell className="text-right tabular-nums">{d.total.toLocaleString('sv-SE')}</TableCell>
<TableCell className="text-right tabular-nums">{d.cash.toLocaleString('sv-SE')}</TableCell>
<TableCell className="text-right tabular-nums">{d.card.toLocaleString('sv-SE')}</TableCell>
<TableCell className="text-right tabular-nums">{d.swish.toLocaleString('sv-SE')}</TableCell>
<TableCell className="text-right tabular-nums">{d.vat.toLocaleString('sv-SE')}</TableCell>
<TableCell className="text-right tabular-nums">
<span className={rowVatPct > 0 && (rowVatPct < 20 || rowVatPct > 30) ? 'text-yellow-600 dark:text-yellow-400' : ''}>
{rowVatPct}%
</span>
</TableCell>
<TableCell>
<div className="flex items-center justify-end gap-1">
<Button variant="ghost" size="sm" onClick={() => openEdit(d)}>
<Pencil className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
<Button variant="ghost" size="sm" onClick={() => setDeleteEntry(d)}>
<Trash2 className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
</div>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between mt-4">
<p className="text-sm text-muted-foreground">
Visar {page * PAGE_SIZE + 1}-{Math.min((page + 1) * PAGE_SIZE, dailySales.length)} av {dailySales.length} rader
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPage(p => Math.max(0, p - 1))}
disabled={page === 0}
>
<ChevronLeft className="h-4 w-4 mr-1" />
Foregaende
</Button>
<span className="text-sm text-muted-foreground">
Sida {page + 1} av {totalPages}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))}
disabled={page >= totalPages - 1}
>
Nasta
<ChevronRight className="h-4 w-4 ml-1" />
</Button>
</div>
</div>
)}
</>
)}
</div>
</TabsContent>
</Tabs>
{/* Edit dialog */}
<EditEntryDialog
open={!!editEntry}
onOpenChange={open => { if (!open) setEditEntry(null) }}
title="Redigera dagskassa"
description={editEntry ? `Redigera data for ${editEntry.date}` : ''}
onSave={handleEditSave}
isSaving={isSaving}
>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="edit-date">Datum</Label>
<Input id="edit-date" type="date" value={editEntry?.date ?? ''} disabled />
</div>
<div className="space-y-2">
<Label htmlFor="edit-total">Totalt (kr)</Label>
<Input
id="edit-total"
type="number"
step="0.01"
min="0"
value={editTotal}
onChange={e => setEditTotal(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-cash">Kontant (kr)</Label>
<Input
id="edit-cash"
type="number"
step="0.01"
min="0"
value={editCash}
onChange={e => setEditCash(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-card">Kort (kr)</Label>
<Input
id="edit-card"
type="number"
step="0.01"
min="0"
value={editCard}
onChange={e => setEditCard(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-swish">Swish (kr)</Label>
<Input
id="edit-swish"
type="number"
step="0.01"
min="0"
value={editSwish}
onChange={e => setEditSwish(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-vat">Moms (kr)</Label>
<Input
id="edit-vat"
type="number"
step="0.01"
min="0"
value={editVat}
onChange={e => setEditVat(e.target.value)}
/>
</div>
</div>
{showEditWarning && (
<div className="flex items-center gap-2 rounded-lg border border-yellow-300 bg-yellow-50 p-3 text-sm text-yellow-800 dark:border-yellow-700 dark:bg-yellow-950/30 dark:text-yellow-200">
<AlertTriangle className="h-4 w-4 shrink-0" />
<span>
Kontant + Kort + Swish ({editPaymentSum.toLocaleString('sv-SE')} kr) avviker {editDiffPct}% fran Totalt ({editTotalVal.toLocaleString('sv-SE')} kr).
</span>
</div>
)}
</EditEntryDialog>
{/* Delete confirmation dialog */}
<ConfirmDeleteDialog
open={!!deleteEntry}
onOpenChange={open => { if (!open) setDeleteEntry(null) }}
title="Ta bort dagskassa"
description={deleteEntry ? `Vill du ta bort data for ${deleteEntry.date}? Atgarden kan inte angras.` : ''}
onConfirm={handleDelete}
isDeleting={isDeleting}
/>
</div>
)
}
@@ -1,14 +1,844 @@
'use client'
import { HandCoins } from 'lucide-react'
import EmptyExtensionState from '@/components/extensions/shared/EmptyExtensionState'
import { useState, useMemo, useCallback } from 'react'
import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
import { useExtensionData } from '@/lib/extensions/use-extension-data'
import KPICard from '@/components/extensions/shared/KPICard'
import DateRangeFilter from '@/components/extensions/shared/DateRangeFilter'
import MonthlyTrendTable from '@/components/extensions/shared/MonthlyTrendTable'
import DataEntryForm from '@/components/extensions/shared/DataEntryForm'
import ExtensionLoadingSkeleton from '@/components/extensions/shared/ExtensionLoadingSkeleton'
import ConfirmDeleteDialog from '@/components/extensions/shared/ConfirmDeleteDialog'
import EditEntryDialog from '@/components/extensions/shared/EditEntryDialog'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Checkbox } from '@/components/ui/checkbox'
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select'
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from '@/components/ui/table'
import { Pencil, Plus, Trash2, Download, ChevronDown, ChevronUp } from 'lucide-react'
interface Employee {
id: string
name: string
active: boolean
}
interface TipEntry {
id: string
date: string
shift: string
employeeId: string
employeeName: string
amount: number
}
type SplitMethod = 'equal' | 'hours' | 'custom'
const SHIFTS = ['Lunch', 'Kväll', 'Heldag']
export default function TipTrackingWorkspace({}: WorkspaceComponentProps) {
const now = new Date()
const [dateRange, setDateRange] = useState({
start: new Date(now.getFullYear(), now.getMonth(), 1).toISOString().slice(0, 10),
end: new Date(now.getFullYear(), now.getMonth() + 1, 0).toISOString().slice(0, 10),
})
const { data, save, remove, refresh, isLoading } = useExtensionData('restaurant', 'tip-tracking')
// ---------------------------------------------------------------------------
// Derived data
// ---------------------------------------------------------------------------
const employees = useMemo(() =>
data.filter(d => d.key.startsWith('employee:'))
.map(d => ({
id: d.key.replace('employee:', ''),
...(d.value as { name: string; active: boolean }),
}))
, [data])
const entries = useMemo(() =>
data.filter(d => d.key.startsWith('entry:'))
.map(d => ({
id: d.key,
...(d.value as Omit<TipEntry, 'id'>),
}))
.filter(e => e.date >= dateRange.start && e.date <= dateRange.end)
.sort((a, b) => b.date.localeCompare(a.date))
, [data, dateRange])
const activeEmployees = employees.filter(e => e.active)
// Settings
const settings = useMemo(() => {
const rec = data.find(d => d.key === 'settings')
return (rec?.value ?? {}) as Record<string, unknown>
}, [data])
// ---------------------------------------------------------------------------
// Register tab form state
// ---------------------------------------------------------------------------
const [entryDate, setEntryDate] = useState(now.toISOString().slice(0, 10))
const [shift, setShift] = useState(SHIFTS[0])
const [selectedEmployeeId, setEmployeeId] = useState('')
const employeeId = selectedEmployeeId || (activeEmployees.length > 0 ? activeEmployees[0].id : '')
const [amount, setAmount] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
// New employee form
const [newEmployeeName, setNewEmployeeName] = useState('')
// Edit entry dialog
const [editEntry, setEditEntry] = useState<TipEntry | null>(null)
const [editDate, setEditDate] = useState('')
const [editShift, setEditShift] = useState('')
const [editEmployeeId, setEditEmployeeId] = useState('')
const [editAmount, setEditAmount] = useState('')
const [isSavingEdit, setIsSavingEdit] = useState(false)
// Delete confirmation dialog
const [deleteKey, setDeleteKey] = useState<string | null>(null)
const [isDeleting, setIsDeleting] = useState(false)
// Edit employee dialog
const [editEmployee, setEditEmployee] = useState<Employee | null>(null)
const [editEmployeeName, setEditEmployeeName] = useState('')
const [isSavingEmployee, setIsSavingEmployee] = useState(false)
// Per-employee expanded view in overview
const [expandedEmployeeId, setExpandedEmployeeId] = useState<string | null>(null)
// Tip pool state
const [poolAmount, setPoolAmount] = useState('')
const [poolSelectedIds, setPoolSelectedIds] = useState<Set<string>>(new Set())
const [splitMethod, setSplitMethod] = useState<SplitMethod>('equal')
const [hoursMap, setHoursMap] = useState<Record<string, string>>({})
const [customPctMap, setCustomPctMap] = useState<Record<string, string>>({})
// ---------------------------------------------------------------------------
// Analytics
// ---------------------------------------------------------------------------
const totalTips = entries.reduce((s, e) => s + e.amount, 0)
const avgPerShift = entries.length > 0 ? Math.round(totalTips / entries.length) : 0
const monthlyTrend = useMemo(() => {
const map = new Map<string, number>()
for (const e of entries) {
const month = e.date.slice(0, 7)
map.set(month, (map.get(month) ?? 0) + e.amount)
}
return Array.from(map.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([month, value]) => ({ month, value }))
}, [entries])
const employeeTotals = useMemo(() => {
const map = new Map<string, { name: string; total: number; count: number }>()
for (const e of entries) {
const existing = map.get(e.employeeId) ?? { name: e.employeeName, total: 0, count: 0 }
existing.total += e.amount
existing.count++
map.set(e.employeeId, existing)
}
return Array.from(map.entries())
.map(([id, d]) => ({ id, ...d }))
.sort((a, b) => b.total - a.total)
}, [entries])
// Per-employee entries for expanded view
const expandedEmployeeEntries = useMemo(() => {
if (!expandedEmployeeId) return []
return entries
.filter(e => e.employeeId === expandedEmployeeId)
.sort((a, b) => b.date.localeCompare(a.date))
}, [entries, expandedEmployeeId])
// ---------------------------------------------------------------------------
// Tip pool calculations
// ---------------------------------------------------------------------------
const poolTotal = parseFloat(poolAmount) || 0
const poolSelectedEmployees = activeEmployees.filter(e => poolSelectedIds.has(e.id))
const poolDistribution = useMemo((): { id: string; name: string; share: number }[] => {
if (poolSelectedEmployees.length === 0 || poolTotal <= 0) return []
if (splitMethod === 'equal') {
const share = Math.round((poolTotal / poolSelectedEmployees.length) * 100) / 100
return poolSelectedEmployees.map(e => ({ id: e.id, name: e.name, share }))
}
if (splitMethod === 'hours') {
const totalHours = poolSelectedEmployees.reduce((sum, e) => {
return sum + (parseFloat(hoursMap[e.id] ?? '0') || 0)
}, 0)
if (totalHours <= 0) return poolSelectedEmployees.map(e => ({ id: e.id, name: e.name, share: 0 }))
return poolSelectedEmployees.map(e => {
const h = parseFloat(hoursMap[e.id] ?? '0') || 0
const share = Math.round((poolTotal * (h / totalHours)) * 100) / 100
return { id: e.id, name: e.name, share }
})
}
// custom
return poolSelectedEmployees.map(e => {
const pct = parseFloat(customPctMap[e.id] ?? '0') || 0
const share = Math.round((poolTotal * (pct / 100)) * 100) / 100
return { id: e.id, name: e.name, share }
})
}, [poolTotal, poolSelectedEmployees, splitMethod, hoursMap, customPctMap])
const customPctTotal = useMemo(() => {
return poolSelectedEmployees.reduce((sum, e) => {
return sum + (parseFloat(customPctMap[e.id] ?? '0') || 0)
}, 0)
}, [poolSelectedEmployees, customPctMap])
// ---------------------------------------------------------------------------
// Handlers
// ---------------------------------------------------------------------------
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
const val = parseFloat(amount)
if (isNaN(val) || val <= 0 || !employeeId) return
setIsSubmitting(true)
const emp = employees.find(em => em.id === employeeId)
const id = crypto.randomUUID()
await save(`entry:${id}`, {
date: entryDate,
shift,
employeeId,
employeeName: emp?.name ?? '',
amount: val,
})
setAmount('')
await refresh()
setIsSubmitting(false)
}
const openEditEntry = (entry: TipEntry) => {
setEditEntry(entry)
setEditDate(entry.date)
setEditShift(entry.shift)
setEditEmployeeId(entry.employeeId)
setEditAmount(String(entry.amount))
}
const handleSaveEdit = async () => {
if (!editEntry) return
const val = parseFloat(editAmount)
if (isNaN(val) || val <= 0 || !editEmployeeId) return
setIsSavingEdit(true)
const emp = employees.find(em => em.id === editEmployeeId)
await save(editEntry.id, {
date: editDate,
shift: editShift,
employeeId: editEmployeeId,
employeeName: emp?.name ?? '',
amount: val,
})
await refresh()
setIsSavingEdit(false)
}
const handleConfirmDelete = async () => {
if (!deleteKey) return
setIsDeleting(true)
await remove(deleteKey)
setIsDeleting(false)
}
const handleAddEmployee = async () => {
if (!newEmployeeName.trim()) return
const id = crypto.randomUUID()
await save(`employee:${id}`, { name: newEmployeeName.trim(), active: true })
setNewEmployeeName('')
await refresh()
}
const handleToggleEmployee = async (emp: Employee) => {
await save(`employee:${emp.id}`, { name: emp.name, active: !emp.active })
await refresh()
}
const openEditEmployee = (emp: Employee) => {
setEditEmployee(emp)
setEditEmployeeName(emp.name)
}
const handleSaveEmployee = async () => {
if (!editEmployee || !editEmployeeName.trim()) return
setIsSavingEmployee(true)
await save(`employee:${editEmployee.id}`, {
name: editEmployeeName.trim(),
active: editEmployee.active,
})
// Also update employeeName on existing entries for this employee
const empEntries = data
.filter(d => d.key.startsWith('entry:'))
.filter(d => (d.value as { employeeId?: string }).employeeId === editEmployee.id)
for (const rec of empEntries) {
const val = rec.value as Record<string, unknown>
await save(rec.key, { ...val, employeeName: editEmployeeName.trim() })
}
await refresh()
setIsSavingEmployee(false)
}
const handleTogglePooling = async () => {
const newVal = !settings.poolingEnabled
await save('settings', { ...settings, poolingEnabled: newVal })
await refresh()
}
const togglePoolEmployee = (empId: string) => {
setPoolSelectedIds(prev => {
const next = new Set(prev)
if (next.has(empId)) {
next.delete(empId)
} else {
next.add(empId)
}
return next
})
}
const toggleExpandedEmployee = (empId: string) => {
setExpandedEmployeeId(prev => prev === empId ? null : empId)
}
// ---------------------------------------------------------------------------
// CSV export
// ---------------------------------------------------------------------------
const handleExportCsv = useCallback(() => {
if (entries.length === 0) return
const header = 'Datum,Skift,Anstalld,Belopp (kr)'
const rows = entries.map(e =>
`${e.date},${e.shift},${e.employeeName.replace(/,/g, ' ')},${e.amount}`
)
const csv = [header, ...rows].join('\n')
const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `dricks_${dateRange.start}_${dateRange.end}.csv`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}, [entries, dateRange])
// ---------------------------------------------------------------------------
// Render
// ---------------------------------------------------------------------------
if (isLoading) return <ExtensionLoadingSkeleton />
export default function TipTrackingWorkspace() {
return (
<EmptyExtensionState
title="Dricksuppföljning"
description="Registrering av dricks per skift kommer snart. Du kommer kunna följa upp dricksfördelning och bokföra det korrekt."
icon={<HandCoins className="h-12 w-12 text-muted-foreground/40 mb-4" />}
/>
<div className="space-y-6">
{/* Edit entry dialog */}
<EditEntryDialog
open={editEntry !== null}
onOpenChange={open => { if (!open) setEditEntry(null) }}
title="Redigera dricksregistrering"
description="Andra uppgifterna for denna registrering."
onSave={handleSaveEdit}
isSaving={isSavingEdit}
>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Datum</Label>
<Input type="date" value={editDate} onChange={e => setEditDate(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Skift</Label>
<Select value={editShift} onValueChange={setEditShift}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{SHIFTS.map(s => <SelectItem key={s} value={s}>{s}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Anstalld</Label>
<Select value={editEmployeeId} onValueChange={setEditEmployeeId}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{activeEmployees.map(e => (
<SelectItem key={e.id} value={e.id}>{e.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Belopp (kr)</Label>
<Input type="number" step="1" min="0" value={editAmount} onChange={e => setEditAmount(e.target.value)} />
</div>
</div>
</EditEntryDialog>
{/* Delete confirmation dialog */}
<ConfirmDeleteDialog
open={deleteKey !== null}
onOpenChange={open => { if (!open) setDeleteKey(null) }}
title="Ta bort registrering"
description="Ar du saker pa att du vill ta bort denna dricksregistrering? Atgarden kan inte angras."
onConfirm={handleConfirmDelete}
isDeleting={isDeleting}
/>
{/* Edit employee dialog */}
<EditEntryDialog
open={editEmployee !== null}
onOpenChange={open => { if (!open) setEditEmployee(null) }}
title="Redigera anstalld"
description="Andra namn pa den anstallda."
onSave={handleSaveEmployee}
isSaving={isSavingEmployee}
>
<div className="space-y-2">
<Label>Namn</Label>
<Input value={editEmployeeName} onChange={e => setEditEmployeeName(e.target.value)} />
</div>
</EditEntryDialog>
<Tabs defaultValue="register">
<TabsList>
<TabsTrigger value="register">Registrera</TabsTrigger>
<TabsTrigger value="overview">Oversikt</TabsTrigger>
<TabsTrigger value="employees">Anstallda</TabsTrigger>
<TabsTrigger value="pool">Drickspool</TabsTrigger>
</TabsList>
{/* ------------------------------------------------------------------ */}
{/* Register tab */}
{/* ------------------------------------------------------------------ */}
<TabsContent value="register" className="space-y-6 mt-4">
{activeEmployees.length === 0 ? (
<div className="rounded-xl border p-6 text-center">
<p className="text-sm text-muted-foreground">
Lagg till anstallda under fliken &quot;Anstallda&quot; for att borja registrera dricks.
</p>
</div>
) : (
<DataEntryForm
title="Registrera dricks"
onSubmit={handleSubmit}
submitLabel="Registrera"
isSubmitting={isSubmitting}
>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="tip-date">Datum</Label>
<Input id="tip-date" type="date" value={entryDate} onChange={e => setEntryDate(e.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="tip-shift">Skift</Label>
<Select value={shift} onValueChange={setShift}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{SHIFTS.map(s => <SelectItem key={s} value={s}>{s}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="tip-employee">Anstalld</Label>
<Select value={employeeId} onValueChange={setEmployeeId}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{activeEmployees.map(e => (
<SelectItem key={e.id} value={e.id}>{e.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="tip-amount">Belopp (kr)</Label>
<Input id="tip-amount" type="number" step="1" min="0" placeholder="0" value={amount} onChange={e => setAmount(e.target.value)} />
</div>
</div>
</DataEntryForm>
)}
{/* Recent entries */}
<div>
<h3 className="text-sm font-semibold mb-3">Senaste registreringar</h3>
{entries.length === 0 ? (
<p className="text-sm text-muted-foreground">Inga registreringar i vald period.</p>
) : (
<div className="rounded-xl border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Datum</TableHead>
<TableHead>Skift</TableHead>
<TableHead>Anstalld</TableHead>
<TableHead className="text-right">Belopp</TableHead>
<TableHead className="w-20"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{entries.slice(0, 20).map(e => (
<TableRow key={e.id}>
<TableCell>{e.date}</TableCell>
<TableCell>{e.shift}</TableCell>
<TableCell>{e.employeeName}</TableCell>
<TableCell className="text-right tabular-nums">{e.amount.toLocaleString('sv-SE')} kr</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" onClick={() => openEditEntry(e)}>
<Pencil className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
<Button variant="ghost" size="sm" onClick={() => setDeleteKey(e.id)}>
<Trash2 className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
</TabsContent>
{/* ------------------------------------------------------------------ */}
{/* Overview tab */}
{/* ------------------------------------------------------------------ */}
<TabsContent value="overview" className="space-y-6 mt-4">
<div className="flex items-center justify-between gap-4 flex-wrap">
<DateRangeFilter onRangeChange={(start, end) => setDateRange({ start, end })} />
<Button variant="outline" size="sm" onClick={handleExportCsv} disabled={entries.length === 0}>
<Download className="h-4 w-4 mr-1" />
Exportera CSV
</Button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<KPICard label="Total dricks" value={totalTips.toLocaleString('sv-SE')} suffix="kr" />
<KPICard label="Snitt per skift" value={avgPerShift.toLocaleString('sv-SE')} suffix="kr" />
<KPICard label="Antal registreringar" value={entries.length} />
</div>
{monthlyTrend.length > 0 && (
<div>
<h3 className="text-sm font-semibold mb-3">Manadstrend</h3>
<MonthlyTrendTable rows={monthlyTrend} valueLabel="Total dricks" />
</div>
)}
{employeeTotals.length > 0 && (
<div>
<h3 className="text-sm font-semibold mb-3">Per anstalld</h3>
<div className="rounded-xl border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Anstalld</TableHead>
<TableHead className="text-right">Total</TableHead>
<TableHead className="text-right">Antal skift</TableHead>
<TableHead className="text-right">Snitt/skift</TableHead>
<TableHead className="w-10"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{employeeTotals.map(e => {
const isExpanded = expandedEmployeeId === e.id
return (
<TableRow key={e.id} className="group">
<TableCell>
<button
type="button"
className="flex items-center gap-1 font-medium text-left hover:underline"
onClick={() => toggleExpandedEmployee(e.id)}
>
{isExpanded
? <ChevronUp className="h-3.5 w-3.5 text-muted-foreground" />
: <ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
}
{e.name}
</button>
</TableCell>
<TableCell className="text-right tabular-nums">{e.total.toLocaleString('sv-SE')} kr</TableCell>
<TableCell className="text-right tabular-nums">{e.count}</TableCell>
<TableCell className="text-right tabular-nums">{Math.round(e.total / e.count).toLocaleString('sv-SE')} kr</TableCell>
<TableCell />
</TableRow>
)
})}
</TableBody>
</Table>
</div>
{/* Expanded employee detail */}
{expandedEmployeeId && expandedEmployeeEntries.length > 0 && (
<div className="mt-3 ml-4">
<h4 className="text-sm font-medium mb-2">
Drickshistorik for {employeeTotals.find(e => e.id === expandedEmployeeId)?.name}
</h4>
<div className="rounded-xl border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Datum</TableHead>
<TableHead>Skift</TableHead>
<TableHead className="text-right">Belopp</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{expandedEmployeeEntries.map(e => (
<TableRow key={e.id}>
<TableCell>{e.date}</TableCell>
<TableCell>{e.shift}</TableCell>
<TableCell className="text-right tabular-nums">{e.amount.toLocaleString('sv-SE')} kr</TableCell>
</TableRow>
))}
<TableRow className="font-medium border-t-2">
<TableCell colSpan={2}>Totalt / Snitt</TableCell>
<TableCell className="text-right tabular-nums">
{expandedEmployeeEntries.reduce((s, e) => s + e.amount, 0).toLocaleString('sv-SE')} kr
{' '}({Math.round(expandedEmployeeEntries.reduce((s, e) => s + e.amount, 0) / expandedEmployeeEntries.length).toLocaleString('sv-SE')} kr/skift)
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</div>
)}
</div>
)}
</TabsContent>
{/* ------------------------------------------------------------------ */}
{/* Employees tab */}
{/* ------------------------------------------------------------------ */}
<TabsContent value="employees" className="space-y-6 mt-4">
<div className="flex gap-2">
<Input
placeholder="Namn pa anstalld"
value={newEmployeeName}
onChange={e => setNewEmployeeName(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleAddEmployee()}
className="max-w-xs"
/>
<Button size="sm" onClick={handleAddEmployee} disabled={!newEmployeeName.trim()}>
<Plus className="h-4 w-4 mr-1" /> Lagg till
</Button>
</div>
{employees.length === 0 ? (
<p className="text-sm text-muted-foreground">Inga anstallda tillagda annu.</p>
) : (
<div className="rounded-xl border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Namn</TableHead>
<TableHead>Status</TableHead>
<TableHead className="w-32"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{employees.map(emp => (
<TableRow key={emp.id}>
<TableCell className="font-medium">{emp.name}</TableCell>
<TableCell>
<Badge variant={emp.active ? 'default' : 'secondary'}>
{emp.active ? 'Aktiv' : 'Inaktiv'}
</Badge>
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" onClick={() => openEditEmployee(emp)}>
<Pencil className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
<Button variant="ghost" size="sm" onClick={() => handleToggleEmployee(emp)}>
{emp.active ? 'Inaktivera' : 'Aktivera'}
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</TabsContent>
{/* ------------------------------------------------------------------ */}
{/* Tip pool tab */}
{/* ------------------------------------------------------------------ */}
<TabsContent value="pool" className="space-y-6 mt-4">
<div className="rounded-xl border p-4 space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold">Drickspool</h3>
<p className="text-sm text-muted-foreground">
Fordela dricks fran en gemensam pool till utvalda anstallda.
</p>
</div>
<Button
variant={settings.poolingEnabled ? 'default' : 'outline'}
size="sm"
onClick={handleTogglePooling}
>
{settings.poolingEnabled ? 'Aktiverad' : 'Inaktiverad'}
</Button>
</div>
{Boolean(settings.poolingEnabled) && (
<div className="space-y-6">
{/* Pool amount */}
<div className="space-y-2">
<Label htmlFor="pool-amount">Total poolbelopp (kr)</Label>
<Input
id="pool-amount"
type="number"
step="1"
min="0"
placeholder="0"
value={poolAmount}
onChange={e => setPoolAmount(e.target.value)}
className="max-w-xs"
/>
</div>
{/* Select employees */}
<div className="space-y-2">
<Label>Valj anstallda</Label>
{activeEmployees.length === 0 ? (
<p className="text-sm text-muted-foreground">Inga aktiva anstallda.</p>
) : (
<div className="space-y-2">
{activeEmployees.map(emp => (
<div key={emp.id} className="flex items-center gap-2">
<Checkbox
id={`pool-emp-${emp.id}`}
checked={poolSelectedIds.has(emp.id)}
onCheckedChange={() => togglePoolEmployee(emp.id)}
/>
<label htmlFor={`pool-emp-${emp.id}`} className="text-sm cursor-pointer">
{emp.name}
</label>
</div>
))}
</div>
)}
</div>
{/* Split method */}
{poolSelectedEmployees.length > 0 && (
<div className="space-y-2">
<Label>Fordelningsmetod</Label>
<Select value={splitMethod} onValueChange={v => setSplitMethod(v as SplitMethod)}>
<SelectTrigger className="max-w-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="equal">Lika</SelectItem>
<SelectItem value="hours">Per timmar</SelectItem>
<SelectItem value="custom">Anpassad %</SelectItem>
</SelectContent>
</Select>
</div>
)}
{/* Hours input (when split by hours) */}
{splitMethod === 'hours' && poolSelectedEmployees.length > 0 && (
<div className="space-y-3">
<Label>Timmar per anstalld</Label>
{poolSelectedEmployees.map(emp => (
<div key={emp.id} className="flex items-center gap-3">
<span className="text-sm w-32 truncate">{emp.name}</span>
<Input
type="number"
step="0.5"
min="0"
placeholder="0"
className="max-w-24"
value={hoursMap[emp.id] ?? ''}
onChange={e => setHoursMap(prev => ({ ...prev, [emp.id]: e.target.value }))}
/>
<span className="text-sm text-muted-foreground">timmar</span>
</div>
))}
</div>
)}
{/* Custom percentage input */}
{splitMethod === 'custom' && poolSelectedEmployees.length > 0 && (
<div className="space-y-3">
<Label>Procent per anstalld</Label>
{poolSelectedEmployees.map(emp => (
<div key={emp.id} className="flex items-center gap-3">
<span className="text-sm w-32 truncate">{emp.name}</span>
<Input
type="number"
step="1"
min="0"
max="100"
placeholder="0"
className="max-w-24"
value={customPctMap[emp.id] ?? ''}
onChange={e => setCustomPctMap(prev => ({ ...prev, [emp.id]: e.target.value }))}
/>
<span className="text-sm text-muted-foreground">%</span>
</div>
))}
<p className={`text-xs ${Math.abs(customPctTotal - 100) < 0.01 ? 'text-muted-foreground' : 'text-red-600'}`}>
Summa: {customPctTotal}% {Math.abs(customPctTotal - 100) >= 0.01 && '(maste vara 100%)'}
</p>
</div>
)}
{/* Distribution result */}
{poolDistribution.length > 0 && poolTotal > 0 && (
<div>
<h4 className="text-sm font-semibold mb-2">Fordelningsresultat</h4>
<div className="rounded-xl border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Anstalld</TableHead>
<TableHead className="text-right">Andel (kr)</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{poolDistribution.map(d => (
<TableRow key={d.id}>
<TableCell className="font-medium">{d.name}</TableCell>
<TableCell className="text-right tabular-nums">{d.share.toLocaleString('sv-SE')} kr</TableCell>
</TableRow>
))}
<TableRow className="font-medium border-t-2">
<TableCell>Totalt</TableCell>
<TableCell className="text-right tabular-nums">
{poolDistribution.reduce((s, d) => s + d.share, 0).toLocaleString('sv-SE')} kr
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</div>
)}
</div>
)}
</div>
</TabsContent>
</Tabs>
</div>
)
}
@@ -0,0 +1,63 @@
'use client'
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Loader2, AlertTriangle } from 'lucide-react'
interface ConfirmDeleteDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
title?: string
description?: string
onConfirm: () => void | Promise<void>
isDeleting?: boolean
}
export default function ConfirmDeleteDialog({
open,
onOpenChange,
title = 'Bekrafta borttagning',
description = 'Ar du saker pa att du vill ta bort detta? Atgarden kan inte angras.',
onConfirm,
isDeleting = false,
}: ConfirmDeleteDialogProps) {
const handleConfirm = async () => {
await onConfirm()
onOpenChange(false)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-sm">
<DialogHeader>
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-red-100 dark:bg-red-950/30">
<AlertTriangle className="h-5 w-5 text-red-600 dark:text-red-400" />
</div>
<div>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</div>
</div>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isDeleting}>
Avbryt
</Button>
<Button variant="destructive" onClick={handleConfirm} disabled={isDeleting}>
{isDeleting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Tar bort...
</>
) : (
'Ta bort'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,231 @@
'use client'
import { useState, useCallback } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select'
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from '@/components/ui/table'
import { Upload, FileText, Check } from 'lucide-react'
interface CsvImportWizardProps {
targetFields: { key: string; label: string; required?: boolean }[]
defaultMappings?: Record<string, string>
onImport: (rows: Record<string, string>[]) => Promise<void>
className?: string
}
function parseCsv(text: string): { headers: string[]; rows: string[][] } {
const lines = text.split(/\r?\n/).filter(line => line.trim())
if (lines.length === 0) return { headers: [], rows: [] }
const separator = lines[0].includes(';') ? ';' : ','
const headers = lines[0].split(separator).map(h => h.trim().replace(/^"(.*)"$/, '$1'))
const rows = lines.slice(1).map(line =>
line.split(separator).map(cell => cell.trim().replace(/^"(.*)"$/, '$1'))
)
return { headers, rows }
}
export default function CsvImportWizard({
targetFields,
defaultMappings,
onImport,
className,
}: CsvImportWizardProps) {
const [step, setStep] = useState<1 | 2 | 3>(1)
const [headers, setHeaders] = useState<string[]>([])
const [rows, setRows] = useState<string[][]>([])
const [mappings, setMappings] = useState<Record<string, string>>({})
const [isImporting, setIsImporting] = useState(false)
const [importCount, setImportCount] = useState(0)
const [fileName, setFileName] = useState('')
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
setFileName(file.name)
const reader = new FileReader()
reader.onload = (ev) => {
const text = ev.target?.result as string
const parsed = parseCsv(text)
setHeaders(parsed.headers)
setRows(parsed.rows)
// Auto-map using defaults
const autoMappings: Record<string, string> = {}
for (const field of targetFields) {
const defaultCsv = defaultMappings?.[field.key]
if (defaultCsv && parsed.headers.includes(defaultCsv)) {
autoMappings[field.key] = defaultCsv
} else {
const match = parsed.headers.find(
h => h.toLowerCase() === field.key.toLowerCase() ||
h.toLowerCase() === field.label.toLowerCase()
)
if (match) autoMappings[field.key] = match
}
}
setMappings(autoMappings)
setStep(2)
}
reader.readAsText(file)
}, [targetFields, defaultMappings])
const handleImport = async () => {
setIsImporting(true)
try {
const mappedRows = rows.map(row => {
const obj: Record<string, string> = {}
for (const [fieldKey, csvCol] of Object.entries(mappings)) {
const colIdx = headers.indexOf(csvCol)
if (colIdx >= 0 && row[colIdx]) {
obj[fieldKey] = row[colIdx]
}
}
return obj
}).filter(row => Object.keys(row).length > 0)
await onImport(mappedRows)
setImportCount(mappedRows.length)
setStep(3)
} finally {
setIsImporting(false)
}
}
const reset = () => {
setStep(1)
setHeaders([])
setRows([])
setMappings({})
setFileName('')
setImportCount(0)
}
const requiredFieldsMapped = targetFields
.filter(f => f.required)
.every(f => mappings[f.key])
return (
<Card className={className}>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
{step === 1 && <><Upload className="h-4 w-4" /> Steg 1: Valj fil</>}
{step === 2 && <><FileText className="h-4 w-4" /> Steg 2: Kolumnmappning</>}
{step === 3 && <><Check className="h-4 w-4" /> Import klar</>}
</CardTitle>
</CardHeader>
<CardContent>
{step === 1 && (
<div className="space-y-4">
<div className="border-2 border-dashed rounded-lg p-8 text-center">
<Upload className="h-8 w-8 mx-auto mb-2 text-muted-foreground" />
<p className="text-sm text-muted-foreground mb-3">
Valj en CSV-fil att importera
</p>
<Label htmlFor="csv-upload" className="cursor-pointer">
<Button variant="outline" size="sm" asChild>
<span>Valj fil</span>
</Button>
</Label>
<input
id="csv-upload"
type="file"
accept=".csv,.txt"
onChange={handleFileSelect}
className="hidden"
/>
</div>
</div>
)}
{step === 2 && (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
{fileName} - {rows.length} rader hittades. Mappa kolumner:
</p>
<div className="space-y-3">
{targetFields.map(field => (
<div key={field.key} className="flex items-center gap-3">
<Label className="w-36 text-sm shrink-0">
{field.label}{field.required && ' *'}
</Label>
<Select
value={mappings[field.key] ?? ''}
onValueChange={(val) => setMappings(prev => ({ ...prev, [field.key]: val }))}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Valj kolumn..." />
</SelectTrigger>
<SelectContent>
{headers.map(h => (
<SelectItem key={h} value={h}>{h}</SelectItem>
))}
</SelectContent>
</Select>
</div>
))}
</div>
{rows.length > 0 && (
<div className="rounded-lg border overflow-auto max-h-48">
<Table>
<TableHeader>
<TableRow>
{headers.map(h => (
<TableHead key={h} className="text-xs whitespace-nowrap">{h}</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{rows.slice(0, 5).map((row, i) => (
<TableRow key={i}>
{row.map((cell, j) => (
<TableCell key={j} className="text-xs whitespace-nowrap">{cell}</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={reset}>
Tillbaka
</Button>
<Button
size="sm"
onClick={handleImport}
disabled={!requiredFieldsMapped || isImporting}
>
{isImporting ? 'Importerar...' : `Importera ${rows.length} rader`}
</Button>
</div>
</div>
)}
{step === 3 && (
<div className="text-center py-4">
<div className="inline-flex items-center justify-center w-12 h-12 rounded-full bg-green-100 mb-3">
<Check className="h-6 w-6 text-green-600" />
</div>
<p className="font-medium">{importCount} rader importerades</p>
<p className="text-sm text-muted-foreground mt-1">
Fran {fileName}
</p>
<Button variant="outline" size="sm" onClick={reset} className="mt-4">
Importera fler
</Button>
</div>
)}
</CardContent>
</Card>
)
}
@@ -0,0 +1,62 @@
'use client'
import { ReactNode } from 'react'
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Loader2 } from 'lucide-react'
interface EditEntryDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
title: string
description?: string
onSave: () => void | Promise<void>
isSaving?: boolean
children: ReactNode
}
export default function EditEntryDialog({
open,
onOpenChange,
title,
description,
onSave,
isSaving = false,
children,
}: EditEntryDialogProps) {
const handleSave = async () => {
await onSave()
onOpenChange(false)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
{description && <DialogDescription>{description}</DialogDescription>}
</DialogHeader>
<div className="space-y-4">
{children}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSaving}>
Avbryt
</Button>
<Button onClick={handleSave} disabled={isSaving}>
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Sparar...
</>
) : (
'Spara'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,74 @@
'use client'
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from '@/components/ui/table'
import { cn } from '@/lib/utils'
interface MonthlyRow {
month: string
value: number
label?: string
}
interface MonthlyTrendTableProps {
rows: MonthlyRow[]
valueLabel?: string
valueSuffix?: string
formatValue?: (v: number) => string
className?: string
}
export default function MonthlyTrendTable({
rows,
valueLabel = 'Belopp',
valueSuffix = 'kr',
formatValue,
className,
}: MonthlyTrendTableProps) {
if (rows.length === 0) {
return (
<div className={cn('rounded-xl border p-6 text-center text-sm text-muted-foreground', className)}>
Ingen data att visa
</div>
)
}
const maxValue = Math.max(...rows.map(r => Math.abs(r.value)), 1)
const fmt = formatValue ?? ((v: number) => v.toLocaleString('sv-SE'))
return (
<div className={cn('rounded-xl border', className)}>
<Table>
<TableHeader>
<TableRow>
<TableHead>Period</TableHead>
<TableHead className="text-right">{valueLabel}</TableHead>
<TableHead className="w-[40%]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => {
const barWidth = Math.round((Math.abs(row.value) / maxValue) * 100)
return (
<TableRow key={row.month}>
<TableCell className="font-medium">{row.label ?? row.month}</TableCell>
<TableCell className="text-right tabular-nums">
{fmt(row.value)} {valueSuffix}
</TableCell>
<TableCell>
<div className="h-4 w-full rounded-sm bg-muted overflow-hidden">
<div
className="h-full rounded-sm bg-primary/60 transition-all"
style={{ width: `${barWidth}%` }}
/>
</div>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
)
}
@@ -0,0 +1,72 @@
'use client'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Settings } from 'lucide-react'
import { useState } from 'react'
interface SetupField {
key: string
label: string
type?: 'number' | 'text'
placeholder?: string
}
interface SetupPromptProps {
title: string
description: string
fields: SetupField[]
onSave: (values: Record<string, string>) => Promise<void>
}
export default function SetupPrompt({ title, description, fields, onSave }: SetupPromptProps) {
const [values, setValues] = useState<Record<string, string>>({})
const [isSaving, setIsSaving] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setIsSaving(true)
try {
await onSave(values)
} finally {
setIsSaving(false)
}
}
const allFilled = fields.every(f => values[f.key]?.trim())
return (
<div className="flex items-center justify-center py-12">
<Card className="w-full max-w-md">
<CardContent className="pt-6">
<div className="text-center mb-6">
<div className="inline-flex items-center justify-center w-12 h-12 rounded-full bg-muted mb-3">
<Settings className="h-6 w-6 text-muted-foreground" />
</div>
<h3 className="font-semibold">{title}</h3>
<p className="text-sm text-muted-foreground mt-1">{description}</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
{fields.map(field => (
<div key={field.key} className="space-y-2">
<Label htmlFor={`setup-${field.key}`}>{field.label}</Label>
<Input
id={`setup-${field.key}`}
type={field.type ?? 'text'}
placeholder={field.placeholder}
value={values[field.key] ?? ''}
onChange={e => setValues(prev => ({ ...prev, [field.key]: e.target.value }))}
/>
</div>
))}
<Button type="submit" className="w-full" disabled={!allFilled || isSaving}>
{isSaving ? 'Sparar...' : 'Kom igang'}
</Button>
</form>
</CardContent>
</Card>
</div>
)
}
@@ -1,14 +1,965 @@
'use client'
import { Clock } from 'lucide-react'
import EmptyExtensionState from '@/components/extensions/shared/EmptyExtensionState'
import { useState, useMemo, useCallback } from 'react'
import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
import { useExtensionData } from '@/lib/extensions/use-extension-data'
import KPICard from '@/components/extensions/shared/KPICard'
import MonthlyTrendTable from '@/components/extensions/shared/MonthlyTrendTable'
import DataEntryForm from '@/components/extensions/shared/DataEntryForm'
import SetupPrompt from '@/components/extensions/shared/SetupPrompt'
import ExtensionLoadingSkeleton from '@/components/extensions/shared/ExtensionLoadingSkeleton'
import ConfirmDeleteDialog from '@/components/extensions/shared/ConfirmDeleteDialog'
import EditEntryDialog from '@/components/extensions/shared/EditEntryDialog'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
import { Switch } from '@/components/ui/switch'
import { Badge } from '@/components/ui/badge'
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select'
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from '@/components/ui/table'
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogFooter, DialogDescription,
} from '@/components/ui/dialog'
import { Pencil, Plus, Trash2, Settings, ChevronLeft, ChevronRight, Archive, CheckCircle } from 'lucide-react'
// --- Types ---
type ProjectStatus = 'active' | 'completed' | 'archived'
interface TimeEntry {
id: string
date: string
projectId: string
projectName: string
hours: number
billable: boolean
description: string
}
interface Project {
id: string
name: string
active: boolean
client?: string
hourlyRate?: number
status?: ProjectStatus
}
// --- Helpers ---
function getMonday(d: Date): Date {
const copy = new Date(d)
const day = copy.getDay()
const diff = day === 0 ? -6 : 1 - day
copy.setDate(copy.getDate() + diff)
copy.setHours(0, 0, 0, 0)
return copy
}
function addDays(d: Date, n: number): Date {
const copy = new Date(d)
copy.setDate(copy.getDate() + n)
return copy
}
function formatDateStr(d: Date): string {
return d.toISOString().slice(0, 10)
}
const DAY_LABELS = ['Man', 'Tis', 'Ons', 'Tor', 'Fre', 'Lor', 'Son']
function getProjectStatus(p: { active: boolean; status?: ProjectStatus }): ProjectStatus {
return p.status ?? (p.active ? 'active' : 'completed')
}
function getEffectiveRate(project: Project | undefined, globalRate: number): number {
if (project?.hourlyRate && project.hourlyRate > 0) return project.hourlyRate
return globalRate
}
function statusLabel(status: ProjectStatus): string {
switch (status) {
case 'active': return 'Aktiv'
case 'completed': return 'Avslutad'
case 'archived': return 'Arkiverad'
}
}
function statusBadgeVariant(status: ProjectStatus): 'default' | 'secondary' | 'outline' {
switch (status) {
case 'active': return 'default'
case 'completed': return 'secondary'
case 'archived': return 'outline'
}
}
// --- Component ---
export default function BillableHoursWorkspace({}: WorkspaceComponentProps) {
const { data, save, remove, refresh, isLoading } = useExtensionData('tech', 'billable-hours')
const settings = data.find(d => d.key === 'settings')?.value as { hourlyRate?: number } | undefined
const hourlyRate = settings?.hourlyRate ?? 0
// --- Derived data ---
const projects = useMemo(() =>
data.filter(d => d.key.startsWith('project:'))
.map(d => ({
id: d.key.replace('project:', ''),
...(d.value as Omit<Project, 'id'>),
}))
, [data])
const entries = useMemo(() =>
data.filter(d => d.key.startsWith('entry:'))
.map(d => ({ id: d.key.replace('entry:', ''), ...(d.value as Omit<TimeEntry, 'id'>) }))
.sort((a, b) => b.date.localeCompare(a.date))
, [data])
// --- Form state ---
const todayStr = useMemo(() => new Date().toISOString().slice(0, 10), [])
const currentMonth = useMemo(() => new Date().toISOString().slice(0, 7), [])
const [entryDate, setEntryDate] = useState(todayStr)
const [selectedProjectId, setProjectId] = useState('')
const activeProjects = projects.filter(p => getProjectStatus(p) === 'active')
const projectId = selectedProjectId || (activeProjects.length > 0 ? activeProjects[0].id : '')
const [hours, setHours] = useState('')
const [billable, setBillable] = useState(true)
const [description, setDescription] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
// New project dialog
const [newProjectName, setNewProjectName] = useState('')
const [newProjectClient, setNewProjectClient] = useState('')
const [newProjectRate, setNewProjectRate] = useState('')
const [showNewProject, setShowNewProject] = useState(false)
// Edit entry dialog
const [editEntry, setEditEntry] = useState<TimeEntry | null>(null)
const [editDate, setEditDate] = useState('')
const [editProjectId, setEditProjectId] = useState('')
const [editHours, setEditHours] = useState('')
const [editBillable, setEditBillable] = useState(true)
const [editDescription, setEditDescription] = useState('')
const [isSavingEdit, setIsSavingEdit] = useState(false)
// Delete confirm
const [deleteEntryId, setDeleteEntryId] = useState<string | null>(null)
const [isDeleting, setIsDeleting] = useState(false)
// Show archived toggle
const [showArchived, setShowArchived] = useState(false)
// Settings dialog
const [showSettings, setShowSettings] = useState(false)
const [settingsRate, setSettingsRate] = useState('')
// Weekly view state
const [weekStart, setWeekStart] = useState(() => getMonday(new Date()))
const [weekCellProject, setWeekCellProject] = useState<string | null>(null)
const [weekCellDay, setWeekCellDay] = useState<number | null>(null)
const [weekCellHours, setWeekCellHours] = useState('')
// --- KPI calculations ---
const monthEntries = useMemo(() =>
entries.filter(e => e.date.startsWith(currentMonth))
, [entries, currentMonth])
const totalHours = monthEntries.reduce((s, e) => s + e.hours, 0)
const billableHours = monthEntries.filter(e => e.billable).reduce((s, e) => s + e.hours, 0)
const utilization = totalHours > 0 ? Math.round((billableHours / totalHours) * 100) : 0
const effectiveRate = totalHours > 0 ? Math.round((billableHours * hourlyRate) / totalHours) : 0
// Today's entries
const todayEntries = entries.filter(e => e.date === todayStr)
// Monthly trend (utilization %)
const monthlyTrend = useMemo(() => {
const map = new Map<string, { total: number; billable: number }>()
for (const e of entries) {
const month = e.date.slice(0, 7)
const existing = map.get(month) ?? { total: 0, billable: 0 }
existing.total += e.hours
if (e.billable) existing.billable += e.hours
map.set(month, existing)
}
return Array.from(map.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([month, d]) => ({
month,
value: d.total > 0 ? Math.round((d.billable / d.total) * 100) : 0,
}))
}, [entries])
// Period summary: monthly totals for billable/non-billable + revenue
const periodSummary = useMemo(() => {
const map = new Map<string, { billable: number; nonBillable: number; revenue: number }>()
for (const e of entries) {
const month = e.date.slice(0, 7)
const existing = map.get(month) ?? { billable: 0, nonBillable: 0, revenue: 0 }
if (e.billable) {
existing.billable += e.hours
const proj = projects.find(p => p.id === e.projectId)
const rate = getEffectiveRate(proj, hourlyRate)
existing.revenue += Math.round(e.hours * rate * 100) / 100
} else {
existing.nonBillable += e.hours
}
map.set(month, existing)
}
return Array.from(map.entries())
.sort(([a], [b]) => b.localeCompare(a))
.map(([month, d]) => ({ month, ...d }))
}, [entries, projects, hourlyRate])
// Per-project stats
const projectStats = useMemo(() => {
const cm = new Date().toISOString().slice(0, 7)
const filtered = entries.filter(e => e.date.startsWith(cm))
const map = new Map<string, { total: number; billable: number }>()
for (const e of filtered) {
const existing = map.get(e.projectId) ?? { total: 0, billable: 0 }
existing.total += e.hours
if (e.billable) existing.billable += e.hours
map.set(e.projectId, existing)
}
return projects.map(p => {
const stats = map.get(p.id) ?? { total: 0, billable: 0 }
return {
...p,
...stats,
effectiveStatus: getProjectStatus(p),
utilization: stats.total > 0 ? Math.round((stats.billable / stats.total) * 100) : 0,
}
})
}, [projects, entries])
// Weekly grid data
const weekDays = useMemo(() =>
Array.from({ length: 7 }, (_, i) => formatDateStr(addDays(weekStart, i)))
, [weekStart])
const weekLabel = useMemo(() => {
const end = addDays(weekStart, 6)
const startStr = weekStart.toLocaleDateString('sv-SE', { day: 'numeric', month: 'short' })
const endStr = end.toLocaleDateString('sv-SE', { day: 'numeric', month: 'short' })
return `${startStr} - ${endStr}`
}, [weekStart])
const weekGrid = useMemo(() => {
const weekDateSet = new Set(weekDays)
const weekEntries = entries.filter(e => weekDateSet.has(e.date))
const grid = new Map<string, Map<number, number>>()
for (const p of activeProjects) {
const dayMap = new Map<number, number>()
for (let i = 0; i < 7; i++) dayMap.set(i, 0)
grid.set(p.id, dayMap)
}
for (const e of weekEntries) {
const dayIndex = weekDays.indexOf(e.date)
if (dayIndex < 0) continue
const existing = grid.get(e.projectId)
if (existing) {
existing.set(dayIndex, (existing.get(dayIndex) ?? 0) + e.hours)
}
}
return grid
}, [activeProjects, entries, weekDays])
const weekDayTotals = useMemo(() => {
const totals = Array(7).fill(0)
for (const dayMap of weekGrid.values()) {
for (let i = 0; i < 7; i++) {
totals[i] += dayMap.get(i) ?? 0
}
}
return totals as number[]
}, [weekGrid])
const weekProjectTotals = useMemo(() => {
const map = new Map<string, number>()
for (const [projId, dayMap] of weekGrid.entries()) {
let total = 0
for (const h of dayMap.values()) total += h
map.set(projId, total)
}
return map
}, [weekGrid])
const weekGrandTotal = weekDayTotals.reduce((s, v) => s + v, 0)
// --- Handlers ---
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
const h = parseFloat(hours)
if (isNaN(h) || h <= 0 || !projectId) return
setIsSubmitting(true)
const proj = projects.find(p => p.id === projectId)
const id = crypto.randomUUID()
await save(`entry:${id}`, {
date: entryDate,
projectId,
projectName: proj?.name ?? '',
hours: h,
billable,
description,
})
setHours('')
setDescription('')
setBillable(true)
await refresh()
setIsSubmitting(false)
}
const handleAddProject = async () => {
if (!newProjectName.trim()) return
const id = crypto.randomUUID()
const rateVal = parseFloat(newProjectRate)
await save(`project:${id}`, {
name: newProjectName.trim(),
active: true,
status: 'active' as ProjectStatus,
client: newProjectClient.trim() || undefined,
hourlyRate: (!isNaN(rateVal) && rateVal > 0) ? rateVal : undefined,
})
setNewProjectName('')
setNewProjectClient('')
setNewProjectRate('')
setShowNewProject(false)
await refresh()
}
const handleDeleteEntry = async () => {
if (!deleteEntryId) return
setIsDeleting(true)
await remove(`entry:${deleteEntryId}`)
await refresh()
setIsDeleting(false)
setDeleteEntryId(null)
}
const openEditEntry = useCallback((entry: TimeEntry) => {
setEditEntry(entry)
setEditDate(entry.date)
setEditProjectId(entry.projectId)
setEditHours(String(entry.hours))
setEditBillable(entry.billable)
setEditDescription(entry.description)
}, [])
const handleSaveEdit = async () => {
if (!editEntry) return
const h = parseFloat(editHours)
if (isNaN(h) || h <= 0 || !editProjectId) return
setIsSavingEdit(true)
const proj = projects.find(p => p.id === editProjectId)
await save(`entry:${editEntry.id}`, {
date: editDate,
projectId: editProjectId,
projectName: proj?.name ?? editEntry.projectName,
hours: h,
billable: editBillable,
description: editDescription,
})
await refresh()
setIsSavingEdit(false)
setEditEntry(null)
}
const handleProjectStatusChange = async (projectId: string, newStatus: ProjectStatus) => {
const proj = projects.find(p => p.id === projectId)
if (!proj) return
await save(`project:${projectId}`, {
name: proj.name,
active: newStatus === 'active',
status: newStatus,
client: proj.client,
hourlyRate: proj.hourlyRate,
})
await refresh()
}
const handleSetup = async (values: Record<string, string>) => {
await save('settings', { hourlyRate: parseFloat(values.hourlyRate) || 0 })
}
const handleSaveSettings = async () => {
const rate = parseFloat(settingsRate)
if (isNaN(rate) || rate <= 0) return
await save('settings', { hourlyRate: rate })
await refresh()
setShowSettings(false)
}
const handleWeekCellClick = (projId: string, dayIndex: number) => {
setWeekCellProject(projId)
setWeekCellDay(dayIndex)
setWeekCellHours('')
}
const handleWeekCellSubmit = async () => {
if (weekCellProject === null || weekCellDay === null) return
const h = parseFloat(weekCellHours)
if (isNaN(h) || h <= 0) {
setWeekCellProject(null)
setWeekCellDay(null)
return
}
const proj = projects.find(p => p.id === weekCellProject)
const id = crypto.randomUUID()
await save(`entry:${id}`, {
date: weekDays[weekCellDay],
projectId: weekCellProject,
projectName: proj?.name ?? '',
hours: h,
billable: true,
description: '',
})
await refresh()
setWeekCellProject(null)
setWeekCellDay(null)
setWeekCellHours('')
}
const handleWeekCellKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault()
handleWeekCellSubmit()
} else if (e.key === 'Escape') {
setWeekCellProject(null)
setWeekCellDay(null)
}
}
// --- Render ---
if (isLoading) return <ExtensionLoadingSkeleton />
if (!hourlyRate) {
return (
<SetupPrompt
title="Konfigurera timpris"
description="Ange ditt timpris for att borja spara debiterbar tid."
fields={[{ key: 'hourlyRate', label: 'Timpris (kr/h)', type: 'number', placeholder: 'T.ex. 1000' }]}
onSave={handleSetup}
/>
)
}
const visibleProjectStats = showArchived
? projectStats
: projectStats.filter(p => p.effectiveStatus !== 'archived')
export default function BillableHoursWorkspace() {
return (
<EmptyExtensionState
title="Debiterbar tid"
description="Tidsrapportering och uppföljning av debiterbara timmar kommer snart. Du kommer kunna logga tid per kund och projekt."
icon={<Clock className="h-12 w-12 text-muted-foreground/40 mb-4" />}
/>
<div className="space-y-6">
{/* Settings gear button */}
<div className="flex justify-end">
<Dialog open={showSettings} onOpenChange={(open) => {
setShowSettings(open)
if (open) setSettingsRate(String(hourlyRate))
}}>
<DialogTrigger asChild>
<Button variant="ghost" size="sm">
<Settings className="h-4 w-4 mr-1" />
Installningar
</Button>
</DialogTrigger>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Installningar</DialogTitle>
<DialogDescription>Andra ditt globala timpris.</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Timpris (kr/h)</Label>
<Input
type="number"
min="0"
value={settingsRate}
onChange={e => setSettingsRate(e.target.value)}
placeholder="T.ex. 1000"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowSettings(false)}>Avbryt</Button>
<Button onClick={handleSaveSettings} disabled={!settingsRate || parseFloat(settingsRate) <= 0}>
Spara
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
<Tabs defaultValue="timesheet">
<TabsList>
<TabsTrigger value="timesheet">Tidrapport</TabsTrigger>
<TabsTrigger value="weekly">Veckorapport</TabsTrigger>
<TabsTrigger value="overview">Oversikt</TabsTrigger>
<TabsTrigger value="projects">Projekt</TabsTrigger>
</TabsList>
{/* --- Timesheet Tab --- */}
<TabsContent value="timesheet" className="space-y-6 mt-4">
{activeProjects.length === 0 ? (
<div className="rounded-xl border p-6 text-center">
<p className="text-sm text-muted-foreground">
Lagg till projekt under fliken &quot;Projekt&quot; for att borja rapportera tid.
</p>
</div>
) : (
<DataEntryForm
title="Registrera tid"
onSubmit={handleSubmit}
submitLabel="Registrera"
isSubmitting={isSubmitting}
>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Datum</Label>
<Input type="date" value={entryDate} onChange={e => setEntryDate(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Projekt</Label>
<Select value={projectId} onValueChange={setProjectId}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{activeProjects.map(p => <SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Timmar</Label>
<Input type="number" step="0.25" min="0" placeholder="0" value={hours} onChange={e => setHours(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Beskrivning</Label>
<Input placeholder="Vad jobbade du med?" value={description} onChange={e => setDescription(e.target.value)} />
</div>
</div>
<div className="flex items-center gap-2">
<Switch checked={billable} onCheckedChange={setBillable} id="billable-switch" />
<Label htmlFor="billable-switch" className="text-sm">Debiterbar</Label>
</div>
</DataEntryForm>
)}
{/* Today's entries */}
<div>
<h3 className="text-sm font-semibold mb-3">Idag ({todayStr})</h3>
{todayEntries.length === 0 ? (
<p className="text-sm text-muted-foreground">Ingen tid registrerad idag.</p>
) : (
<div className="rounded-xl border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Projekt</TableHead>
<TableHead className="text-right">Timmar</TableHead>
<TableHead>Typ</TableHead>
<TableHead>Beskrivning</TableHead>
<TableHead className="w-20"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{todayEntries.map(e => (
<TableRow key={e.id}>
<TableCell className="font-medium">{e.projectName}</TableCell>
<TableCell className="text-right tabular-nums">{e.hours}h</TableCell>
<TableCell>{e.billable ? 'Debiterbar' : 'Intern'}</TableCell>
<TableCell className="text-muted-foreground">{e.description}</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" onClick={() => openEditEntry(e)}>
<Pencil className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
<Button variant="ghost" size="sm" onClick={() => setDeleteEntryId(e.id)}>
<Trash2 className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
</TabsContent>
{/* --- Weekly Tab --- */}
<TabsContent value="weekly" className="space-y-6 mt-4">
{activeProjects.length === 0 ? (
<div className="rounded-xl border p-6 text-center">
<p className="text-sm text-muted-foreground">
Lagg till projekt under fliken &quot;Projekt&quot; for att borja rapportera tid.
</p>
</div>
) : (
<>
<div className="flex items-center justify-between">
<Button
variant="outline"
size="sm"
onClick={() => setWeekStart(prev => addDays(prev, -7))}
>
<ChevronLeft className="h-4 w-4 mr-1" />
Foregaende
</Button>
<span className="text-sm font-medium">{weekLabel}</span>
<Button
variant="outline"
size="sm"
onClick={() => setWeekStart(prev => addDays(prev, 7))}
>
Nasta
<ChevronRight className="h-4 w-4 ml-1" />
</Button>
</div>
<div className="rounded-xl border overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="min-w-[140px]">Projekt</TableHead>
{DAY_LABELS.map((label, i) => (
<TableHead key={i} className="text-center min-w-[80px]">
<div className="text-xs">{label}</div>
<div className="text-xs text-muted-foreground">{weekDays[i].slice(5)}</div>
</TableHead>
))}
<TableHead className="text-right min-w-[70px]">Totalt</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{activeProjects.map(p => {
const dayMap = weekGrid.get(p.id)
const projTotal = weekProjectTotals.get(p.id) ?? 0
return (
<TableRow key={p.id}>
<TableCell className="font-medium text-sm">{p.name}</TableCell>
{Array.from({ length: 7 }, (_, i) => {
const cellHours = dayMap?.get(i) ?? 0
const isEditing = weekCellProject === p.id && weekCellDay === i
return (
<TableCell key={i} className="text-center p-1">
{isEditing ? (
<Input
type="number"
step="0.25"
min="0"
className="h-8 w-16 mx-auto text-center text-sm"
value={weekCellHours}
onChange={e => setWeekCellHours(e.target.value)}
onBlur={handleWeekCellSubmit}
onKeyDown={handleWeekCellKeyDown}
autoFocus
/>
) : (
<button
type="button"
className="w-full h-8 rounded text-sm tabular-nums hover:bg-muted transition-colors cursor-pointer"
onClick={() => handleWeekCellClick(p.id, i)}
title="Klicka for att registrera timmar"
>
{cellHours > 0 ? `${cellHours}h` : '-'}
</button>
)}
</TableCell>
)
})}
<TableCell className="text-right tabular-nums font-medium">
{projTotal > 0 ? `${projTotal}h` : '-'}
</TableCell>
</TableRow>
)
})}
{/* Totals row */}
<TableRow className="border-t-2 font-semibold">
<TableCell>Totalt</TableCell>
{weekDayTotals.map((total, i) => (
<TableCell key={i} className="text-center tabular-nums">
{total > 0 ? `${total}h` : '-'}
</TableCell>
))}
<TableCell className="text-right tabular-nums">
{weekGrandTotal > 0 ? `${weekGrandTotal}h` : '-'}
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</>
)}
</TabsContent>
{/* --- Overview Tab --- */}
<TabsContent value="overview" className="space-y-6 mt-4">
<div className="grid grid-cols-1 sm:grid-cols-4 gap-4">
<KPICard label="Belaggningsgrad" value={utilization} suffix="%" />
<KPICard label="Debiterbara timmar" value={billableHours} suffix="h" />
<KPICard label="Totala timmar" value={totalHours} suffix="h" />
<KPICard label="Effektivt timpris" value={effectiveRate.toLocaleString('sv-SE')} suffix="kr/h" />
</div>
{monthlyTrend.length > 0 && (
<div>
<h3 className="text-sm font-semibold mb-3">Belaggning per manad</h3>
<MonthlyTrendTable rows={monthlyTrend} valueLabel="Belaggning" valueSuffix="%" />
</div>
)}
{/* Period summary */}
{periodSummary.length > 0 && (
<div>
<h3 className="text-sm font-semibold mb-3">Manatlig sammanstallning</h3>
<div className="rounded-xl border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Period</TableHead>
<TableHead className="text-right">Debiterbara</TableHead>
<TableHead className="text-right">Icke-debiterbara</TableHead>
<TableHead className="text-right">Totalt</TableHead>
<TableHead className="text-right">Intakt</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{periodSummary.map(row => (
<TableRow key={row.month}>
<TableCell className="font-medium">{row.month}</TableCell>
<TableCell className="text-right tabular-nums">{row.billable}h</TableCell>
<TableCell className="text-right tabular-nums">{row.nonBillable}h</TableCell>
<TableCell className="text-right tabular-nums">
{Math.round((row.billable + row.nonBillable) * 100) / 100}h
</TableCell>
<TableCell className="text-right tabular-nums">
{Math.round(row.revenue).toLocaleString('sv-SE')} kr
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)}
</TabsContent>
{/* --- Projects Tab --- */}
<TabsContent value="projects" className="space-y-6 mt-4">
<div className="flex items-center justify-between flex-wrap gap-2">
<Dialog open={showNewProject} onOpenChange={setShowNewProject}>
<DialogTrigger asChild>
<Button size="sm" variant="outline">
<Plus className="h-4 w-4 mr-1" /> Nytt projekt
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Nytt projekt</DialogTitle>
<DialogDescription>Lagg till ett nytt projekt att rapportera tid pa.</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Projektnamn</Label>
<Input
placeholder="T.ex. Kundprojekt A"
value={newProjectName}
onChange={e => setNewProjectName(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Kund (valfritt)</Label>
<Input
placeholder="T.ex. Foretag AB"
value={newProjectClient}
onChange={e => setNewProjectClient(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Timpris (kr/h, valfritt)</Label>
<Input
type="number"
min="0"
placeholder={`Standard: ${hourlyRate}`}
value={newProjectRate}
onChange={e => setNewProjectRate(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Om tomt anvands det globala timpriset ({hourlyRate} kr/h).
</p>
</div>
</div>
<DialogFooter>
<Button onClick={handleAddProject} disabled={!newProjectName.trim()}>Skapa</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<div className="flex items-center gap-2">
<Switch
checked={showArchived}
onCheckedChange={setShowArchived}
id="show-archived-switch"
/>
<Label htmlFor="show-archived-switch" className="text-sm">Visa arkiverade</Label>
</div>
</div>
{visibleProjectStats.length === 0 ? (
<p className="text-sm text-muted-foreground">Inga projekt att visa.</p>
) : (
<div className="rounded-xl border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Projekt</TableHead>
<TableHead>Kund</TableHead>
<TableHead className="text-right">Timpris</TableHead>
<TableHead className="text-right">Timmar (manad)</TableHead>
<TableHead className="text-right">Debiterbara</TableHead>
<TableHead className="text-right">Belaggning</TableHead>
<TableHead>Status</TableHead>
<TableHead className="w-28"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{visibleProjectStats.map(p => {
const status = p.effectiveStatus
return (
<TableRow key={p.id} className={status === 'archived' ? 'opacity-60' : undefined}>
<TableCell className="font-medium">{p.name}</TableCell>
<TableCell className="text-muted-foreground">{p.client || '-'}</TableCell>
<TableCell className="text-right tabular-nums">
{p.hourlyRate ? `${p.hourlyRate} kr/h` : `${hourlyRate} kr/h`}
{p.hourlyRate ? (
<span className="text-xs text-muted-foreground ml-1">(projekt)</span>
) : null}
</TableCell>
<TableCell className="text-right tabular-nums">{p.total}h</TableCell>
<TableCell className="text-right tabular-nums">{p.billable}h</TableCell>
<TableCell className="text-right tabular-nums">{p.utilization}%</TableCell>
<TableCell>
<Badge variant={statusBadgeVariant(status)}>
{statusLabel(status)}
</Badge>
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
{status === 'active' && (
<Button
variant="ghost"
size="sm"
title="Markera som avslutad"
onClick={() => handleProjectStatusChange(p.id, 'completed')}
>
<CheckCircle className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
)}
{status === 'completed' && (
<Button
variant="ghost"
size="sm"
title="Ateraktivera"
onClick={() => handleProjectStatusChange(p.id, 'active')}
>
<CheckCircle className="h-3.5 w-3.5 text-green-600" />
</Button>
)}
{status !== 'archived' && (
<Button
variant="ghost"
size="sm"
title="Arkivera"
onClick={() => handleProjectStatusChange(p.id, 'archived')}
>
<Archive className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
)}
{status === 'archived' && (
<Button
variant="ghost"
size="sm"
title="Ateraktivera"
onClick={() => handleProjectStatusChange(p.id, 'active')}
>
<Archive className="h-3.5 w-3.5 text-green-600" />
</Button>
)}
</div>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
)}
</TabsContent>
</Tabs>
{/* Edit Entry Dialog */}
<EditEntryDialog
open={editEntry !== null}
onOpenChange={(open) => { if (!open) setEditEntry(null) }}
title="Redigera tidpost"
description="Andra uppgifterna for den registrerade tiden."
onSave={handleSaveEdit}
isSaving={isSavingEdit}
>
<div className="space-y-2">
<Label>Datum</Label>
<Input type="date" value={editDate} onChange={e => setEditDate(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Projekt</Label>
<Select value={editProjectId} onValueChange={setEditProjectId}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{activeProjects.map(p => <SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Timmar</Label>
<Input
type="number"
step="0.25"
min="0"
value={editHours}
onChange={e => setEditHours(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Beskrivning</Label>
<Input
placeholder="Vad jobbade du med?"
value={editDescription}
onChange={e => setEditDescription(e.target.value)}
/>
</div>
<div className="flex items-center gap-2">
<Switch checked={editBillable} onCheckedChange={setEditBillable} id="edit-billable-switch" />
<Label htmlFor="edit-billable-switch" className="text-sm">Debiterbar</Label>
</div>
</EditEntryDialog>
{/* Confirm Delete Dialog */}
<ConfirmDeleteDialog
open={deleteEntryId !== null}
onOpenChange={(open) => { if (!open) setDeleteEntryId(null) }}
title="Ta bort tidpost"
description="Ar du saker pa att du vill ta bort denna tidpost? Atgarden kan inte angras."
onConfirm={handleDeleteEntry}
isDeleting={isDeleting}
/>
</div>
)
}
File diff suppressed because it is too large Load Diff
+11
View File
@@ -0,0 +1,11 @@
'use client'
import { ThemeProvider as NextThemesProvider } from 'next-themes'
import type { ComponentProps } from 'react'
export function ThemeProvider({
children,
...props
}: ComponentProps<typeof NextThemesProvider>) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}
+1 -1
View File
@@ -8,7 +8,7 @@ const Card = React.forwardRef<
<div
ref={ref}
className={cn(
"rounded-xl border border-border/50 bg-card text-card-foreground shadow-[0_1px_3px_rgba(30,25,20,0.04)]",
"rounded-xl border border-border/50 bg-card text-card-foreground shadow-[var(--shadow-sm)]",
className
)}
{...props}
@@ -0,0 +1,186 @@
import { describe, it, expect } from 'vitest'
import {
calculateProjectStats,
calculateCategoryBreakdown,
filterByDateRange,
getBudgetStatus,
type CostEntry,
type RevenueEntry,
} from '../project-cost-calculator'
describe('calculateProjectStats', () => {
it('calculates basic project stats correctly', () => {
const costs: CostEntry[] = [
{ projectId: 'p1', amount: 200000, date: '2025-03-01', category: 'materials' },
{ projectId: 'p1', amount: 100000, date: '2025-03-15', category: 'labor' },
]
const revenues: RevenueEntry[] = [
{ projectId: 'p1', amount: 500000, date: '2025-03-20' },
]
const stats = calculateProjectStats(costs, revenues, 400000)
expect(stats.totalCost).toBe(300000)
expect(stats.totalRevenue).toBe(500000)
// margin: (500000 - 300000) / 500000 * 100 = 40
expect(stats.margin).toBe(40)
// budgetUsed: 300000 / 400000 * 100 = 75
expect(stats.budgetUsed).toBe(75)
expect(stats.budgetStatus).toBe('ok')
})
it('returns margin 0 when revenue is zero', () => {
const costs: CostEntry[] = [
{ projectId: 'p1', amount: 50000, date: '2025-01-10', category: 'materials' },
]
const revenues: RevenueEntry[] = []
const stats = calculateProjectStats(costs, revenues, 100000)
expect(stats.totalCost).toBe(50000)
expect(stats.totalRevenue).toBe(0)
expect(stats.margin).toBe(0)
})
it('returns negative margin when costs exceed revenue', () => {
const costs: CostEntry[] = [
{ projectId: 'p1', amount: 150000, date: '2025-02-01', category: 'labor' },
]
const revenues: RevenueEntry[] = [
{ projectId: 'p1', amount: 100000, date: '2025-02-15' },
]
const stats = calculateProjectStats(costs, revenues, 200000)
// margin: (100000 - 150000) / 100000 * 100 = -50
expect(stats.margin).toBe(-50)
})
it('caps budgetUsed at 100 when costs exceed budget', () => {
const costs: CostEntry[] = [
{ projectId: 'p1', amount: 210000, date: '2025-04-01', category: 'materials' },
]
const revenues: RevenueEntry[] = [
{ projectId: 'p1', amount: 300000, date: '2025-04-10' },
]
const stats = calculateProjectStats(costs, revenues, 200000)
// raw budgetUsed: 210000 / 200000 * 100 = 105, capped at 100
expect(stats.budgetUsed).toBe(100)
expect(stats.budgetStatus).toBe('danger')
})
it('returns zero stats for empty arrays', () => {
const stats = calculateProjectStats([], [], 100000)
expect(stats.totalCost).toBe(0)
expect(stats.totalRevenue).toBe(0)
expect(stats.margin).toBe(0)
expect(stats.budgetUsed).toBe(0)
expect(stats.budgetStatus).toBe('ok')
})
it('handles monetary rounding edge cases', () => {
const costs: CostEntry[] = [
{ projectId: 'p1', amount: 33333.33, date: '2025-01-01', category: 'materials' },
{ projectId: 'p1', amount: 33333.33, date: '2025-01-02', category: 'labor' },
{ projectId: 'p1', amount: 33333.34, date: '2025-01-03', category: 'equipment' },
]
const revenues: RevenueEntry[] = [
{ projectId: 'p1', amount: 200000, date: '2025-01-15' },
]
const stats = calculateProjectStats(costs, revenues, 150000)
// totalCost: 33333.33 + 33333.33 + 33333.34 = 100000.00
expect(stats.totalCost).toBe(100000)
expect(stats.totalRevenue).toBe(200000)
// margin: (200000 - 100000) / 200000 * 100 = 50
expect(stats.margin).toBe(50)
})
})
describe('getBudgetStatus', () => {
it('returns ok when budget used is below 80%', () => {
expect(getBudgetStatus(0)).toBe('ok')
expect(getBudgetStatus(50)).toBe('ok')
expect(getBudgetStatus(79.99)).toBe('ok')
})
it('returns warning when budget used is between 80% and 99.99%', () => {
expect(getBudgetStatus(80)).toBe('warning')
expect(getBudgetStatus(85)).toBe('warning')
expect(getBudgetStatus(99.99)).toBe('warning')
})
it('returns danger when budget used is 100% or above', () => {
expect(getBudgetStatus(100)).toBe('danger')
expect(getBudgetStatus(105)).toBe('danger')
expect(getBudgetStatus(200)).toBe('danger')
})
})
describe('calculateCategoryBreakdown', () => {
it('breaks down costs by category with correct percentages', () => {
const costs: CostEntry[] = [
{ projectId: 'p1', amount: 60000, date: '2025-01-01', category: 'materials' },
{ projectId: 'p1', amount: 30000, date: '2025-01-05', category: 'labor' },
{ projectId: 'p1', amount: 10000, date: '2025-01-10', category: 'equipment' },
]
const breakdown = calculateCategoryBreakdown(costs)
expect(breakdown).toHaveLength(3)
// Sorted by amount descending
expect(breakdown[0]).toEqual({ category: 'materials', amount: 60000, percent: 60 })
expect(breakdown[1]).toEqual({ category: 'labor', amount: 30000, percent: 30 })
expect(breakdown[2]).toEqual({ category: 'equipment', amount: 10000, percent: 10 })
})
it('returns single category as 100%', () => {
const costs: CostEntry[] = [
{ projectId: 'p1', amount: 25000, date: '2025-01-01', category: 'labor' },
{ projectId: 'p1', amount: 75000, date: '2025-01-05', category: 'labor' },
]
const breakdown = calculateCategoryBreakdown(costs)
expect(breakdown).toHaveLength(1)
expect(breakdown[0]).toEqual({ category: 'labor', amount: 100000, percent: 100 })
})
it('returns empty array for empty costs', () => {
const breakdown = calculateCategoryBreakdown([])
expect(breakdown).toEqual([])
})
})
describe('filterByDateRange', () => {
const entries: CostEntry[] = [
{ projectId: 'p1', amount: 1000, date: '2025-01-15', category: 'materials' },
{ projectId: 'p1', amount: 2000, date: '2025-02-15', category: 'labor' },
{ projectId: 'p1', amount: 3000, date: '2025-03-15', category: 'equipment' },
]
it('includes entries within the date range', () => {
const filtered = filterByDateRange(entries, '2025-01-01', '2025-02-28')
expect(filtered).toHaveLength(2)
expect(filtered[0].date).toBe('2025-01-15')
expect(filtered[1].date).toBe('2025-02-15')
})
it('excludes entries outside the date range', () => {
const filtered = filterByDateRange(entries, '2025-04-01', '2025-04-30')
expect(filtered).toHaveLength(0)
})
it('includes entries on boundary dates', () => {
const filtered = filterByDateRange(entries, '2025-01-15', '2025-03-15')
expect(filtered).toHaveLength(3)
})
})
@@ -0,0 +1,133 @@
/**
* Pure calculation functions for the Project Cost extension.
*
* Tracks costs, revenues, margins, and budget usage per construction project.
* All monetary calculations use Math.round(x * 100) / 100 per project rules.
*/
export interface CostEntry {
projectId: string
amount: number
date: string
category: string
}
export interface RevenueEntry {
projectId: string
amount: number
date: string
}
export interface ProjectStats {
totalCost: number
totalRevenue: number
margin: number // (revenue - cost) / revenue * 100, or 0 if no revenue
budgetUsed: number // cost / budget * 100, capped at 100
budgetStatus: 'ok' | 'warning' | 'danger' // ok < 80%, warning >= 80%, danger >= 100%
}
export interface CategoryBreakdown {
category: string
amount: number
percent: number
}
/**
* Determine budget status based on percentage of budget used.
* - ok: less than 80%
* - warning: 80% to below 100%
* - danger: 100% or above
*/
export function getBudgetStatus(budgetUsedPct: number): 'ok' | 'warning' | 'danger' {
if (budgetUsedPct >= 100) return 'danger'
if (budgetUsedPct >= 80) return 'warning'
return 'ok'
}
/**
* Calculate project statistics from cost entries, revenue entries, and budget.
*
* - totalCost: sum of all cost entry amounts
* - totalRevenue: sum of all revenue entry amounts
* - margin: (revenue - cost) / revenue * 100, or 0 when revenue is zero
* - budgetUsed: cost / budget * 100, capped at 100; 0 if budget <= 0
* - budgetStatus: derived from budgetUsed via getBudgetStatus
*/
export function calculateProjectStats(
costs: CostEntry[],
revenues: RevenueEntry[],
budget: number
): ProjectStats {
const totalCost = Math.round(
costs.reduce((sum, c) => sum + c.amount, 0) * 100
) / 100
const totalRevenue = Math.round(
revenues.reduce((sum, r) => sum + r.amount, 0) * 100
) / 100
const margin = totalRevenue > 0
? Math.round(((totalRevenue - totalCost) / totalRevenue) * 10000) / 100
: 0
const rawBudgetUsed = budget > 0
? Math.round((totalCost / budget) * 10000) / 100
: 0
const budgetUsed = Math.min(rawBudgetUsed, 100)
const budgetStatus = getBudgetStatus(rawBudgetUsed)
return {
totalCost,
totalRevenue,
margin,
budgetUsed,
budgetStatus,
}
}
/**
* Calculate cost breakdown by category.
*
* Returns an array of categories sorted by amount descending,
* each with the category name, total amount, and percentage of total cost.
* Returns an empty array if there are no costs.
*/
export function calculateCategoryBreakdown(costs: CostEntry[]): CategoryBreakdown[] {
if (costs.length === 0) return []
const totalCost = Math.round(
costs.reduce((sum, c) => sum + c.amount, 0) * 100
) / 100
const categoryMap = new Map<string, number>()
for (const cost of costs) {
const current = categoryMap.get(cost.category) ?? 0
categoryMap.set(cost.category, current + cost.amount)
}
const breakdown: CategoryBreakdown[] = []
for (const [category, rawAmount] of categoryMap) {
const amount = Math.round(rawAmount * 100) / 100
const percent = totalCost > 0
? Math.round((amount / totalCost) * 10000) / 100
: 0
breakdown.push({ category, amount, percent })
}
breakdown.sort((a, b) => b.amount - a.amount)
return breakdown
}
/**
* Filter entries by date range (inclusive on both ends).
* Dates are compared as ISO date strings (YYYY-MM-DD).
*/
export function filterByDateRange<T extends { date: string }>(
entries: T[],
start: string,
end: string
): T[] {
return entries.filter(e => e.date >= start && e.date <= end)
}
@@ -0,0 +1,164 @@
import { describe, it, expect } from 'vitest'
import {
calculateRotDeduction,
calculateCustomerQuotas,
filterJobsByYear,
generateRotCsvContent,
MAX_ROT_YEARLY,
ROT_RATE,
type RotJob,
} from '../rot-calculator'
describe('calculateRotDeduction', () => {
it('calculates 30% of labor as ROT deduction', () => {
const result = calculateRotDeduction(100000, 40000, 0)
// Labor = 100000 - 40000 = 60000
// ROT = 60000 * 0.30 = 18000
expect(result.labor).toBe(60000)
expect(result.rotDeduction).toBe(18000)
expect(result.customerPays).toBe(82000)
expect(result.remainingQuota).toBe(MAX_ROT_YEARLY - 18000)
})
it('caps ROT deduction at remaining yearly quota', () => {
// Customer has already used 45000 of 50000 quota
const result = calculateRotDeduction(100000, 40000, 45000)
// Labor = 60000, raw ROT = 18000, but only 5000 remaining
expect(result.rotDeduction).toBe(5000)
expect(result.customerPays).toBe(95000)
expect(result.remainingQuota).toBe(0)
})
it('returns zero deduction when labor is zero (material equals total)', () => {
const result = calculateRotDeduction(50000, 50000, 0)
expect(result.labor).toBe(0)
expect(result.rotDeduction).toBe(0)
expect(result.customerPays).toBe(50000)
expect(result.remainingQuota).toBe(MAX_ROT_YEARLY)
})
it('returns zero deduction when quota is already exhausted', () => {
const result = calculateRotDeduction(80000, 30000, MAX_ROT_YEARLY)
expect(result.labor).toBe(50000)
expect(result.rotDeduction).toBe(0)
expect(result.customerPays).toBe(80000)
expect(result.remainingQuota).toBe(0)
})
it('calculates customerPays as total minus rotDeduction', () => {
const result = calculateRotDeduction(75000, 25000, 0)
// Labor = 50000, ROT = 15000
expect(result.customerPays).toBe(75000 - result.rotDeduction)
expect(result.customerPays).toBe(60000)
})
it('handles monetary rounding correctly', () => {
// total=10001, material=3333 -> labor=6668
// ROT = 6668 * 0.30 = 2000.4
const result = calculateRotDeduction(10001, 3333, 0)
expect(result.labor).toBe(6668)
expect(result.rotDeduction).toBe(2000.4)
expect(result.customerPays).toBe(8000.6)
expect(result.remainingQuota).toBe(Math.round((MAX_ROT_YEARLY - 2000.4) * 100) / 100)
})
})
describe('calculateCustomerQuotas', () => {
const baseJob: RotJob = {
id: 'job-1',
customerId: 'cust-1',
total: 100000,
material: 40000,
labor: 60000,
rotDeduction: 18000,
date: '2025-03-15',
status: 'completed',
}
it('calculates used quota from completed jobs for a customer', () => {
const jobs: RotJob[] = [
{ ...baseJob, id: 'job-1', rotDeduction: 10000 },
{ ...baseJob, id: 'job-2', rotDeduction: 15000 },
]
const quotas = calculateCustomerQuotas(jobs, 2025)
expect(quotas.get('cust-1')).toBe(25000)
})
it('does not count draft jobs toward quota', () => {
const jobs: RotJob[] = [
{ ...baseJob, id: 'job-1', rotDeduction: 10000, status: 'completed' },
{ ...baseJob, id: 'job-2', rotDeduction: 15000, status: 'draft' },
]
const quotas = calculateCustomerQuotas(jobs, 2025)
expect(quotas.get('cust-1')).toBe(10000)
})
it('tracks multiple customers with different quotas', () => {
const jobs: RotJob[] = [
{ ...baseJob, id: 'job-1', customerId: 'cust-1', rotDeduction: 12000 },
{ ...baseJob, id: 'job-2', customerId: 'cust-2', rotDeduction: 8000 },
{ ...baseJob, id: 'job-3', customerId: 'cust-1', rotDeduction: 5000 },
]
const quotas = calculateCustomerQuotas(jobs, 2025)
expect(quotas.get('cust-1')).toBe(17000)
expect(quotas.get('cust-2')).toBe(8000)
})
})
describe('filterJobsByYear', () => {
it('returns only jobs from the specified year', () => {
const jobs: RotJob[] = [
{ id: '1', customerId: 'c1', total: 50000, material: 20000, labor: 30000, rotDeduction: 9000, date: '2025-06-01', status: 'completed' },
{ id: '2', customerId: 'c1', total: 60000, material: 25000, labor: 35000, rotDeduction: 10500, date: '2024-11-15', status: 'completed' },
{ id: '3', customerId: 'c1', total: 70000, material: 30000, labor: 40000, rotDeduction: 12000, date: '2025-12-31', status: 'completed' },
]
const filtered = filterJobsByYear(jobs, 2025)
expect(filtered).toHaveLength(2)
expect(filtered.map(j => j.id)).toEqual(['1', '3'])
})
})
describe('generateRotCsvContent', () => {
it('generates CSV with correct columns for completed jobs only', () => {
const jobs: RotJob[] = [
{ id: '1', customerId: 'c1', total: 80000, material: 30000, labor: 50000, rotDeduction: 15000, date: '2025-04-10', status: 'completed' },
{ id: '2', customerId: 'c2', total: 60000, material: 20000, labor: 40000, rotDeduction: 12000, date: '2025-05-20', status: 'draft' },
{ id: '3', customerId: 'c1', total: 40000, material: 15000, labor: 25000, rotDeduction: 7500, date: '2025-06-15', status: 'completed' },
]
const customers = new Map([
['c1', { name: 'Anna Svensson', personalNumber: '198501011234' }],
['c2', { name: 'Erik Johansson', personalNumber: '199003025678' }],
])
const csv = generateRotCsvContent(jobs, customers)
const lines = csv.split('\n')
expect(lines[0]).toBe('PersonalNumber,CustomerName,Labor,RotDeduction,Date')
// Draft job (id=2) should be excluded
expect(lines).toHaveLength(3)
expect(lines[1]).toBe('198501011234,Anna Svensson,50000,15000,2025-04-10')
expect(lines[2]).toBe('198501011234,Anna Svensson,25000,7500,2025-06-15')
})
})
describe('constants', () => {
it('has correct ROT rate and yearly maximum', () => {
expect(ROT_RATE).toBe(0.30)
expect(MAX_ROT_YEARLY).toBe(50000)
})
})
@@ -0,0 +1,118 @@
/**
* ROT (Repairs, Conversion, Extension) tax deduction calculator.
*
* ROT deduction allows Swedish homeowners to deduct 30% of labor costs
* for home renovation work, up to SEK 50 000 per person per year.
*
* All monetary values use Math.round(x * 100) / 100 to avoid
* floating-point precision issues.
*/
export const MAX_ROT_YEARLY = 50000
export const ROT_RATE = 0.30
export interface RotJob {
id: string
customerId: string
total: number
material: number
labor: number
rotDeduction: number
date: string
status: 'draft' | 'completed'
}
export interface RotCalculation {
labor: number
rotDeduction: number
customerPays: number
remainingQuota: number
}
export interface CustomerQuota {
customerId: string
usedQuota: number
remainingQuota: number
}
/**
* Calculate ROT deduction for a job given the customer's already-used quota.
*
* The deduction is 30% of labor (total - material), capped by the
* remaining yearly quota (MAX_ROT_YEARLY - usedQuota).
*/
export function calculateRotDeduction(
total: number,
material: number,
usedQuota: number
): RotCalculation {
const labor = Math.round((total - material) * 100) / 100
const remaining = Math.max(MAX_ROT_YEARLY - usedQuota, 0)
const rawDeduction = Math.round(labor * ROT_RATE * 100) / 100
const rotDeduction = Math.round(Math.min(rawDeduction, remaining) * 100) / 100
const customerPays = Math.round((total - rotDeduction) * 100) / 100
const remainingQuota = Math.round((remaining - rotDeduction) * 100) / 100
return {
labor,
rotDeduction,
customerPays,
remainingQuota,
}
}
/**
* Calculate per-customer used quota for a given year.
* Only completed jobs count toward the quota.
*/
export function calculateCustomerQuotas(
jobs: RotJob[],
year: number
): Map<string, number> {
const yearJobs = filterJobsByYear(jobs, year).filter(
j => j.status === 'completed'
)
const quotas = new Map<string, number>()
for (const job of yearJobs) {
const current = quotas.get(job.customerId) ?? 0
quotas.set(
job.customerId,
Math.round((current + job.rotDeduction) * 100) / 100
)
}
return quotas
}
/**
* Filter jobs whose date falls within the specified year.
*/
export function filterJobsByYear(jobs: RotJob[], year: number): RotJob[] {
const yearStr = String(year)
return jobs.filter(j => j.date.startsWith(yearStr))
}
/**
* Generate CSV content for Skatteverket ROT deduction reporting.
* Only completed jobs are included.
*
* Columns: PersonalNumber, CustomerName, Labor, RotDeduction, Date
*/
export function generateRotCsvContent(
jobs: RotJob[],
customers: Map<string, { name: string; personalNumber: string }>
): string {
const header = 'PersonalNumber,CustomerName,Labor,RotDeduction,Date'
const completedJobs = jobs.filter(j => j.status === 'completed')
const rows = completedJobs.map(job => {
const customer = customers.get(job.customerId)
const personalNumber = customer?.personalNumber ?? ''
const customerName = customer?.name ?? ''
return `${personalNumber},${customerName},${job.labor},${job.rotDeduction},${job.date}`
})
return [header, ...rows].join('\n')
}
@@ -0,0 +1,211 @@
import { describe, it, expect } from 'vitest'
import {
calculateChannelSummary,
calculateOverallAOV,
findDuplicate,
calculateChannelGrowth,
buildMonthlyComparison,
filterEntriesByRange,
type RevenueEntry,
} from '../multichannel-calculator'
describe('calculateChannelSummary', () => {
it('groups entries by channel and calculates totals with AOV', () => {
const entries: RevenueEntry[] = [
{ id: '1', month: '2025-01', channel: 'Shopify', revenue: 50000, orderCount: 200 },
{ id: '2', month: '2025-02', channel: 'Shopify', revenue: 60000, orderCount: 250 },
{ id: '3', month: '2025-01', channel: 'Amazon', revenue: 30000, orderCount: 150 },
{ id: '4', month: '2025-02', channel: 'Amazon', revenue: 35000, orderCount: 180 },
]
const result = calculateChannelSummary(entries)
expect(result).toHaveLength(2)
const shopify = result.find(s => s.channel === 'Shopify')!
expect(shopify.totalRevenue).toBe(110000)
expect(shopify.totalOrders).toBe(450)
// 110000 / 450 = 244.444... -> 244.44
expect(shopify.aov).toBe(244.44)
const amazon = result.find(s => s.channel === 'Amazon')!
expect(amazon.totalRevenue).toBe(65000)
expect(amazon.totalOrders).toBe(330)
// 65000 / 330 = 196.969696... -> 196.97
expect(amazon.aov).toBe(196.97)
})
it('returns AOV of 0 when a channel has zero orders', () => {
const entries: RevenueEntry[] = [
{ id: '1', month: '2025-01', channel: 'Wholesale', revenue: 0, orderCount: 0 },
]
const result = calculateChannelSummary(entries)
expect(result).toHaveLength(1)
expect(result[0].aov).toBe(0)
expect(result[0].totalOrders).toBe(0)
})
})
describe('calculateOverallAOV', () => {
it('calculates overall AOV across all channels', () => {
const entries: RevenueEntry[] = [
{ id: '1', month: '2025-01', channel: 'Shopify', revenue: 50000, orderCount: 200 },
{ id: '2', month: '2025-01', channel: 'Amazon', revenue: 30000, orderCount: 100 },
]
// Total revenue: 80000, Total orders: 300
// 80000 / 300 = 266.666... -> 266.67
const result = calculateOverallAOV(entries)
expect(result).toBe(266.67)
})
it('returns 0 for empty entries', () => {
const result = calculateOverallAOV([])
expect(result).toBe(0)
})
})
describe('findDuplicate', () => {
const entries: RevenueEntry[] = [
{ id: '1', month: '2025-01', channel: 'Shopify', revenue: 50000, orderCount: 200 },
{ id: '2', month: '2025-02', channel: 'Amazon', revenue: 30000, orderCount: 150 },
]
it('finds an existing entry for same month and channel', () => {
const dup = findDuplicate(entries, '2025-01', 'Shopify')
expect(dup).toBeDefined()
expect(dup!.id).toBe('1')
})
it('returns undefined when no duplicate exists', () => {
const dup = findDuplicate(entries, '2025-03', 'Shopify')
expect(dup).toBeUndefined()
})
})
describe('calculateChannelGrowth', () => {
it('calculates positive growth percentage', () => {
const previous: RevenueEntry[] = [
{ id: '1', month: '2025-01', channel: 'Shopify', revenue: 40000, orderCount: 100 },
]
const current: RevenueEntry[] = [
{ id: '2', month: '2025-02', channel: 'Shopify', revenue: 50000, orderCount: 120 },
]
const result = calculateChannelGrowth(current, previous)
expect(result).toHaveLength(1)
expect(result[0].channel).toBe('Shopify')
expect(result[0].currentRevenue).toBe(50000)
expect(result[0].previousRevenue).toBe(40000)
// (50000 - 40000) / 40000 * 100 = 25
expect(result[0].growthPct).toBe(25)
})
it('calculates negative growth percentage', () => {
const previous: RevenueEntry[] = [
{ id: '1', month: '2025-01', channel: 'Amazon', revenue: 60000, orderCount: 200 },
]
const current: RevenueEntry[] = [
{ id: '2', month: '2025-02', channel: 'Amazon', revenue: 45000, orderCount: 150 },
]
const result = calculateChannelGrowth(current, previous)
expect(result).toHaveLength(1)
expect(result[0].growthPct).toBe(-25)
})
it('returns null growthPct for new channel with no previous data', () => {
const previous: RevenueEntry[] = [
{ id: '1', month: '2025-01', channel: 'Shopify', revenue: 40000, orderCount: 100 },
]
const current: RevenueEntry[] = [
{ id: '2', month: '2025-02', channel: 'Shopify', revenue: 50000, orderCount: 120 },
{ id: '3', month: '2025-02', channel: 'TikTok Shop', revenue: 10000, orderCount: 50 },
]
const result = calculateChannelGrowth(current, previous)
const tiktok = result.find(r => r.channel === 'TikTok Shop')!
expect(tiktok.currentRevenue).toBe(10000)
expect(tiktok.previousRevenue).toBe(0)
expect(tiktok.growthPct).toBeNull()
})
})
describe('buildMonthlyComparison', () => {
it('builds comparison table with 2 channels across 3 months', () => {
const entries: RevenueEntry[] = [
{ id: '1', month: '2025-01', channel: 'Shopify', revenue: 50000, orderCount: 200 },
{ id: '2', month: '2025-01', channel: 'Amazon', revenue: 30000, orderCount: 100 },
{ id: '3', month: '2025-02', channel: 'Shopify', revenue: 55000, orderCount: 220 },
{ id: '4', month: '2025-02', channel: 'Amazon', revenue: 32000, orderCount: 110 },
{ id: '5', month: '2025-03', channel: 'Shopify', revenue: 60000, orderCount: 240 },
{ id: '6', month: '2025-03', channel: 'Amazon', revenue: 35000, orderCount: 130 },
]
const channelNames = ['Shopify', 'Amazon']
const result = buildMonthlyComparison(entries, channelNames)
expect(result).toHaveLength(3)
// Months should be sorted ascending
expect(result[0].month).toBe('2025-01')
expect(result[1].month).toBe('2025-02')
expect(result[2].month).toBe('2025-03')
// January
expect(result[0].channels['Shopify']).toBe(50000)
expect(result[0].channels['Amazon']).toBe(30000)
expect(result[0].total).toBe(80000)
// March
expect(result[2].channels['Shopify']).toBe(60000)
expect(result[2].channels['Amazon']).toBe(35000)
expect(result[2].total).toBe(95000)
})
})
describe('filterEntriesByRange', () => {
it('filters entries by YYYY-MM date range inclusive', () => {
const entries: RevenueEntry[] = [
{ id: '1', month: '2024-11', channel: 'Shopify', revenue: 40000, orderCount: 100 },
{ id: '2', month: '2024-12', channel: 'Shopify', revenue: 45000, orderCount: 110 },
{ id: '3', month: '2025-01', channel: 'Shopify', revenue: 50000, orderCount: 120 },
{ id: '4', month: '2025-02', channel: 'Shopify', revenue: 55000, orderCount: 130 },
{ id: '5', month: '2025-03', channel: 'Shopify', revenue: 60000, orderCount: 140 },
]
const result = filterEntriesByRange(entries, '2024-12', '2025-02')
expect(result).toHaveLength(3)
expect(result.map(e => e.month)).toEqual(['2024-12', '2025-01', '2025-02'])
})
})
describe('monetary rounding', () => {
it('applies Math.round(x * 100) / 100 for all monetary outputs', () => {
const entries: RevenueEntry[] = [
{ id: '1', month: '2025-01', channel: 'Shopify', revenue: 33333.33, orderCount: 7 },
{ id: '2', month: '2025-01', channel: 'Amazon', revenue: 16666.67, orderCount: 3 },
]
// Overall AOV: 50000 / 10 = 5000
expect(calculateOverallAOV(entries)).toBe(5000)
// Per-channel AOV
const summaries = calculateChannelSummary(entries)
const shopify = summaries.find(s => s.channel === 'Shopify')!
// 33333.33 / 7 = 4761.9042857... -> 4761.9
expect(shopify.aov).toBe(4761.9)
expect(shopify.totalRevenue).toBe(33333.33)
const amazon = summaries.find(s => s.channel === 'Amazon')!
// 16666.67 / 3 = 5555.5566... -> 5555.56
expect(amazon.aov).toBe(5555.56)
expect(amazon.totalRevenue).toBe(16666.67)
})
})
@@ -0,0 +1,200 @@
/**
* Pure calculation functions for the Multichannel Revenue extension.
*
* Aggregates revenue data across sales channels (e.g. Shopify, Amazon,
* physical store) and computes per-channel summaries, growth rates,
* and monthly comparison tables.
*
* All monetary values use Math.round(x * 100) / 100 for precision.
*/
export interface RevenueEntry {
id: string
month: string // YYYY-MM
channel: string
revenue: number
orderCount: number
}
export interface ChannelSummary {
channel: string
totalRevenue: number
totalOrders: number
aov: number // revenue / orders, or 0
}
export interface ChannelGrowth {
channel: string
currentRevenue: number
previousRevenue: number
growthPct: number | null // null if no previous data
}
export interface MonthlyComparison {
month: string
channels: Record<string, number> // channel name -> revenue
total: number
}
/**
* Calculate per-channel summary with AOV (Average Order Value).
* Groups entries by channel and computes totals.
*/
export function calculateChannelSummary(entries: RevenueEntry[]): ChannelSummary[] {
const byChannel = new Map<string, { revenue: number; orders: number }>()
for (const entry of entries) {
const existing = byChannel.get(entry.channel)
if (existing) {
existing.revenue += entry.revenue
existing.orders += entry.orderCount
} else {
byChannel.set(entry.channel, {
revenue: entry.revenue,
orders: entry.orderCount,
})
}
}
const summaries: ChannelSummary[] = []
for (const [channel, data] of byChannel) {
const totalRevenue = Math.round(data.revenue * 100) / 100
const totalOrders = data.orders
const aov = totalOrders > 0
? Math.round((totalRevenue / totalOrders) * 100) / 100
: 0
summaries.push({ channel, totalRevenue, totalOrders, aov })
}
return summaries
}
/**
* Calculate overall AOV across all channels.
* Returns 0 if there are no orders.
*/
export function calculateOverallAOV(entries: RevenueEntry[]): number {
const totalRevenue = entries.reduce((sum, e) => sum + e.revenue, 0)
const totalOrders = entries.reduce((sum, e) => sum + e.orderCount, 0)
if (totalOrders === 0) return 0
return Math.round((totalRevenue / totalOrders) * 100) / 100
}
/**
* Check for duplicate entry (same month + channel).
* Returns the first matching entry or undefined.
*/
export function findDuplicate(
entries: RevenueEntry[],
month: string,
channel: string
): RevenueEntry | undefined {
return entries.find(e => e.month === month && e.channel === channel)
}
/**
* Calculate channel growth between current and previous period entries.
* A channel present only in currentEntries gets growthPct: null.
*/
export function calculateChannelGrowth(
currentEntries: RevenueEntry[],
previousEntries: RevenueEntry[]
): ChannelGrowth[] {
const currentByChannel = new Map<string, number>()
for (const entry of currentEntries) {
currentByChannel.set(
entry.channel,
(currentByChannel.get(entry.channel) ?? 0) + entry.revenue
)
}
const previousByChannel = new Map<string, number>()
for (const entry of previousEntries) {
previousByChannel.set(
entry.channel,
(previousByChannel.get(entry.channel) ?? 0) + entry.revenue
)
}
const results: ChannelGrowth[] = []
for (const [channel, currentRevenue] of currentByChannel) {
const rounded = Math.round(currentRevenue * 100) / 100
const previousRevenue = previousByChannel.get(channel)
if (previousRevenue === undefined || previousRevenue === 0) {
results.push({
channel,
currentRevenue: rounded,
previousRevenue: 0,
growthPct: null,
})
} else {
const prevRounded = Math.round(previousRevenue * 100) / 100
const growthPct = Math.round(
((rounded - prevRounded) / prevRounded) * 10000
) / 100
results.push({
channel,
currentRevenue: rounded,
previousRevenue: prevRounded,
growthPct,
})
}
}
return results
}
/**
* Build monthly comparison table.
* Each row contains per-channel revenue and a total for that month.
* Months are sorted in ascending order.
*/
export function buildMonthlyComparison(
entries: RevenueEntry[],
channelNames: string[]
): MonthlyComparison[] {
const monthMap = new Map<string, Record<string, number>>()
for (const entry of entries) {
if (!monthMap.has(entry.month)) {
const channels: Record<string, number> = {}
for (const name of channelNames) {
channels[name] = 0
}
monthMap.set(entry.month, channels)
}
const channels = monthMap.get(entry.month)!
channels[entry.channel] = Math.round(
((channels[entry.channel] ?? 0) + entry.revenue) * 100
) / 100
}
const months = Array.from(monthMap.keys()).sort()
return months.map(month => {
const channels = monthMap.get(month)!
const total = Math.round(
Object.values(channels).reduce((sum, v) => sum + v, 0) * 100
) / 100
return { month, channels, total }
})
}
/**
* Filter entries by date range using the month field (YYYY-MM).
* Inclusive on both ends: startDate <= month <= endDate.
*/
export function filterEntriesByRange(
entries: RevenueEntry[],
startDate: string,
endDate: string
): RevenueEntry[] {
return entries.filter(e => e.month >= startDate && e.month <= endDate)
}
@@ -0,0 +1,167 @@
import { describe, it, expect } from 'vitest'
import {
calculateShopifyStats,
calculatePaymentBreakdown,
calculateFulfillmentBreakdown,
calculateMonthlyVat,
filterOrdersByDateRange,
parseCsvNumber,
type ShopifyOrder,
} from '../shopify-calculator'
function makeOrder(overrides: Partial<ShopifyOrder> = {}): ShopifyOrder {
return {
id: 'order-1',
createdAt: '2025-03-15T10:00:00Z',
total: 1250,
subtotal: 1000,
shipping: 0,
taxes: 250,
paymentMethod: 'Shopify Payments',
fulfillmentStatus: 'fulfilled',
...overrides,
}
}
describe('calculateShopifyStats', () => {
it('calculates order count, revenue, and AOV correctly', () => {
const orders = [
makeOrder({ id: '1', total: 500, subtotal: 400, taxes: 100 }),
makeOrder({ id: '2', total: 1500, subtotal: 1200, taxes: 300 }),
]
const stats = calculateShopifyStats(orders)
expect(stats.orderCount).toBe(2)
expect(stats.totalRevenue).toBe(2000)
expect(stats.aov).toBe(1000)
expect(stats.totalTaxes).toBe(400)
expect(stats.totalSubtotal).toBe(1600)
})
it('returns all zeros for empty orders', () => {
const stats = calculateShopifyStats([])
expect(stats.orderCount).toBe(0)
expect(stats.totalRevenue).toBe(0)
expect(stats.aov).toBe(0)
expect(stats.totalTaxes).toBe(0)
expect(stats.totalSubtotal).toBe(0)
expect(stats.avgVatRate).toBe(0)
})
it('calculates average VAT rate correctly', () => {
const orders = [
makeOrder({ id: '1', subtotal: 800, taxes: 200 }),
makeOrder({ id: '2', subtotal: 1200, taxes: 300 }),
]
const stats = calculateShopifyStats(orders)
// Total taxes: 500, total subtotal: 2000 -> 500/2000*100 = 25
expect(stats.avgVatRate).toBe(25)
})
it('returns avgVatRate 0 when subtotal is zero', () => {
const orders = [
makeOrder({ id: '1', total: 0, subtotal: 0, taxes: 0 }),
]
const stats = calculateShopifyStats(orders)
expect(stats.avgVatRate).toBe(0)
})
})
describe('calculatePaymentBreakdown', () => {
it('groups orders by payment method with count and total', () => {
const orders = [
makeOrder({ id: '1', total: 500, paymentMethod: 'Klarna' }),
makeOrder({ id: '2', total: 800, paymentMethod: 'Shopify Payments' }),
makeOrder({ id: '3', total: 300, paymentMethod: 'Klarna' }),
makeOrder({ id: '4', total: 200, paymentMethod: 'PayPal' }),
]
const breakdown = calculatePaymentBreakdown(orders)
expect(breakdown).toEqual([
{ method: 'Klarna', count: 2, total: 800 },
{ method: 'PayPal', count: 1, total: 200 },
{ method: 'Shopify Payments', count: 1, total: 800 },
])
})
})
describe('calculateFulfillmentBreakdown', () => {
it('groups orders by fulfillment status', () => {
const orders = [
makeOrder({ id: '1', fulfillmentStatus: 'fulfilled' }),
makeOrder({ id: '2', fulfillmentStatus: 'unfulfilled' }),
makeOrder({ id: '3', fulfillmentStatus: 'fulfilled' }),
makeOrder({ id: '4', fulfillmentStatus: 'partial' }),
makeOrder({ id: '5', fulfillmentStatus: 'unfulfilled' }),
]
const breakdown = calculateFulfillmentBreakdown(orders)
expect(breakdown).toEqual([
{ status: 'fulfilled', count: 2 },
{ status: 'partial', count: 1 },
{ status: 'unfulfilled', count: 2 },
])
})
})
describe('calculateMonthlyVat', () => {
it('calculates monthly VAT breakdown sorted chronologically', () => {
const orders = [
makeOrder({ id: '1', createdAt: '2025-01-10T10:00:00Z', subtotal: 800, taxes: 200 }),
makeOrder({ id: '2', createdAt: '2025-01-20T10:00:00Z', subtotal: 400, taxes: 100 }),
makeOrder({ id: '3', createdAt: '2025-03-05T10:00:00Z', subtotal: 1000, taxes: 250 }),
]
const monthly = calculateMonthlyVat(orders)
expect(monthly).toEqual([
{ month: '2025-01', taxes: 300, subtotal: 1200, vatRate: 25 },
{ month: '2025-03', taxes: 250, subtotal: 1000, vatRate: 25 },
])
})
})
describe('filterOrdersByDateRange', () => {
it('includes orders within the date range and excludes those outside', () => {
const orders = [
makeOrder({ id: '1', createdAt: '2025-01-01T08:00:00Z' }),
makeOrder({ id: '2', createdAt: '2025-01-15T12:00:00Z' }),
makeOrder({ id: '3', createdAt: '2025-01-31T23:59:59Z' }),
makeOrder({ id: '4', createdAt: '2025-02-01T00:00:00Z' }),
makeOrder({ id: '5', createdAt: '2024-12-31T23:59:59Z' }),
]
const filtered = filterOrdersByDateRange(orders, '2025-01-01', '2025-01-31')
expect(filtered.map(o => o.id)).toEqual(['1', '2', '3'])
})
})
describe('parseCsvNumber', () => {
it('parses Swedish-style number with spaces and comma decimal', () => {
expect(parseCsvNumber('1 234,56')).toBe(1234.56)
})
it('parses standard dot-decimal notation', () => {
expect(parseCsvNumber('1234.56')).toBe(1234.56)
})
it('returns 0 for empty string', () => {
expect(parseCsvNumber('')).toBe(0)
})
it('rounds monetary values to two decimal places', () => {
// 1234.567 should round to 1234.57
expect(parseCsvNumber('1234.567')).toBe(1234.57)
// 99.999 should round to 100.00
expect(parseCsvNumber('99.999')).toBe(100)
})
})
@@ -0,0 +1,199 @@
/**
* Pure calculation functions for Shopify order import data.
*
* Calculates aggregate statistics, payment breakdowns, fulfillment
* breakdowns, and monthly VAT summaries from imported Shopify orders.
*
* All monetary values use Math.round(x * 100) / 100 for precision.
*/
export interface ShopifyOrder {
id: string
createdAt: string
total: number
subtotal: number
shipping: number
taxes: number
paymentMethod: string
fulfillmentStatus: string
}
export interface ShopifyStats {
orderCount: number
totalRevenue: number
aov: number
totalTaxes: number
totalSubtotal: number
avgVatRate: number
}
export interface PaymentBreakdown {
method: string
count: number
total: number
}
export interface FulfillmentBreakdown {
status: string
count: number
}
export interface MonthlyVat {
month: string
taxes: number
subtotal: number
vatRate: number
}
/**
* Calculate overall statistics from a list of Shopify orders.
* AOV is totalRevenue / orderCount, or 0 if no orders.
* avgVatRate is (totalTaxes / totalSubtotal) * 100, or 0 if subtotal is zero.
*/
export function calculateShopifyStats(orders: ShopifyOrder[]): ShopifyStats {
if (orders.length === 0) {
return {
orderCount: 0,
totalRevenue: 0,
aov: 0,
totalTaxes: 0,
totalSubtotal: 0,
avgVatRate: 0,
}
}
const totalRevenue = orders.reduce((sum, o) => sum + o.total, 0)
const totalTaxes = orders.reduce((sum, o) => sum + o.taxes, 0)
const totalSubtotal = orders.reduce((sum, o) => sum + o.subtotal, 0)
const aov = Math.round((totalRevenue / orders.length) * 100) / 100
const avgVatRate = totalSubtotal > 0
? Math.round((totalTaxes / totalSubtotal) * 10000) / 100
: 0
return {
orderCount: orders.length,
totalRevenue: Math.round(totalRevenue * 100) / 100,
aov,
totalTaxes: Math.round(totalTaxes * 100) / 100,
totalSubtotal: Math.round(totalSubtotal * 100) / 100,
avgVatRate,
}
}
/**
* Group orders by payment method and calculate count and total per method.
* Returns sorted alphabetically by method name.
*/
export function calculatePaymentBreakdown(orders: ShopifyOrder[]): PaymentBreakdown[] {
const map = new Map<string, { count: number; total: number }>()
for (const order of orders) {
const entry = map.get(order.paymentMethod)
if (entry) {
entry.count += 1
entry.total += order.total
} else {
map.set(order.paymentMethod, { count: 1, total: order.total })
}
}
return Array.from(map.entries())
.map(([method, { count, total }]) => ({
method,
count,
total: Math.round(total * 100) / 100,
}))
.sort((a, b) => a.method.localeCompare(b.method))
}
/**
* Group orders by fulfillment status and calculate count per status.
* Returns sorted alphabetically by status.
*/
export function calculateFulfillmentBreakdown(orders: ShopifyOrder[]): FulfillmentBreakdown[] {
const map = new Map<string, number>()
for (const order of orders) {
map.set(order.fulfillmentStatus, (map.get(order.fulfillmentStatus) ?? 0) + 1)
}
return Array.from(map.entries())
.map(([status, count]) => ({ status, count }))
.sort((a, b) => a.status.localeCompare(b.status))
}
/**
* Calculate monthly VAT breakdown from orders.
* Groups by YYYY-MM derived from createdAt, calculates taxes, subtotal,
* and effective VAT rate per month. Returns sorted chronologically.
*/
export function calculateMonthlyVat(orders: ShopifyOrder[]): MonthlyVat[] {
const map = new Map<string, { taxes: number; subtotal: number }>()
for (const order of orders) {
const month = order.createdAt.slice(0, 7) // YYYY-MM
const entry = map.get(month)
if (entry) {
entry.taxes += order.taxes
entry.subtotal += order.subtotal
} else {
map.set(month, { taxes: order.taxes, subtotal: order.subtotal })
}
}
return Array.from(map.entries())
.map(([month, { taxes, subtotal }]) => ({
month,
taxes: Math.round(taxes * 100) / 100,
subtotal: Math.round(subtotal * 100) / 100,
vatRate: subtotal > 0
? Math.round((taxes / subtotal) * 10000) / 100
: 0,
}))
.sort((a, b) => a.month.localeCompare(b.month))
}
/**
* Filter orders whose createdAt falls within [from, to] inclusive.
* Comparison is done on the date portion (YYYY-MM-DD) of createdAt.
*/
export function filterOrdersByDateRange(
orders: ShopifyOrder[],
from: string,
to: string
): ShopifyOrder[] {
return orders.filter(o => {
const date = o.createdAt.slice(0, 10)
return date >= from && date <= to
})
}
/**
* Parse a numeric value from a CSV string.
* Handles Swedish-style formatting with spaces as thousands separators
* and commas as decimal separators (e.g. "1 234,56" -> 1234.56).
* Also handles standard dot-decimal notation ("1234.56" -> 1234.56).
* Returns 0 for empty or unparseable strings.
*/
export function parseCsvNumber(value: string): number {
if (!value || value.trim() === '') {
return 0
}
// Remove whitespace (thousands separators)
let cleaned = value.replace(/\s/g, '')
// If the string contains a comma, treat it as a decimal separator
// (Swedish CSV convention)
if (cleaned.includes(',')) {
cleaned = cleaned.replace(',', '.')
}
const result = parseFloat(cleaned)
if (isNaN(result)) {
return 0
}
return Math.round(result * 100) / 100
}
@@ -0,0 +1,178 @@
import { describe, it, expect } from 'vitest'
import {
calculateOccupancyKPIs,
validateOccupancyEntry,
computePreviousPeriod,
filterEntriesByRange,
getOccupancyColor,
type DailyOccupancyEntry,
} from '../occupancy-calculator'
describe('calculateOccupancyKPIs', () => {
it('calculates basic occupancy percentage', () => {
const entries: DailyOccupancyEntry[] = [
{ date: '2025-01-01', roomsOccupied: 80, roomsOutOfOrder: 2 },
{ date: '2025-01-02', roomsOccupied: 90, roomsOutOfOrder: 2 },
{ date: '2025-01-03', roomsOccupied: 70, roomsOutOfOrder: 3 },
]
const result = calculateOccupancyKPIs(entries, 100)
// totalOccupied = 240, totalCapacity = 300
// 240 / 300 * 100 = 80
expect(result.occupancyPct).toBe(80)
expect(result.totalOccupied).toBe(240)
expect(result.daysWithData).toBe(3)
})
it('calculates average occupied rooms', () => {
const entries: DailyOccupancyEntry[] = [
{ date: '2025-01-01', roomsOccupied: 40, roomsOutOfOrder: 0 },
{ date: '2025-01-02', roomsOccupied: 50, roomsOutOfOrder: 0 },
{ date: '2025-01-03', roomsOccupied: 60, roomsOutOfOrder: 0 },
]
const result = calculateOccupancyKPIs(entries, 100)
// 150 / 3 = 50.0
expect(result.avgOccupied).toBe(50)
})
it('calculates average out-of-order rooms with 1 decimal', () => {
const entries: DailyOccupancyEntry[] = [
{ date: '2025-01-01', roomsOccupied: 50, roomsOutOfOrder: 3 },
{ date: '2025-01-02', roomsOccupied: 50, roomsOutOfOrder: 5 },
{ date: '2025-01-03', roomsOccupied: 50, roomsOutOfOrder: 2 },
]
const result = calculateOccupancyKPIs(entries, 100)
// totalOOO = 10, 10 / 3 = 3.333... -> 3.3
expect(result.avgOutOfOrder).toBe(3.3)
expect(result.totalOutOfOrder).toBe(10)
})
it('calculates average available rooms = totalRooms - occupied - OOO', () => {
const entries: DailyOccupancyEntry[] = [
{ date: '2025-01-01', roomsOccupied: 60, roomsOutOfOrder: 5 },
{ date: '2025-01-02', roomsOccupied: 70, roomsOutOfOrder: 10 },
]
const result = calculateOccupancyKPIs(entries, 100)
// totalCapacity = 200, totalOccupied = 130, totalOOO = 15
// available = (200 - 130 - 15) / 2 = 55 / 2 = 27.5
expect(result.avgAvailable).toBe(27.5)
})
it('returns all zeros for empty entries', () => {
const result = calculateOccupancyKPIs([], 100)
expect(result.occupancyPct).toBe(0)
expect(result.avgOccupied).toBe(0)
expect(result.avgOutOfOrder).toBe(0)
expect(result.avgAvailable).toBe(0)
expect(result.totalOccupied).toBe(0)
expect(result.totalOutOfOrder).toBe(0)
expect(result.daysWithData).toBe(0)
})
it('handles rounding edge cases for percentages and averages', () => {
const entries: DailyOccupancyEntry[] = [
{ date: '2025-01-01', roomsOccupied: 33, roomsOutOfOrder: 1 },
{ date: '2025-01-02', roomsOccupied: 33, roomsOutOfOrder: 1 },
{ date: '2025-01-03', roomsOccupied: 34, roomsOutOfOrder: 1 },
]
const result = calculateOccupancyKPIs(entries, 100)
// totalOccupied = 100, totalCapacity = 300
// 100 / 300 * 100 = 33.333... -> Math.round(33.333... * 10000) / 100 = 33.33 (not 33.34)
expect(result.occupancyPct).toBe(33.33)
// avgOccupied = 100 / 3 = 33.333... -> Math.round(33.333... * 10) / 10 = 33.3
expect(result.avgOccupied).toBe(33.3)
// avgOOO = 3 / 3 = 1.0
expect(result.avgOutOfOrder).toBe(1)
// avgAvailable = (300 - 100 - 3) / 3 = 197 / 3 = 65.666... -> 65.7
expect(result.avgAvailable).toBe(65.7)
})
})
describe('validateOccupancyEntry', () => {
it('returns null for a valid entry (occupied + OOO <= total)', () => {
expect(validateOccupancyEntry(80, 10, 100)).toBeNull()
})
it('returns error when occupied + OOO exceeds total rooms', () => {
const result = validateOccupancyEntry(90, 20, 100)
expect(result).toBe('Occupied (90) + out of order (20) exceeds total rooms (100)')
})
it('returns null when both occupied and OOO are zero', () => {
expect(validateOccupancyEntry(0, 0, 100)).toBeNull()
})
it('returns null when occupied + OOO exactly equals total rooms', () => {
expect(validateOccupancyEntry(80, 20, 100)).toBeNull()
})
})
describe('computePreviousPeriod', () => {
it('computes previous period of the same length immediately before', () => {
// 2025-01-11 to 2025-01-20 = 10 days
const prev = computePreviousPeriod('2025-01-11', '2025-01-20')
expect(prev.start).toBe('2025-01-01')
expect(prev.end).toBe('2025-01-10')
})
it('handles month boundary crossing', () => {
// 2025-02-01 to 2025-02-28 = 28 days
const prev = computePreviousPeriod('2025-02-01', '2025-02-28')
expect(prev.start).toBe('2025-01-04')
expect(prev.end).toBe('2025-01-31')
})
})
describe('filterEntriesByRange', () => {
const entries: DailyOccupancyEntry[] = [
{ date: '2025-01-05', roomsOccupied: 50, roomsOutOfOrder: 2 },
{ date: '2025-01-10', roomsOccupied: 60, roomsOutOfOrder: 3 },
{ date: '2025-01-15', roomsOccupied: 70, roomsOutOfOrder: 1 },
{ date: '2025-01-20', roomsOccupied: 80, roomsOutOfOrder: 0 },
{ date: '2025-02-01', roomsOccupied: 90, roomsOutOfOrder: 5 },
]
it('includes boundary dates and excludes out-of-range entries', () => {
const filtered = filterEntriesByRange(entries, '2025-01-10', '2025-01-20')
expect(filtered).toHaveLength(3)
expect(filtered.map(e => e.date)).toEqual([
'2025-01-10',
'2025-01-15',
'2025-01-20',
])
})
})
describe('getOccupancyColor', () => {
it('returns green for >= 80%', () => {
expect(getOccupancyColor(80)).toBe('bg-green-500')
expect(getOccupancyColor(100)).toBe('bg-green-500')
})
it('returns yellow for 50-79%', () => {
expect(getOccupancyColor(50)).toBe('bg-yellow-500')
expect(getOccupancyColor(79)).toBe('bg-yellow-500')
})
it('returns red for 1-49%', () => {
expect(getOccupancyColor(1)).toBe('bg-red-500')
expect(getOccupancyColor(49)).toBe('bg-red-500')
})
it('returns muted for 0%', () => {
expect(getOccupancyColor(0)).toBe('bg-muted')
})
})
@@ -0,0 +1,151 @@
/**
* Pure calculation functions for hotel occupancy KPIs.
*
* Occupancy % = (totalOccupied / (totalRooms * days)) * 100
*
* All percentages use Math.round(x * 10000) / 100 (2 decimals).
* All 1-decimal averages use Math.round(x * 10) / 10.
*/
export interface DailyOccupancyEntry {
date: string
roomsOccupied: number
roomsOutOfOrder: number
reason?: string
}
export interface OccupancyKPIs {
/** totalOccupied / (totalRooms * days) * 100, rounded to 2 decimals */
occupancyPct: number
/** totalOccupied / days, rounded to 1 decimal */
avgOccupied: number
/** totalOOO / days, rounded to 1 decimal */
avgOutOfOrder: number
/** (totalRooms * days - occupied - OOO) / days, rounded to 1 decimal */
avgAvailable: number
totalOccupied: number
totalOutOfOrder: number
daysWithData: number
}
/**
* Calculate occupancy KPIs from daily entries and total room count.
* Returns all-zero KPIs when entries is empty or totalRooms is 0.
*/
export function calculateOccupancyKPIs(
entries: DailyOccupancyEntry[],
totalRooms: number
): OccupancyKPIs {
const days = entries.length
if (days === 0 || totalRooms <= 0) {
return {
occupancyPct: 0,
avgOccupied: 0,
avgOutOfOrder: 0,
avgAvailable: 0,
totalOccupied: 0,
totalOutOfOrder: 0,
daysWithData: 0,
}
}
const totalOccupied = entries.reduce((sum, e) => sum + e.roomsOccupied, 0)
const totalOutOfOrder = entries.reduce((sum, e) => sum + e.roomsOutOfOrder, 0)
const totalCapacity = totalRooms * days
const occupancyPct = Math.round((totalOccupied / totalCapacity) * 10000) / 100
const avgOccupied = Math.round((totalOccupied / days) * 10) / 10
const avgOutOfOrder = Math.round((totalOutOfOrder / days) * 10) / 10
const avgAvailable =
Math.round(((totalCapacity - totalOccupied - totalOutOfOrder) / days) * 10) / 10
return {
occupancyPct,
avgOccupied,
avgOutOfOrder,
avgAvailable,
totalOccupied,
totalOutOfOrder,
daysWithData: days,
}
}
/**
* Validate that occupied + outOfOrder does not exceed totalRooms.
* Returns an error message string, or null if valid.
*/
export function validateOccupancyEntry(
occupied: number,
outOfOrder: number,
totalRooms: number
): string | null {
if (occupied < 0 || outOfOrder < 0) {
return 'Values cannot be negative'
}
if (occupied + outOfOrder > totalRooms) {
return `Occupied (${occupied}) + out of order (${outOfOrder}) exceeds total rooms (${totalRooms})`
}
return null
}
/**
* Compute the previous period of the same length, immediately before
* the given start date.
*
* For example, if start=2025-01-11 and end=2025-01-20 (10 days),
* the previous period is 2025-01-01 to 2025-01-10.
*/
export function computePreviousPeriod(
start: string,
end: string
): { start: string; end: string } {
const startDate = new Date(start + 'T00:00:00')
const endDate = new Date(end + 'T00:00:00')
// Duration in milliseconds (inclusive: add 1 day)
const durationMs = endDate.getTime() - startDate.getTime() + 24 * 60 * 60 * 1000
const prevEnd = new Date(startDate.getTime() - 24 * 60 * 60 * 1000)
const prevStart = new Date(prevEnd.getTime() - durationMs + 24 * 60 * 60 * 1000)
return {
start: formatDate(prevStart),
end: formatDate(prevEnd),
}
}
/**
* Filter entries whose date falls within [start, end] inclusive.
*/
export function filterEntriesByRange(
entries: DailyOccupancyEntry[],
start: string,
end: string
): DailyOccupancyEntry[] {
return entries.filter((e) => e.date >= start && e.date <= end)
}
/**
* Get a Tailwind color class for a calendar heatmap cell based on occupancy percentage.
*
* - >= 80%: green (high occupancy)
* - 50-79%: yellow (moderate)
* - 1-49%: red (low occupancy)
* - 0%: muted (empty)
*/
export function getOccupancyColor(pct: number): string {
if (pct >= 80) return 'bg-green-500'
if (pct >= 50) return 'bg-yellow-500'
if (pct >= 1) return 'bg-red-500'
return 'bg-muted'
}
function formatDate(date: Date): string {
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
return `${y}-${m}-${d}`
}
@@ -0,0 +1,227 @@
import { describe, it, expect } from 'vitest'
import {
calculateRevparKPIs,
calculateMonthlyRevparTrend,
computePreviousPeriod,
filterEntriesByRange,
type DailyRevparEntry,
} from '../revpar-calculator'
describe('calculateRevparKPIs', () => {
it('calculates basic RevPAR correctly', () => {
const entries: DailyRevparEntry[] = [
{ date: '2025-01-01', roomsSold: 80, roomRevenue: 120000 },
{ date: '2025-01-02', roomsSold: 90, roomRevenue: 135000 },
]
const result = calculateRevparKPIs(entries, 100)
// totalRevenue = 255000, availableRoomNights = 100 * 2 = 200
// revpar = 255000 / 200 = 1275
expect(result.revpar).toBe(1275)
expect(result.totalRevenue).toBe(255000)
expect(result.daysWithData).toBe(2)
})
it('calculates ADR as revenue divided by rooms sold', () => {
const entries: DailyRevparEntry[] = [
{ date: '2025-01-01', roomsSold: 50, roomRevenue: 75000 },
{ date: '2025-01-02', roomsSold: 60, roomRevenue: 96000 },
]
const result = calculateRevparKPIs(entries, 100)
// totalRevenue = 171000, totalRoomsSold = 110
// adr = 171000 / 110 = 1554.545454... -> 1554.55
expect(result.adr).toBe(1554.55)
expect(result.totalRoomsSold).toBe(110)
})
it('calculates occupancy percentage', () => {
const entries: DailyRevparEntry[] = [
{ date: '2025-01-01', roomsSold: 75, roomRevenue: 100000 },
{ date: '2025-01-02', roomsSold: 85, roomRevenue: 110000 },
{ date: '2025-01-03', roomsSold: 80, roomRevenue: 105000 },
]
const result = calculateRevparKPIs(entries, 100)
// totalRoomsSold = 240, availableRoomNights = 100 * 3 = 300
// occupancyPct = (240 / 300) * 100 = 80.00
expect(result.occupancyPct).toBe(80)
})
it('returns ADR = 0 when zero rooms sold', () => {
const entries: DailyRevparEntry[] = [
{ date: '2025-01-01', roomsSold: 0, roomRevenue: 0 },
{ date: '2025-01-02', roomsSold: 0, roomRevenue: 0 },
]
const result = calculateRevparKPIs(entries, 100)
expect(result.adr).toBe(0)
expect(result.revpar).toBe(0)
expect(result.occupancyPct).toBe(0)
expect(result.totalRoomsSold).toBe(0)
})
it('returns all zeros when totalRooms is zero', () => {
const entries: DailyRevparEntry[] = [
{ date: '2025-01-01', roomsSold: 50, roomRevenue: 75000 },
]
const result = calculateRevparKPIs(entries, 0)
expect(result.revpar).toBe(0)
expect(result.adr).toBe(0)
expect(result.occupancyPct).toBe(0)
expect(result.totalRevenue).toBe(0)
expect(result.totalRoomsSold).toBe(0)
expect(result.daysWithData).toBe(0)
})
it('returns all zeros for empty entries', () => {
const result = calculateRevparKPIs([], 100)
expect(result.revpar).toBe(0)
expect(result.adr).toBe(0)
expect(result.occupancyPct).toBe(0)
expect(result.totalRevenue).toBe(0)
expect(result.totalRoomsSold).toBe(0)
expect(result.daysWithData).toBe(0)
})
it('rounds monetary values correctly', () => {
const entries: DailyRevparEntry[] = [
{ date: '2025-01-01', roomsSold: 33, roomRevenue: 49999.99 },
{ date: '2025-01-02', roomsSold: 33, roomRevenue: 49999.99 },
{ date: '2025-01-03', roomsSold: 33, roomRevenue: 49999.99 },
]
const result = calculateRevparKPIs(entries, 50)
// totalRevenue = 149999.97, availableRoomNights = 50 * 3 = 150
// revpar = 149999.97 / 150 = 999.9998 -> 1000.00
expect(result.revpar).toBe(1000)
expect(result.totalRevenue).toBe(149999.97)
// adr = 149999.97 / 99 = 1515.1512... -> 1515.15
expect(result.adr).toBe(1515.15)
// occupancyPct = (99 / 150) * 100 = 66.00
expect(result.occupancyPct).toBe(66)
})
})
describe('calculateMonthlyRevparTrend', () => {
it('groups entries by month and calculates per-month KPIs', () => {
const entries: DailyRevparEntry[] = [
{ date: '2025-01-05', roomsSold: 80, roomRevenue: 100000 },
{ date: '2025-01-15', roomsSold: 90, roomRevenue: 120000 },
{ date: '2025-02-10', roomsSold: 70, roomRevenue: 85000 },
{ date: '2025-03-20', roomsSold: 95, roomRevenue: 140000 },
]
const trend = calculateMonthlyRevparTrend(entries, 100)
expect(trend).toHaveLength(3)
expect(trend[0].month).toBe('2025-01')
expect(trend[1].month).toBe('2025-02')
expect(trend[2].month).toBe('2025-03')
// January: revenue = 220000, rooms sold = 170, days = 2, available = 200
// revpar = 220000 / 200 = 1100
expect(trend[0].revpar).toBe(1100)
// adr = 220000 / 170 = 1294.117647... -> 1294.12
expect(trend[0].adr).toBe(1294.12)
// occupancyPct = (170 / 200) * 100 = 85.00
expect(trend[0].occupancyPct).toBe(85)
// February: revenue = 85000, rooms sold = 70, days = 1, available = 100
expect(trend[1].revpar).toBe(850)
// adr = 85000 / 70 = 1214.285714... -> 1214.29
expect(trend[1].adr).toBe(1214.29)
expect(trend[1].occupancyPct).toBe(70)
// March: revenue = 140000, rooms sold = 95, days = 1, available = 100
expect(trend[2].revpar).toBe(1400)
// adr = 140000 / 95 = 1473.684210... -> 1473.68
expect(trend[2].adr).toBe(1473.68)
expect(trend[2].occupancyPct).toBe(95)
})
it('returns empty array for empty entries', () => {
expect(calculateMonthlyRevparTrend([], 100)).toEqual([])
})
it('returns empty array when totalRooms is zero', () => {
const entries: DailyRevparEntry[] = [
{ date: '2025-01-01', roomsSold: 50, roomRevenue: 75000 },
]
expect(calculateMonthlyRevparTrend(entries, 0)).toEqual([])
})
})
describe('computePreviousPeriod', () => {
it('computes previous period for January (wraps to previous year)', () => {
const prev = computePreviousPeriod('2025-01-01', '2025-01-31')
// 31 days in range. Previous period ends 2024-12-31, starts 2024-12-01.
expect(prev.start).toBe('2024-12-01')
expect(prev.end).toBe('2024-12-31')
})
it('computes previous period for same-month range', () => {
const prev = computePreviousPeriod('2025-06-01', '2025-06-30')
// June 1-30 = 30 days inclusive (29-day span).
// Previous ends May 31, starts May 31 - 29 = May 2.
// May 2-31 = 30 days inclusive — same length.
expect(prev.start).toBe('2025-05-02')
expect(prev.end).toBe('2025-05-31')
})
it('computes previous period for a 7-day range', () => {
const prev = computePreviousPeriod('2025-03-10', '2025-03-16')
// March 10-16 = 7 days inclusive (6-day span).
// Previous ends March 9, starts March 9 - 6 = March 3.
// March 3-9 = 7 days inclusive — same length.
expect(prev.start).toBe('2025-03-03')
expect(prev.end).toBe('2025-03-09')
})
})
describe('filterEntriesByRange', () => {
const entries: DailyRevparEntry[] = [
{ date: '2025-01-01', roomsSold: 80, roomRevenue: 100000 },
{ date: '2025-01-15', roomsSold: 90, roomRevenue: 120000 },
{ date: '2025-01-31', roomsSold: 85, roomRevenue: 110000 },
{ date: '2025-02-01', roomsSold: 70, roomRevenue: 85000 },
{ date: '2025-02-15', roomsSold: 75, roomRevenue: 95000 },
]
it('filters entries within date range inclusive', () => {
const filtered = filterEntriesByRange(entries, '2025-01-01', '2025-01-31')
expect(filtered).toHaveLength(3)
expect(filtered.map(e => e.date)).toEqual([
'2025-01-01',
'2025-01-15',
'2025-01-31',
])
})
it('excludes entries outside the range', () => {
const filtered = filterEntriesByRange(entries, '2025-02-01', '2025-02-28')
expect(filtered).toHaveLength(2)
expect(filtered.map(e => e.date)).toEqual(['2025-02-01', '2025-02-15'])
})
it('returns empty array when no entries match', () => {
const filtered = filterEntriesByRange(entries, '2025-06-01', '2025-06-30')
expect(filtered).toHaveLength(0)
})
})
@@ -0,0 +1,166 @@
/**
* Calculate RevPAR (Revenue Per Available Room) and related hotel KPIs.
*
* RevPAR = Total Room Revenue / Total Available Room-Nights
* ADR = Total Room Revenue / Total Rooms Sold
* Occ% = Total Rooms Sold / Total Available Room-Nights * 100
*
* Pure calculation functions — no side effects, no DB access.
*/
export interface DailyRevparEntry {
date: string
roomsSold: number
roomRevenue: number
}
export interface RevparKPIs {
revpar: number
adr: number
occupancyPct: number
totalRevenue: number
totalRoomsSold: number
daysWithData: number
}
export interface MonthlyRevparTrend {
month: string
revpar: number
adr: number
occupancyPct: number
}
/**
* Calculate RevPAR KPIs from daily entries.
*
* - revpar: totalRevenue / (totalRooms * daysWithData)
* - adr: totalRevenue / totalRoomsSold
* - occupancyPct: totalRoomsSold / (totalRooms * daysWithData) * 100
*/
export function calculateRevparKPIs(
entries: DailyRevparEntry[],
totalRooms: number
): RevparKPIs {
if (totalRooms <= 0 || entries.length === 0) {
return {
revpar: 0,
adr: 0,
occupancyPct: 0,
totalRevenue: 0,
totalRoomsSold: 0,
daysWithData: 0,
}
}
const daysWithData = entries.length
const totalRevenue = entries.reduce((sum, e) => sum + e.roomRevenue, 0)
const totalRoomsSold = entries.reduce((sum, e) => sum + e.roomsSold, 0)
const availableRoomNights = totalRooms * daysWithData
const revpar = Math.round((totalRevenue / availableRoomNights) * 100) / 100
const adr =
totalRoomsSold > 0
? Math.round((totalRevenue / totalRoomsSold) * 100) / 100
: 0
const occupancyPct =
Math.round((totalRoomsSold / availableRoomNights) * 10000) / 100
return {
revpar,
adr,
occupancyPct,
totalRevenue: Math.round(totalRevenue * 100) / 100,
totalRoomsSold,
daysWithData,
}
}
/**
* Calculate monthly trend with RevPAR, ADR, and occupancy for each month.
*
* Groups entries by YYYY-MM and calculates KPIs per group.
* Returns results sorted chronologically.
*/
export function calculateMonthlyRevparTrend(
entries: DailyRevparEntry[],
totalRooms: number
): MonthlyRevparTrend[] {
if (totalRooms <= 0 || entries.length === 0) {
return []
}
const byMonth = new Map<string, DailyRevparEntry[]>()
for (const entry of entries) {
const month = entry.date.slice(0, 7) // YYYY-MM
const group = byMonth.get(month)
if (group) {
group.push(entry)
} else {
byMonth.set(month, [entry])
}
}
const months = Array.from(byMonth.keys()).sort()
return months.map((month) => {
const monthEntries = byMonth.get(month)!
const kpis = calculateRevparKPIs(monthEntries, totalRooms)
return {
month,
revpar: kpis.revpar,
adr: kpis.adr,
occupancyPct: kpis.occupancyPct,
}
})
}
/**
* Compute previous period date range of equal length, immediately before
* the given range.
*
* For example, 2025-01-01 to 2025-01-31 (31 days) produces
* 2024-12-01 to 2024-12-31.
*/
export function computePreviousPeriod(
start: string,
end: string
): { start: string; end: string } {
const startDate = new Date(start + 'T00:00:00')
const endDate = new Date(end + 'T00:00:00')
const durationMs = endDate.getTime() - startDate.getTime()
const durationDays = Math.round(durationMs / (1000 * 60 * 60 * 24))
// Previous period ends the day before the current start
const prevEnd = new Date(startDate.getTime())
prevEnd.setDate(prevEnd.getDate() - 1)
// Previous period starts (durationDays) days before prevEnd
const prevStart = new Date(prevEnd.getTime())
prevStart.setDate(prevStart.getDate() - durationDays)
return {
start: formatDate(prevStart),
end: formatDate(prevEnd),
}
}
/**
* Filter entries to those whose date falls within [start, end] inclusive.
*/
export function filterEntriesByRange(
entries: DailyRevparEntry[],
start: string,
end: string
): DailyRevparEntry[] {
return entries.filter((e) => e.date >= start && e.date <= end)
}
function formatDate(d: Date): string {
const year = d.getFullYear()
const month = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
@@ -0,0 +1,225 @@
import { describe, it, expect } from 'vitest'
import {
calculatePosSummary,
validatePaymentBreakdown,
calculatePaymentTrend,
isVatRateInRange,
type DailySale,
} from '../pos-calculator'
describe('calculatePosSummary', () => {
it('calculates summary statistics from multiple days', () => {
const sales: DailySale[] = [
{ date: '2025-01-01', total: 10000, cash: 2000, card: 5000, swish: 3000, vat: 2400 },
{ date: '2025-01-02', total: 12000, cash: 3000, card: 6000, swish: 3000, vat: 2880 },
{ date: '2025-01-03', total: 8000, cash: 1000, card: 4000, swish: 3000, vat: 1920 },
]
const result = calculatePosSummary(sales)
expect(result.totalSales).toBe(30000)
expect(result.avgPerDay).toBe(10000)
expect(result.totalCash).toBe(6000)
expect(result.totalCard).toBe(15000)
expect(result.totalSwish).toBe(9000)
expect(result.totalVat).toBe(7200)
expect(result.dayCount).toBe(3)
// 6000 / 30000 = 20%
expect(result.cashPercent).toBe(20)
// 15000 / 30000 = 50%
expect(result.cardPercent).toBe(50)
// 9000 / 30000 = 30%
expect(result.swishPercent).toBe(30)
// 7200 / 30000 = 24%
expect(result.avgVatRate).toBe(24)
})
it('returns zeroes for an empty sales array', () => {
const result = calculatePosSummary([])
expect(result.totalSales).toBe(0)
expect(result.avgPerDay).toBe(0)
expect(result.totalCash).toBe(0)
expect(result.totalCard).toBe(0)
expect(result.totalSwish).toBe(0)
expect(result.totalVat).toBe(0)
expect(result.cashPercent).toBe(0)
expect(result.cardPercent).toBe(0)
expect(result.swishPercent).toBe(0)
expect(result.avgVatRate).toBe(0)
expect(result.dayCount).toBe(0)
})
it('handles monetary rounding edge cases', () => {
const sales: DailySale[] = [
{ date: '2025-01-01', total: 33333.33, cash: 11111.11, card: 11111.11, swish: 11111.11, vat: 7999.99 },
]
const result = calculatePosSummary(sales)
expect(result.totalSales).toBe(33333.33)
expect(result.avgPerDay).toBe(33333.33)
expect(result.totalCash).toBe(11111.11)
expect(result.totalCard).toBe(11111.11)
expect(result.totalSwish).toBe(11111.11)
expect(result.totalVat).toBe(7999.99)
// 11111.11 / 33333.33 * 100 = 33.333333... -> 33.33
expect(result.cashPercent).toBe(33.33)
expect(result.cardPercent).toBe(33.33)
expect(result.swishPercent).toBe(33.33)
// 7999.99 / 33333.33 * 100 = 24.00... -> 24
expect(result.avgVatRate).toBe(24)
})
it('handles a single day with zero total', () => {
const sales: DailySale[] = [
{ date: '2025-01-01', total: 0, cash: 0, card: 0, swish: 0, vat: 0 },
]
const result = calculatePosSummary(sales)
expect(result.totalSales).toBe(0)
expect(result.avgPerDay).toBe(0)
expect(result.cashPercent).toBe(0)
expect(result.cardPercent).toBe(0)
expect(result.swishPercent).toBe(0)
expect(result.avgVatRate).toBe(0)
expect(result.dayCount).toBe(1)
})
})
describe('validatePaymentBreakdown', () => {
it('returns null when breakdown is within 5% of total', () => {
// Cash + Card + Swish = 10000, Total = 10000 -> 0% difference
const result = validatePaymentBreakdown(10000, 3000, 5000, 2000)
expect(result).toBeNull()
})
it('returns null when breakdown differs by exactly 5%', () => {
// Total = 10000, payment sum = 9500 -> 5% difference (boundary)
const result = validatePaymentBreakdown(10000, 3000, 4500, 2000)
expect(result).toBeNull()
})
it('returns a warning when breakdown differs by more than 5%', () => {
// Total = 10000, payment sum = 9000 -> 10% difference
const result = validatePaymentBreakdown(10000, 2000, 4000, 3000)
expect(result).not.toBeNull()
expect(result).toContain('10%')
})
it('returns null when total is zero', () => {
const result = validatePaymentBreakdown(0, 100, 200, 300)
expect(result).toBeNull()
})
it('returns null when all payment methods are zero', () => {
const result = validatePaymentBreakdown(10000, 0, 0, 0)
expect(result).toBeNull()
})
it('returns a warning with correct amounts in message', () => {
// Total = 1000, payment sum = 500 -> 50% difference
const result = validatePaymentBreakdown(1000, 200, 200, 100)
expect(result).not.toBeNull()
expect(result).toContain('500')
expect(result).toContain('1000')
expect(result).toContain('50%')
})
})
describe('calculatePaymentTrend', () => {
it('groups sales by month and calculates percentages', () => {
const sales: DailySale[] = [
{ date: '2025-01-05', total: 10000, cash: 2000, card: 5000, swish: 3000, vat: 2400 },
{ date: '2025-01-15', total: 10000, cash: 3000, card: 4000, swish: 3000, vat: 2400 },
{ date: '2025-02-10', total: 20000, cash: 4000, card: 10000, swish: 6000, vat: 4800 },
]
const result = calculatePaymentTrend(sales)
// Sorted descending: February first
expect(result).toHaveLength(2)
expect(result[0].month).toBe('2025-02')
expect(result[1].month).toBe('2025-01')
// February: single day
expect(result[0].cash).toBe(4000)
expect(result[0].card).toBe(10000)
expect(result[0].swish).toBe(6000)
// 4000 / 20000 = 20%
expect(result[0].cashPct).toBe(20)
// 10000 / 20000 = 50%
expect(result[0].cardPct).toBe(50)
// 6000 / 20000 = 30%
expect(result[0].swishPct).toBe(30)
// January: two days aggregated
expect(result[1].cash).toBe(5000)
expect(result[1].card).toBe(9000)
expect(result[1].swish).toBe(6000)
// 5000 / 20000 = 25%
expect(result[1].cashPct).toBe(25)
// 9000 / 20000 = 45%
expect(result[1].cardPct).toBe(45)
// 6000 / 20000 = 30%
expect(result[1].swishPct).toBe(30)
})
it('returns empty array for empty sales', () => {
const result = calculatePaymentTrend([])
expect(result).toEqual([])
})
it('handles monetary rounding in trend aggregation', () => {
const sales: DailySale[] = [
{ date: '2025-03-01', total: 3333.33, cash: 1111.11, card: 1111.11, swish: 1111.11, vat: 800 },
{ date: '2025-03-02', total: 3333.34, cash: 1111.12, card: 1111.11, swish: 1111.11, vat: 800 },
]
const result = calculatePaymentTrend(sales)
expect(result).toHaveLength(1)
expect(result[0].month).toBe('2025-03')
// 1111.11 + 1111.12 = 2222.23
expect(result[0].cash).toBe(2222.23)
// 1111.11 + 1111.11 = 2222.22
expect(result[0].card).toBe(2222.22)
expect(result[0].swish).toBe(2222.22)
})
})
describe('isVatRateInRange', () => {
it('returns true when VAT rate is within 20-30%', () => {
// 2400 / 10000 = 24%
expect(isVatRateInRange(2400, 10000)).toBe(true)
})
it('returns true at the lower boundary (20%)', () => {
// 2000 / 10000 = 20%
expect(isVatRateInRange(2000, 10000)).toBe(true)
})
it('returns true at the upper boundary (30%)', () => {
// 3000 / 10000 = 30%
expect(isVatRateInRange(3000, 10000)).toBe(true)
})
it('returns false when VAT rate is below 20%', () => {
// 1500 / 10000 = 15%
expect(isVatRateInRange(1500, 10000)).toBe(false)
})
it('returns false when VAT rate is above 30%', () => {
// 3500 / 10000 = 35%
expect(isVatRateInRange(3500, 10000)).toBe(false)
})
it('returns false when subtotal is zero', () => {
expect(isVatRateInRange(100, 0)).toBe(false)
})
it('returns false when subtotal is negative', () => {
expect(isVatRateInRange(100, -500)).toBe(false)
})
})
@@ -0,0 +1,183 @@
/**
* Pure calculation functions for the POS Import extension.
*
* Computes summary statistics, payment method trends, and validation
* for daily POS (point-of-sale) sales data imported from restaurant
* cash register systems.
*
* All monetary calculations use Math.round(x * 100) / 100 per project convention.
*/
export interface DailySale {
date: string
total: number
cash: number
card: number
swish: number
vat: number
}
export interface PosSummary {
totalSales: number
avgPerDay: number
totalCash: number
totalCard: number
totalSwish: number
totalVat: number
cashPercent: number
cardPercent: number
swishPercent: number
avgVatRate: number
dayCount: number
}
export interface PaymentMethodTrend {
month: string
cash: number
card: number
swish: number
cashPct: number
cardPct: number
swishPct: number
}
/**
* Calculate summary statistics from daily sales data.
*
* Returns totals, averages, and payment method percentage breakdowns.
* All monetary values are rounded to 2 decimal places.
* Percentages are rounded to 2 decimal places.
*/
export function calculatePosSummary(sales: DailySale[]): PosSummary {
if (sales.length === 0) {
return {
totalSales: 0,
avgPerDay: 0,
totalCash: 0,
totalCard: 0,
totalSwish: 0,
totalVat: 0,
cashPercent: 0,
cardPercent: 0,
swishPercent: 0,
avgVatRate: 0,
dayCount: 0,
}
}
const totalSales = sales.reduce((sum, d) => sum + d.total, 0)
const totalCash = sales.reduce((sum, d) => sum + d.cash, 0)
const totalCard = sales.reduce((sum, d) => sum + d.card, 0)
const totalSwish = sales.reduce((sum, d) => sum + d.swish, 0)
const totalVat = sales.reduce((sum, d) => sum + d.vat, 0)
const avgPerDay = Math.round(totalSales / sales.length * 100) / 100
const cashPercent = totalSales > 0
? Math.round(totalCash / totalSales * 10000) / 100
: 0
const cardPercent = totalSales > 0
? Math.round(totalCard / totalSales * 10000) / 100
: 0
const swishPercent = totalSales > 0
? Math.round(totalSwish / totalSales * 10000) / 100
: 0
const avgVatRate = totalSales > 0
? Math.round(totalVat / totalSales * 10000) / 100
: 0
return {
totalSales: Math.round(totalSales * 100) / 100,
avgPerDay,
totalCash: Math.round(totalCash * 100) / 100,
totalCard: Math.round(totalCard * 100) / 100,
totalSwish: Math.round(totalSwish * 100) / 100,
totalVat: Math.round(totalVat * 100) / 100,
cashPercent,
cardPercent,
swishPercent,
avgVatRate,
dayCount: sales.length,
}
}
/**
* Validate that the payment method breakdown matches the total.
*
* Returns a warning message if the sum of cash + card + swish differs
* from the total by more than 5%. Returns null if within tolerance
* or if the total is zero.
*/
export function validatePaymentBreakdown(
total: number,
cash: number,
card: number,
swish: number
): string | null {
if (total <= 0) return null
const paymentSum = Math.round((cash + card + swish) * 100) / 100
const roundedTotal = Math.round(total * 100) / 100
if (paymentSum === 0) return null
const diffPct = Math.round(
Math.abs(paymentSum - roundedTotal) / roundedTotal * 10000
) / 100
if (diffPct > 5) {
return `Payment breakdown (${paymentSum}) differs from total (${roundedTotal}) by ${diffPct}%`
}
return null
}
/**
* Calculate monthly payment method trends from daily sales data.
*
* Groups sales by month (YYYY-MM) and computes the absolute amounts
* and percentage share for each payment method. Results are sorted
* in descending chronological order (newest month first).
*/
export function calculatePaymentTrend(sales: DailySale[]): PaymentMethodTrend[] {
if (sales.length === 0) return []
const map = new Map<string, { cash: number; card: number; swish: number; total: number }>()
for (const d of sales) {
const month = d.date.slice(0, 7)
const existing = map.get(month) ?? { cash: 0, card: 0, swish: 0, total: 0 }
existing.cash += d.cash
existing.card += d.card
existing.swish += d.swish
existing.total += d.total
map.set(month, existing)
}
return Array.from(map.entries())
.sort(([a], [b]) => b.localeCompare(a))
.map(([month, vals]) => ({
month,
cash: Math.round(vals.cash * 100) / 100,
card: Math.round(vals.card * 100) / 100,
swish: Math.round(vals.swish * 100) / 100,
cashPct: vals.total > 0 ? Math.round(vals.cash / vals.total * 10000) / 100 : 0,
cardPct: vals.total > 0 ? Math.round(vals.card / vals.total * 10000) / 100 : 0,
swishPct: vals.total > 0 ? Math.round(vals.swish / vals.total * 10000) / 100 : 0,
}))
}
/**
* Check if a VAT rate falls within the expected range for Swedish restaurants.
*
* Swedish restaurant VAT is typically 12% on food and 25% on alcohol.
* The blended effective rate on total sales (including VAT) usually
* falls between 20% and 30%. Returns true if within that range.
*
* A subtotal of zero returns false (cannot determine rate).
*/
export function isVatRateInRange(vatAmount: number, subtotal: number): boolean {
if (subtotal <= 0) return false
const rate = Math.round(vatAmount / subtotal * 10000) / 100
return rate >= 20 && rate <= 30
}
@@ -0,0 +1,196 @@
import { describe, it, expect } from 'vitest'
import {
calculateTipSummary,
calculateEmployeeTotals,
calculateEqualSplit,
calculateHoursSplit,
calculateCustomSplit,
calculateMonthlyTipTrend,
type TipEntry,
} from '../tip-calculator'
describe('calculateTipSummary', () => {
it('calculates total tips, average per shift, and entry count', () => {
const entries: TipEntry[] = [
{ date: '2025-01-10', shift: 'lunch', employeeId: 'e1', employeeName: 'Anna', amount: 350 },
{ date: '2025-01-10', shift: 'dinner', employeeId: 'e2', employeeName: 'Björn', amount: 520 },
{ date: '2025-01-11', shift: 'lunch', employeeId: 'e1', employeeName: 'Anna', amount: 430 },
]
const result = calculateTipSummary(entries)
expect(result.totalTips).toBe(1300)
expect(result.avgPerShift).toBe(433.33)
expect(result.entryCount).toBe(3)
})
it('returns zeros for an empty entries array', () => {
const result = calculateTipSummary([])
expect(result.totalTips).toBe(0)
expect(result.avgPerShift).toBe(0)
expect(result.entryCount).toBe(0)
})
})
describe('calculateEmployeeTotals', () => {
it('calculates per-employee totals with multiple employees', () => {
const entries: TipEntry[] = [
{ date: '2025-01-10', shift: 'lunch', employeeId: 'e1', employeeName: 'Anna', amount: 300 },
{ date: '2025-01-10', shift: 'dinner', employeeId: 'e2', employeeName: 'Björn', amount: 500 },
{ date: '2025-01-11', shift: 'lunch', employeeId: 'e1', employeeName: 'Anna', amount: 400 },
{ date: '2025-01-11', shift: 'dinner', employeeId: 'e2', employeeName: 'Björn', amount: 600 },
{ date: '2025-01-12', shift: 'lunch', employeeId: 'e3', employeeName: 'Clara', amount: 250 },
]
const result = calculateEmployeeTotals(entries)
// Sorted by total descending: Björn (1100), Anna (700), Clara (250)
expect(result).toHaveLength(3)
expect(result[0]).toEqual({ employeeId: 'e2', name: 'Björn', total: 1100, count: 2, average: 550 })
expect(result[1]).toEqual({ employeeId: 'e1', name: 'Anna', total: 700, count: 2, average: 350 })
expect(result[2]).toEqual({ employeeId: 'e3', name: 'Clara', total: 250, count: 1, average: 250 })
})
it('calculates correct average for a single employee', () => {
const entries: TipEntry[] = [
{ date: '2025-01-10', shift: 'lunch', employeeId: 'e1', employeeName: 'Anna', amount: 333.33 },
{ date: '2025-01-11', shift: 'lunch', employeeId: 'e1', employeeName: 'Anna', amount: 666.67 },
]
const result = calculateEmployeeTotals(entries)
expect(result).toHaveLength(1)
expect(result[0].total).toBe(1000)
expect(result[0].count).toBe(2)
expect(result[0].average).toBe(500)
})
})
describe('calculateEqualSplit', () => {
it('splits pool equally between 2 employees', () => {
const result = calculateEqualSplit(1000, [
{ id: 'e1', name: 'Anna' },
{ id: 'e2', name: 'Björn' },
])
expect(result).toHaveLength(2)
expect(result[0]).toEqual({ employeeId: 'e1', name: 'Anna', share: 500 })
expect(result[1]).toEqual({ employeeId: 'e2', name: 'Björn', share: 500 })
})
it('splits pool equally between 3 employees with rounding', () => {
const result = calculateEqualSplit(1000, [
{ id: 'e1', name: 'Anna' },
{ id: 'e2', name: 'Björn' },
{ id: 'e3', name: 'Clara' },
])
expect(result).toHaveLength(3)
// 1000 / 3 = 333.333... -> rounded to 333.33
expect(result[0].share).toBe(333.33)
expect(result[1].share).toBe(333.33)
expect(result[2].share).toBe(333.33)
})
it('returns empty array for no employees', () => {
const result = calculateEqualSplit(1000, [])
expect(result).toEqual([])
})
})
describe('calculateHoursSplit', () => {
it('distributes pool proportionally by hours worked', () => {
const result = calculateHoursSplit(1200, [
{ id: 'e1', name: 'Anna', hours: 8 },
{ id: 'e2', name: 'Björn', hours: 4 },
])
expect(result).toHaveLength(2)
// Anna: 1200 * (8/12) = 800
expect(result[0]).toEqual({ employeeId: 'e1', name: 'Anna', share: 800 })
// Björn: 1200 * (4/12) = 400
expect(result[1]).toEqual({ employeeId: 'e2', name: 'Björn', share: 400 })
})
it('returns zero shares when all employees have zero hours', () => {
const result = calculateHoursSplit(1000, [
{ id: 'e1', name: 'Anna', hours: 0 },
{ id: 'e2', name: 'Björn', hours: 0 },
])
expect(result).toHaveLength(2)
expect(result[0].share).toBe(0)
expect(result[1].share).toBe(0)
})
it('handles rounding correctly with uneven hours', () => {
const result = calculateHoursSplit(1000, [
{ id: 'e1', name: 'Anna', hours: 7 },
{ id: 'e2', name: 'Björn', hours: 3 },
{ id: 'e3', name: 'Clara', hours: 5 },
])
// Total hours: 15
// Anna: 1000 * 7/15 = 466.666... -> 466.67
expect(result[0].share).toBe(466.67)
// Björn: 1000 * 3/15 = 200
expect(result[1].share).toBe(200)
// Clara: 1000 * 5/15 = 333.333... -> 333.33
expect(result[2].share).toBe(333.33)
})
})
describe('calculateCustomSplit', () => {
it('distributes pool by custom percentages', () => {
const result = calculateCustomSplit(2000, [
{ id: 'e1', name: 'Anna', pct: 50 },
{ id: 'e2', name: 'Björn', pct: 30 },
{ id: 'e3', name: 'Clara', pct: 20 },
])
expect(result).toHaveLength(3)
expect(result[0]).toEqual({ employeeId: 'e1', name: 'Anna', share: 1000 })
expect(result[1]).toEqual({ employeeId: 'e2', name: 'Björn', share: 600 })
expect(result[2]).toEqual({ employeeId: 'e3', name: 'Clara', share: 400 })
})
it('handles rounding with fractional percentages', () => {
const result = calculateCustomSplit(1000, [
{ id: 'e1', name: 'Anna', pct: 33.33 },
{ id: 'e2', name: 'Björn', pct: 33.33 },
{ id: 'e3', name: 'Clara', pct: 33.34 },
])
// 1000 * 33.33 / 100 = 333.3 -> 333.3
expect(result[0].share).toBe(333.3)
expect(result[1].share).toBe(333.3)
// 1000 * 33.34 / 100 = 333.4 -> 333.4
expect(result[2].share).toBe(333.4)
})
})
describe('calculateMonthlyTipTrend', () => {
it('aggregates tips by month in chronological order', () => {
const entries: TipEntry[] = [
{ date: '2025-03-05', shift: 'lunch', employeeId: 'e1', employeeName: 'Anna', amount: 200 },
{ date: '2025-01-10', shift: 'lunch', employeeId: 'e1', employeeName: 'Anna', amount: 300 },
{ date: '2025-01-15', shift: 'dinner', employeeId: 'e2', employeeName: 'Björn', amount: 500 },
{ date: '2025-02-10', shift: 'lunch', employeeId: 'e1', employeeName: 'Anna', amount: 400 },
{ date: '2025-03-10', shift: 'dinner', employeeId: 'e2', employeeName: 'Björn', amount: 350 },
]
const result = calculateMonthlyTipTrend(entries)
expect(result).toEqual([
{ month: '2025-01', total: 800 },
{ month: '2025-02', total: 400 },
{ month: '2025-03', total: 550 },
])
})
it('returns empty array for no entries', () => {
const result = calculateMonthlyTipTrend([])
expect(result).toEqual([])
})
})
@@ -0,0 +1,180 @@
/**
* Pure calculation functions for the Tip Tracking extension.
* Handles tip summaries, per-employee totals, pool distribution
* (equal, hours-based, custom percentage), and monthly trends.
*
* All monetary values use Math.round(x * 100) / 100 for precision.
*/
export interface TipEntry {
date: string
shift: string
employeeId: string
employeeName: string
amount: number
}
export interface TipSummary {
totalTips: number
avgPerShift: number
entryCount: number
}
export interface EmployeeTipSummary {
employeeId: string
name: string
total: number
count: number
average: number
}
export interface PoolDistribution {
employeeId: string
name: string
share: number
}
/**
* Calculate tip summary for a set of entries.
* Returns total tips, average per shift, and entry count.
*/
export function calculateTipSummary(entries: TipEntry[]): TipSummary {
if (entries.length === 0) {
return { totalTips: 0, avgPerShift: 0, entryCount: 0 }
}
const totalTips = entries.reduce((sum, e) => sum + e.amount, 0)
const roundedTotal = Math.round(totalTips * 100) / 100
const avgPerShift = Math.round((roundedTotal / entries.length) * 100) / 100
return {
totalTips: roundedTotal,
avgPerShift,
entryCount: entries.length,
}
}
/**
* Calculate per-employee totals from tip entries.
* Groups entries by employeeId and returns sorted by total descending.
*/
export function calculateEmployeeTotals(entries: TipEntry[]): EmployeeTipSummary[] {
const map = new Map<string, { name: string; total: number; count: number }>()
for (const entry of entries) {
const existing = map.get(entry.employeeId)
if (existing) {
existing.total += entry.amount
existing.count += 1
} else {
map.set(entry.employeeId, {
name: entry.employeeName,
total: entry.amount,
count: 1,
})
}
}
const results: EmployeeTipSummary[] = []
for (const [employeeId, data] of map) {
const total = Math.round(data.total * 100) / 100
const average = Math.round((total / data.count) * 100) / 100
results.push({
employeeId,
name: data.name,
total,
count: data.count,
average,
})
}
return results.sort((a, b) => b.total - a.total)
}
/**
* Calculate pool distribution using equal split.
* Each employee receives an equal share of the pool amount.
*/
export function calculateEqualSplit(
poolAmount: number,
employees: { id: string; name: string }[]
): PoolDistribution[] {
if (employees.length === 0) {
return []
}
const share = Math.round((poolAmount / employees.length) * 100) / 100
return employees.map(e => ({
employeeId: e.id,
name: e.name,
share,
}))
}
/**
* Calculate pool distribution by hours worked.
* Each employee's share is proportional to their hours.
* Employees with zero hours receive zero.
*/
export function calculateHoursSplit(
poolAmount: number,
employeeHours: { id: string; name: string; hours: number }[]
): PoolDistribution[] {
if (employeeHours.length === 0) {
return []
}
const totalHours = employeeHours.reduce((sum, e) => sum + e.hours, 0)
if (totalHours === 0) {
return employeeHours.map(e => ({
employeeId: e.id,
name: e.name,
share: 0,
}))
}
return employeeHours.map(e => ({
employeeId: e.id,
name: e.name,
share: Math.round((poolAmount * (e.hours / totalHours)) * 100) / 100,
}))
}
/**
* Calculate pool distribution by custom percentages.
* Each employee's share is determined by their assigned percentage.
* Percentages do not need to sum to 100 — each is applied independently.
*/
export function calculateCustomSplit(
poolAmount: number,
employeePcts: { id: string; name: string; pct: number }[]
): PoolDistribution[] {
return employeePcts.map(e => ({
employeeId: e.id,
name: e.name,
share: Math.round((poolAmount * (e.pct / 100)) * 100) / 100,
}))
}
/**
* Calculate monthly trend — total tips per month.
* Returns an array sorted by month (YYYY-MM) in ascending order.
*/
export function calculateMonthlyTipTrend(entries: TipEntry[]): { month: string; total: number }[] {
const map = new Map<string, number>()
for (const entry of entries) {
// Extract YYYY-MM from date string
const month = entry.date.substring(0, 7)
map.set(month, (map.get(month) ?? 0) + entry.amount)
}
const results: { month: string; total: number }[] = []
for (const [month, total] of map) {
results.push({ month, total: Math.round(total * 100) / 100 })
}
return results.sort((a, b) => a.month.localeCompare(b.month))
}
@@ -0,0 +1,193 @@
import { describe, it, expect } from 'vitest'
import {
calculateUtilization,
calculateProjectTimeStats,
buildWeeklyGrid,
calculateMonthlySummary,
getEffectiveRate,
type TimeEntry,
} from '../billable-hours-calculator'
describe('calculateUtilization', () => {
it('calculates basic utilization KPIs', () => {
const entries: TimeEntry[] = [
{ date: '2025-03-03', projectId: 'p1', hours: 6, billable: true },
{ date: '2025-03-03', projectId: 'p1', hours: 2, billable: false },
]
const result = calculateUtilization(entries, 1000)
expect(result.totalHours).toBe(8)
expect(result.billableHours).toBe(6)
expect(result.nonBillableHours).toBe(2)
expect(result.utilization).toBe(75)
expect(result.revenue).toBe(6000)
expect(result.effectiveRate).toBe(750)
})
it('returns all zeros for empty entries', () => {
const result = calculateUtilization([], 1500)
expect(result.totalHours).toBe(0)
expect(result.billableHours).toBe(0)
expect(result.nonBillableHours).toBe(0)
expect(result.utilization).toBe(0)
expect(result.effectiveRate).toBe(0)
expect(result.revenue).toBe(0)
})
it('returns 100% utilization when all hours are billable', () => {
const entries: TimeEntry[] = [
{ date: '2025-03-03', projectId: 'p1', hours: 4, billable: true },
{ date: '2025-03-04', projectId: 'p2', hours: 4, billable: true },
]
const result = calculateUtilization(entries, 800)
expect(result.utilization).toBe(100)
expect(result.billableHours).toBe(8)
expect(result.nonBillableHours).toBe(0)
expect(result.revenue).toBe(6400)
})
it('returns 0% utilization and zero revenue when all hours are non-billable', () => {
const entries: TimeEntry[] = [
{ date: '2025-03-03', projectId: 'p1', hours: 3, billable: false },
{ date: '2025-03-04', projectId: 'p1', hours: 5, billable: false },
]
const result = calculateUtilization(entries, 1200)
expect(result.utilization).toBe(0)
expect(result.billableHours).toBe(0)
expect(result.nonBillableHours).toBe(8)
expect(result.revenue).toBe(0)
expect(result.effectiveRate).toBe(0)
})
it('calculates revenue as billable hours times rate', () => {
const entries: TimeEntry[] = [
{ date: '2025-03-03', projectId: 'p1', hours: 10, billable: true },
{ date: '2025-03-04', projectId: 'p1', hours: 5, billable: false },
]
const result = calculateUtilization(entries, 950)
expect(result.revenue).toBe(9500)
// effectiveRate = 9500 / 15 = 633.33
expect(result.effectiveRate).toBe(633.33)
})
})
describe('calculateProjectTimeStats', () => {
it('calculates per-project stats for multiple projects', () => {
const entries: TimeEntry[] = [
{ date: '2025-03-03', projectId: 'alpha', hours: 4, billable: true },
{ date: '2025-03-03', projectId: 'alpha', hours: 1, billable: false },
{ date: '2025-03-03', projectId: 'beta', hours: 3, billable: true },
{ date: '2025-03-04', projectId: 'beta', hours: 2, billable: true },
]
const result = calculateProjectTimeStats(entries)
expect(result).toHaveLength(2)
const alpha = result.find(p => p.projectId === 'alpha')!
expect(alpha.totalHours).toBe(5)
expect(alpha.billableHours).toBe(4)
expect(alpha.utilization).toBe(80)
const beta = result.find(p => p.projectId === 'beta')!
expect(beta.totalHours).toBe(5)
expect(beta.billableHours).toBe(5)
expect(beta.utilization).toBe(100)
})
it('returns empty array for empty entries', () => {
const result = calculateProjectTimeStats([])
expect(result).toEqual([])
})
})
describe('buildWeeklyGrid', () => {
const weekDates = [
'2025-03-03', // Mon
'2025-03-04', // Tue
'2025-03-05', // Wed
'2025-03-06', // Thu
'2025-03-07', // Fri
'2025-03-08', // Sat
'2025-03-09', // Sun
]
it('builds grid with entries on different days', () => {
const entries: TimeEntry[] = [
{ date: '2025-03-03', projectId: 'p1', hours: 4, billable: true },
{ date: '2025-03-04', projectId: 'p1', hours: 6, billable: true },
{ date: '2025-03-03', projectId: 'p2', hours: 2, billable: true },
{ date: '2025-03-05', projectId: 'p2', hours: 3, billable: false },
]
const result = buildWeeklyGrid(entries, weekDates, ['p1', 'p2'])
expect(result.projects).toHaveLength(2)
const p1 = result.projects.find(p => p.projectId === 'p1')!
expect(p1.days).toEqual([4, 6, 0, 0, 0, 0, 0])
const p2 = result.projects.find(p => p.projectId === 'p2')!
expect(p2.days).toEqual([2, 0, 3, 0, 0, 0, 0])
expect(result.dayTotals).toEqual([6, 6, 3, 0, 0, 0, 0])
})
it('returns zeros for an empty week', () => {
const result = buildWeeklyGrid([], weekDates, ['p1'])
expect(result.projects).toHaveLength(1)
expect(result.projects[0].days).toEqual([0, 0, 0, 0, 0, 0, 0])
expect(result.dayTotals).toEqual([0, 0, 0, 0, 0, 0, 0])
})
})
describe('calculateMonthlySummary', () => {
it('groups entries into monthly summaries across two months', () => {
const entries: TimeEntry[] = [
{ date: '2025-01-10', projectId: 'p1', hours: 40, billable: true },
{ date: '2025-01-15', projectId: 'p1', hours: 8, billable: false },
{ date: '2025-02-05', projectId: 'p1', hours: 32, billable: true },
{ date: '2025-02-10', projectId: 'p1', hours: 4, billable: false },
]
const result = calculateMonthlySummary(entries, 1000)
expect(result).toHaveLength(2)
expect(result[0].month).toBe('2025-01')
expect(result[0].billableHours).toBe(40)
expect(result[0].nonBillableHours).toBe(8)
expect(result[0].totalHours).toBe(48)
expect(result[0].revenue).toBe(40000)
expect(result[1].month).toBe('2025-02')
expect(result[1].billableHours).toBe(32)
expect(result[1].nonBillableHours).toBe(4)
expect(result[1].totalHours).toBe(36)
expect(result[1].revenue).toBe(32000)
})
it('returns empty array for no entries', () => {
const result = calculateMonthlySummary([], 1000)
expect(result).toEqual([])
})
})
describe('getEffectiveRate', () => {
it('returns project rate when defined', () => {
expect(getEffectiveRate(1500, 1000)).toBe(1500)
})
it('falls back to global rate when project rate is undefined', () => {
expect(getEffectiveRate(undefined, 1000)).toBe(1000)
})
})
@@ -0,0 +1,190 @@
/**
* Pure calculation functions for billable hours tracking.
* No side effects, no database calls — just math on time entries.
*/
export interface TimeEntry {
date: string
projectId: string
hours: number
billable: boolean
}
export interface UtilizationKPIs {
totalHours: number
billableHours: number
nonBillableHours: number
utilization: number // billable / total * 100, or 0
effectiveRate: number // (billableHours * hourlyRate) / totalHours, or 0
revenue: number // billableHours * hourlyRate
}
export interface ProjectTimeStats {
projectId: string
totalHours: number
billableHours: number
utilization: number
}
export interface WeeklyGrid {
projects: { projectId: string; days: number[] }[] // days[0..6] = Mon..Sun
dayTotals: number[] // 7 day totals
}
export interface MonthlyPeriodSummary {
month: string
billableHours: number
nonBillableHours: number
totalHours: number
revenue: number
}
/**
* Calculate utilization KPIs for a set of time entries.
*/
export function calculateUtilization(
entries: TimeEntry[],
hourlyRate: number
): UtilizationKPIs {
const totalHours = Math.round(
entries.reduce((sum, e) => sum + e.hours, 0) * 100
) / 100
const billableHours = Math.round(
entries.filter(e => e.billable).reduce((sum, e) => sum + e.hours, 0) * 100
) / 100
const nonBillableHours = Math.round((totalHours - billableHours) * 100) / 100
const utilization = totalHours > 0
? Math.round((billableHours / totalHours) * 10000) / 100
: 0
const revenue = Math.round(billableHours * hourlyRate * 100) / 100
const effectiveRate = totalHours > 0
? Math.round((revenue / totalHours) * 100) / 100
: 0
return {
totalHours,
billableHours,
nonBillableHours,
utilization,
effectiveRate,
revenue,
}
}
/**
* Calculate per-project time statistics.
*/
export function calculateProjectTimeStats(
entries: TimeEntry[]
): ProjectTimeStats[] {
const projectMap = new Map<string, { total: number; billable: number }>()
for (const entry of entries) {
const existing = projectMap.get(entry.projectId) ?? { total: 0, billable: 0 }
existing.total += entry.hours
if (entry.billable) {
existing.billable += entry.hours
}
projectMap.set(entry.projectId, existing)
}
return Array.from(projectMap.entries()).map(([projectId, stats]) => {
const totalHours = Math.round(stats.total * 100) / 100
const billableHours = Math.round(stats.billable * 100) / 100
const utilization = totalHours > 0
? Math.round((billableHours / totalHours) * 10000) / 100
: 0
return { projectId, totalHours, billableHours, utilization }
})
}
/**
* Build a weekly timesheet grid for the given week dates (7 date strings,
* Mon-Sun) and project IDs. Each project row has 7 day values. dayTotals
* sums all projects per day.
*/
export function buildWeeklyGrid(
entries: TimeEntry[],
weekDates: string[],
projectIds: string[]
): WeeklyGrid {
const dateIndex = new Map<string, number>()
for (let i = 0; i < weekDates.length; i++) {
dateIndex.set(weekDates[i], i)
}
const projects = projectIds.map(projectId => {
const days = [0, 0, 0, 0, 0, 0, 0]
for (const entry of entries) {
if (entry.projectId !== projectId) continue
const idx = dateIndex.get(entry.date)
if (idx !== undefined) {
days[idx] = Math.round((days[idx] + entry.hours) * 100) / 100
}
}
return { projectId, days }
})
const dayTotals = [0, 0, 0, 0, 0, 0, 0]
for (const project of projects) {
for (let i = 0; i < 7; i++) {
dayTotals[i] = Math.round((dayTotals[i] + project.days[i]) * 100) / 100
}
}
return { projects, dayTotals }
}
/**
* Calculate monthly period summaries, grouped by YYYY-MM.
*/
export function calculateMonthlySummary(
entries: TimeEntry[],
hourlyRate: number
): MonthlyPeriodSummary[] {
const monthMap = new Map<string, { billable: number; nonBillable: number }>()
for (const entry of entries) {
// Extract YYYY-MM from the date string
const month = entry.date.substring(0, 7)
const existing = monthMap.get(month) ?? { billable: 0, nonBillable: 0 }
if (entry.billable) {
existing.billable += entry.hours
} else {
existing.nonBillable += entry.hours
}
monthMap.set(month, existing)
}
return Array.from(monthMap.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([month, stats]) => {
const billableHours = Math.round(stats.billable * 100) / 100
const nonBillableHours = Math.round(stats.nonBillable * 100) / 100
const totalHours = Math.round((billableHours + nonBillableHours) * 100) / 100
const revenue = Math.round(billableHours * hourlyRate * 100) / 100
return { month, billableHours, nonBillableHours, totalHours, revenue }
})
}
/**
* Get the effective rate for a project. A project-specific rate overrides
* the global rate. If the project rate is undefined, fall back to the global rate.
*/
export function getEffectiveRate(
projectRate: number | undefined,
globalRate: number
): number {
return projectRate !== undefined ? projectRate : globalRate
}
@@ -0,0 +1,206 @@
import { describe, it, expect } from 'vitest'
import {
calculateProjectBillingStats,
calculateAggregateStats,
type BillingEntry,
type CostEntry,
} from '../billing-calculator'
describe('calculateProjectBillingStats', () => {
const makeBilling = (overrides: Partial<BillingEntry> = {}): BillingEntry => ({
projectId: 'proj-1',
amount: 10000,
date: '2025-03-15',
invoiced: true,
...overrides,
})
const makeCost = (overrides: Partial<CostEntry> = {}): CostEntry => ({
projectId: 'proj-1',
amount: 5000,
date: '2025-03-10',
category: 'labor',
...overrides,
})
it('calculates CORRECT margin as (revenue - costs) / revenue * 100', () => {
const billings = [makeBilling({ amount: 100000 })]
const costs = [makeCost({ amount: 60000 })]
const result = calculateProjectBillingStats(billings, costs, 200000)
// Margin = (100000 - 60000) / 100000 * 100 = 40%
expect(result.margin).toBe(40)
// NOT the buggy formula: (budget - billed) / budget = (200000 - 100000) / 200000 = 50%
expect(result.margin).not.toBe(50)
})
it('returns 100% margin when there are no costs', () => {
const billings = [makeBilling({ amount: 80000 })]
const costs: CostEntry[] = []
const result = calculateProjectBillingStats(billings, costs, 100000)
// (80000 - 0) / 80000 * 100 = 100%
expect(result.margin).toBe(100)
expect(result.totalCosts).toBe(0)
})
it('returns negative margin when costs exceed revenue', () => {
const billings = [makeBilling({ amount: 50000 })]
const costs = [makeCost({ amount: 75000 })]
const result = calculateProjectBillingStats(billings, costs, 100000)
// (50000 - 75000) / 50000 * 100 = -50%
expect(result.margin).toBe(-50)
})
it('returns 0% margin when revenue is zero', () => {
const billings: BillingEntry[] = []
const costs = [makeCost({ amount: 10000 })]
const result = calculateProjectBillingStats(billings, costs, 50000)
expect(result.margin).toBe(0)
expect(result.totalBilled).toBe(0)
expect(result.totalCosts).toBe(10000)
})
it('calculates budget used as billed / budget * 100', () => {
const billings = [makeBilling({ amount: 60000 })]
const costs: CostEntry[] = []
const result = calculateProjectBillingStats(billings, costs, 120000)
// 60000 / 120000 * 100 = 50%
expect(result.budgetUsed).toBe(50)
})
it('caps budget used at 100% when billed exceeds budget', () => {
const billings = [makeBilling({ amount: 150000 })]
const costs: CostEntry[] = []
const result = calculateProjectBillingStats(billings, costs, 100000)
expect(result.budgetUsed).toBe(100)
})
it('calculates budget remaining as max(budget - billed, 0)', () => {
const billings = [makeBilling({ amount: 70000 })]
const costs: CostEntry[] = []
const result = calculateProjectBillingStats(billings, costs, 100000)
expect(result.budgetRemaining).toBe(30000)
})
it('never returns negative budget remaining', () => {
const billings = [makeBilling({ amount: 120000 })]
const costs: CostEntry[] = []
const result = calculateProjectBillingStats(billings, costs, 100000)
expect(result.budgetRemaining).toBe(0)
})
it('sums uninvoiced entries correctly', () => {
const billings = [
makeBilling({ amount: 30000, invoiced: true }),
makeBilling({ amount: 20000, invoiced: false }),
makeBilling({ amount: 15000, invoiced: false }),
makeBilling({ amount: 10000, invoiced: true }),
]
const costs: CostEntry[] = []
const result = calculateProjectBillingStats(billings, costs, 100000)
expect(result.uninvoicedAmount).toBe(35000)
expect(result.totalBilled).toBe(75000)
})
it('returns 0 uninvoiced when all entries are invoiced', () => {
const billings = [
makeBilling({ amount: 25000, invoiced: true }),
makeBilling({ amount: 15000, invoiced: true }),
]
const costs: CostEntry[] = []
const result = calculateProjectBillingStats(billings, costs, 50000)
expect(result.uninvoicedAmount).toBe(0)
})
it('returns all zeros for empty billings and costs', () => {
const result = calculateProjectBillingStats([], [], 100000)
expect(result.totalBilled).toBe(0)
expect(result.totalCosts).toBe(0)
expect(result.margin).toBe(0)
expect(result.budgetUsed).toBe(0)
expect(result.budgetRemaining).toBe(100000)
expect(result.uninvoicedAmount).toBe(0)
})
it('rounds monetary values correctly with floating point edge cases', () => {
const billings = [
makeBilling({ amount: 33333.33 }),
makeBilling({ amount: 33333.33 }),
makeBilling({ amount: 33333.34 }),
]
const costs = [
makeCost({ amount: 11111.11 }),
makeCost({ amount: 11111.11 }),
makeCost({ amount: 11111.11 }),
]
const result = calculateProjectBillingStats(billings, costs, 150000)
// totalBilled = 100000.00, totalCosts = 33333.33
expect(result.totalBilled).toBe(100000)
expect(result.totalCosts).toBe(33333.33)
// margin = (100000 - 33333.33) / 100000 * 100 = 66.6667 -> 66.67
expect(result.margin).toBe(66.67)
})
})
describe('calculateAggregateStats', () => {
it('aggregates stats across multiple projects', () => {
const stats = [
{
totalBilled: 100000,
totalCosts: 60000,
margin: 40,
budgetUsed: 80,
budgetRemaining: 25000,
uninvoicedAmount: 10000,
},
{
totalBilled: 50000,
totalCosts: 20000,
margin: 60,
budgetUsed: 50,
budgetRemaining: 50000,
uninvoicedAmount: 5000,
},
]
const result = calculateAggregateStats(stats)
expect(result.totalBilled).toBe(150000)
expect(result.totalCosts).toBe(80000)
expect(result.avgMargin).toBe(50) // (40 + 60) / 2
expect(result.totalUninvoiced).toBe(15000)
expect(result.totalBudgetRemaining).toBe(75000)
})
it('returns all zeros for empty project stats', () => {
const result = calculateAggregateStats([])
expect(result.totalBilled).toBe(0)
expect(result.totalCosts).toBe(0)
expect(result.avgMargin).toBe(0)
expect(result.totalUninvoiced).toBe(0)
expect(result.totalBudgetRemaining).toBe(0)
})
})
@@ -0,0 +1,107 @@
/**
* Calculate project billing statistics.
*
* IMPORTANT: Margin is calculated as (revenue - costs) / revenue * 100.
* This is the correct gross margin formula. A previous implementation
* incorrectly used (budget - billed) / budget which conflates budget
* utilization with profitability.
*/
export interface BillingEntry {
projectId: string
amount: number
date: string
invoiced: boolean
}
export interface CostEntry {
projectId: string
amount: number
date: string
category: string
}
export interface ProjectBillingStats {
totalBilled: number
totalCosts: number
margin: number
budgetUsed: number
budgetRemaining: number
uninvoicedAmount: number
}
export interface AggregateStats {
totalBilled: number
totalCosts: number
avgMargin: number
totalUninvoiced: number
totalBudgetRemaining: number
}
export function calculateProjectBillingStats(
billings: BillingEntry[],
costs: CostEntry[],
budget: number
): ProjectBillingStats {
const totalBilled = billings.reduce((sum, b) => sum + b.amount, 0)
const roundedBilled = Math.round(totalBilled * 100) / 100
const totalCosts = costs.reduce((sum, c) => sum + c.amount, 0)
const roundedCosts = Math.round(totalCosts * 100) / 100
// CORRECT margin formula: (revenue - costs) / revenue * 100
// Revenue = totalBilled. Returns 0 when there is no revenue.
const margin = roundedBilled > 0
? Math.round(((roundedBilled - roundedCosts) / roundedBilled) * 10000) / 100
: 0
const budgetUsedRaw = budget > 0
? Math.round((roundedBilled / budget) * 10000) / 100
: 0
const budgetUsed = Math.min(budgetUsedRaw, 100)
const budgetRemaining = Math.round(Math.max(budget - roundedBilled, 0) * 100) / 100
const uninvoicedAmount = billings
.filter(b => !b.invoiced)
.reduce((sum, b) => sum + b.amount, 0)
const roundedUninvoiced = Math.round(uninvoicedAmount * 100) / 100
return {
totalBilled: roundedBilled,
totalCosts: roundedCosts,
margin,
budgetUsed,
budgetRemaining,
uninvoicedAmount: roundedUninvoiced,
}
}
export function calculateAggregateStats(
projectStats: ProjectBillingStats[]
): AggregateStats {
if (projectStats.length === 0) {
return {
totalBilled: 0,
totalCosts: 0,
avgMargin: 0,
totalUninvoiced: 0,
totalBudgetRemaining: 0,
}
}
const totalBilled = projectStats.reduce((sum, s) => sum + s.totalBilled, 0)
const totalCosts = projectStats.reduce((sum, s) => sum + s.totalCosts, 0)
const totalUninvoiced = projectStats.reduce((sum, s) => sum + s.uninvoicedAmount, 0)
const totalBudgetRemaining = projectStats.reduce((sum, s) => sum + s.budgetRemaining, 0)
const avgMargin = projectStats.reduce((sum, s) => sum + s.margin, 0) / projectStats.length
return {
totalBilled: Math.round(totalBilled * 100) / 100,
totalCosts: Math.round(totalCosts * 100) / 100,
avgMargin: Math.round(avgMargin * 100) / 100,
totalUninvoiced: Math.round(totalUninvoiced * 100) / 100,
totalBudgetRemaining: Math.round(totalBudgetRemaining * 100) / 100,
}
}
+177
View File
@@ -0,0 +1,177 @@
import { describe, it, expect } from 'vitest'
import {
validateSwedishPersonalNumber,
validatePositiveNumber,
validateNonNegativeNumber,
validateMaxNumber,
validateRequired,
validateDateNotFuture,
} from '../validation'
describe('validateSwedishPersonalNumber', () => {
it('accepts a valid personal number with dash', () => {
// 811228-9874 is a valid test number (Luhn passes)
expect(validateSwedishPersonalNumber('19811228-9874')).toBeNull()
})
it('accepts a valid personal number without dash', () => {
expect(validateSwedishPersonalNumber('198112289874')).toBeNull()
})
it('rejects empty string', () => {
expect(validateSwedishPersonalNumber('')).toBe('Personnummer kravs')
})
it('rejects too short input', () => {
expect(validateSwedishPersonalNumber('19811228')).toBe('Format: YYYYMMDD-XXXX (12 siffror)')
})
it('rejects too long input', () => {
expect(validateSwedishPersonalNumber('198112289874555')).toBe('Format: YYYYMMDD-XXXX (12 siffror)')
})
it('rejects non-numeric characters', () => {
expect(validateSwedishPersonalNumber('19811228ABCD')).toBe('Format: YYYYMMDD-XXXX (12 siffror)')
})
it('rejects invalid month', () => {
expect(validateSwedishPersonalNumber('199913011234')).toBe('Ogiltig manad')
})
it('rejects month 00', () => {
expect(validateSwedishPersonalNumber('199900011234')).toBe('Ogiltig manad')
})
it('rejects invalid day', () => {
expect(validateSwedishPersonalNumber('199901321234')).toBe('Ogiltig dag')
})
it('rejects day 00', () => {
expect(validateSwedishPersonalNumber('199901001234')).toBe('Ogiltig dag')
})
it('rejects year before 1900', () => {
expect(validateSwedishPersonalNumber('189901011234')).toBe('Ogiltigt ar')
})
it('rejects future year', () => {
const futureYear = new Date().getFullYear() + 1
expect(validateSwedishPersonalNumber(`${futureYear}01011234`)).toBe('Ogiltigt ar')
})
it('rejects invalid Luhn checksum', () => {
// Change last digit to break checksum
expect(validateSwedishPersonalNumber('19811228-9875')).toBe('Ogiltig kontrollsiffra')
})
it('handles spaces in input', () => {
expect(validateSwedishPersonalNumber('1981 1228 9874')).toBeNull()
})
})
describe('validatePositiveNumber', () => {
it('returns null for positive number', () => {
expect(validatePositiveNumber(5)).toBeNull()
})
it('returns null for positive string number', () => {
expect(validatePositiveNumber('42.5')).toBeNull()
})
it('rejects zero', () => {
expect(validatePositiveNumber(0)).toBe('Varde maste vara storre an 0')
})
it('rejects negative number', () => {
expect(validatePositiveNumber(-3)).toBe('Varde maste vara storre an 0')
})
it('rejects NaN string', () => {
expect(validatePositiveNumber('abc')).toBe('Varde maste vara storre an 0')
})
})
describe('validateNonNegativeNumber', () => {
it('returns null for positive number', () => {
expect(validateNonNegativeNumber(5)).toBeNull()
})
it('returns null for zero', () => {
expect(validateNonNegativeNumber(0)).toBeNull()
})
it('rejects negative number', () => {
expect(validateNonNegativeNumber(-1)).toBe('Varde kan inte vara negativt')
})
it('rejects NaN string', () => {
expect(validateNonNegativeNumber('xyz')).toBe('Varde kan inte vara negativt')
})
})
describe('validateMaxNumber', () => {
it('returns null when value is under max', () => {
expect(validateMaxNumber(5, 10)).toBeNull()
})
it('returns null when value equals max', () => {
expect(validateMaxNumber(10, 10)).toBeNull()
})
it('rejects when value exceeds max', () => {
expect(validateMaxNumber(15, 10)).toBe('Varde kan inte overskrida 10')
})
it('rejects NaN input', () => {
expect(validateMaxNumber('abc', 10)).toBe('Ogiltigt varde')
})
it('works with string numbers', () => {
expect(validateMaxNumber('8', 10)).toBeNull()
})
})
describe('validateRequired', () => {
it('returns null for non-empty string', () => {
expect(validateRequired('hello')).toBeNull()
})
it('returns null for number', () => {
expect(validateRequired(42)).toBeNull()
})
it('returns null for zero', () => {
expect(validateRequired(0)).toBeNull()
})
it('rejects empty string', () => {
expect(validateRequired('')).toBe('Obligatoriskt falt')
})
it('rejects undefined', () => {
expect(validateRequired(undefined)).toBe('Obligatoriskt falt')
})
it('rejects null', () => {
expect(validateRequired(null)).toBe('Obligatoriskt falt')
})
})
describe('validateDateNotFuture', () => {
it('returns null for past date', () => {
expect(validateDateNotFuture('2020-01-01')).toBeNull()
})
it('returns null for today', () => {
const today = new Date().toISOString().slice(0, 10)
expect(validateDateNotFuture(today)).toBeNull()
})
it('rejects future date', () => {
expect(validateDateNotFuture('2099-01-01')).toBe('Datum kan inte vara i framtiden')
})
it('rejects empty string', () => {
expect(validateDateNotFuture('')).toBe('Datum kravs')
})
})
+80
View File
@@ -0,0 +1,80 @@
'use client'
import { useState, useEffect, useCallback, useRef } from 'react'
interface AccountTotal {
account_number: string
debit: number
credit: number
net: number
}
interface MonthlyTotal {
month: string
account_number: string
debit: number
credit: number
net: number
}
interface UseAccountTotalsOptions {
from: string
to: string
dateFrom?: string
dateTo?: string
groupBy?: 'month'
}
export function useAccountTotals(options: UseAccountTotalsOptions) {
const [totals, setTotals] = useState<AccountTotal[]>([])
const [monthly, setMonthly] = useState<MonthlyTotal[]>([])
const [isLoading, setIsLoading] = useState(true)
const mountedRef = useRef(true)
useEffect(() => {
mountedRef.current = true
return () => { mountedRef.current = false }
}, [])
const refresh = useCallback(async () => {
setIsLoading(true)
try {
const params = new URLSearchParams({
from: options.from,
to: options.to,
})
if (options.dateFrom) params.set('date_from', options.dateFrom)
if (options.dateTo) params.set('date_to', options.dateTo)
if (options.groupBy) params.set('group_by', options.groupBy)
const res = await fetch(`/api/bookkeeping/account-totals?${params}`)
if (res.ok) {
const json = await res.json()
if (mountedRef.current) {
setTotals(json.totals ?? [])
setMonthly(json.monthly ?? [])
}
}
} finally {
if (mountedRef.current) setIsLoading(false)
}
}, [options.from, options.to, options.dateFrom, options.dateTo, options.groupBy])
useEffect(() => {
refresh()
}, [refresh])
const totalDebit = totals.reduce((sum, t) => sum + t.debit, 0)
const totalCredit = totals.reduce((sum, t) => sum + t.credit, 0)
const totalNet = totals.reduce((sum, t) => sum + t.net, 0)
return {
totals,
monthly,
isLoading,
totalDebit: Math.round(totalDebit * 100) / 100,
totalCredit: Math.round(totalCredit * 100) / 100,
totalNet: Math.round(totalNet * 100) / 100,
refresh,
}
}
+86
View File
@@ -0,0 +1,86 @@
'use client'
import { useState, useEffect, useCallback, useRef } from 'react'
interface ExtensionDataRecord {
id: string
key: string
value: Record<string, unknown>
created_at: string
updated_at: string
}
export function useExtensionData(sector: string, slug: string) {
const [data, setData] = useState<ExtensionDataRecord[]>([])
const [isLoading, setIsLoading] = useState(true)
const basePath = `/api/extensions/${sector}/${slug}/data`
const mountedRef = useRef(true)
useEffect(() => {
mountedRef.current = true
return () => { mountedRef.current = false }
}, [])
const refresh = useCallback(async () => {
setIsLoading(true)
try {
const res = await fetch(basePath)
if (res.ok) {
const json = await res.json()
if (mountedRef.current) setData(json.data ?? [])
}
} finally {
if (mountedRef.current) setIsLoading(false)
}
}, [basePath])
useEffect(() => {
refresh()
}, [refresh])
const save = useCallback(async (key: string, value: Record<string, unknown>) => {
const res = await fetch(basePath, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key, value }),
})
if (res.ok) {
const json = await res.json()
setData(prev => {
const idx = prev.findIndex(d => d.key === key)
if (idx >= 0) {
const updated = [...prev]
updated[idx] = json.data
return updated
}
return [...prev, json.data]
})
return json.data
}
return null
}, [basePath])
const remove = useCallback(async (key: string) => {
const res = await fetch(`${basePath}?key=${encodeURIComponent(key)}`, {
method: 'DELETE',
})
if (res.ok) {
setData(prev => prev.filter(d => d.key !== key))
}
}, [basePath])
const getByPrefix = useCallback(async (prefix: string): Promise<ExtensionDataRecord[]> => {
const res = await fetch(`${basePath}?prefix=${encodeURIComponent(prefix)}`)
if (res.ok) {
const json = await res.json()
return json.data ?? []
}
return []
}, [basePath])
const getByKey = useCallback((key: string) => {
return data.find(d => d.key === key) ?? null
}, [data])
return { data, isLoading, save, remove, getByPrefix, getByKey, refresh }
}
+70
View File
@@ -0,0 +1,70 @@
/**
* Validates a Swedish personal number (YYYYMMDD-XXXX) using Luhn checksum.
* Returns an error message string, or null if valid.
*/
export function validateSwedishPersonalNumber(pnr: string): string | null {
if (!pnr) return 'Personnummer kravs'
// Accept YYYYMMDD-XXXX or YYYYMMDDXXXX
const cleaned = pnr.replace(/[-\s]/g, '')
if (!/^\d{12}$/.test(cleaned)) {
return 'Format: YYYYMMDD-XXXX (12 siffror)'
}
const year = parseInt(cleaned.slice(0, 4))
const month = parseInt(cleaned.slice(4, 6))
const day = parseInt(cleaned.slice(6, 8))
if (month < 1 || month > 12) return 'Ogiltig manad'
if (day < 1 || day > 31) return 'Ogiltig dag'
if (year < 1900 || year > new Date().getFullYear()) return 'Ogiltigt ar'
// Luhn check on the last 10 digits (YYMMDDXXXX)
const luhnDigits = cleaned.slice(2)
let sum = 0
for (let i = 0; i < 10; i++) {
let digit = parseInt(luhnDigits[i])
if (i % 2 === 0) {
digit *= 2
if (digit > 9) digit -= 9
}
sum += digit
}
if (sum % 10 !== 0) return 'Ogiltig kontrollsiffra'
return null
}
export function validatePositiveNumber(value: number | string): string | null {
const num = typeof value === 'string' ? parseFloat(value) : value
if (isNaN(num) || num <= 0) return 'Varde maste vara storre an 0'
return null
}
export function validateNonNegativeNumber(value: number | string): string | null {
const num = typeof value === 'string' ? parseFloat(value) : value
if (isNaN(num) || num < 0) return 'Varde kan inte vara negativt'
return null
}
export function validateMaxNumber(value: number | string, max: number): string | null {
const num = typeof value === 'string' ? parseFloat(value) : value
if (isNaN(num)) return 'Ogiltigt varde'
if (num > max) return `Varde kan inte overskrida ${max}`
return null
}
export function validateRequired(value: string | number | undefined | null): string | null {
if (value === undefined || value === null || value === '') return 'Obligatoriskt falt'
return null
}
export function validateDateNotFuture(dateStr: string): string | null {
if (!dateStr) return 'Datum kravs'
const date = new Date(dateStr)
const today = new Date()
today.setHours(23, 59, 59, 999)
if (date > today) return 'Datum kan inte vara i framtiden'
return null
}
+11
View File
@@ -38,6 +38,7 @@
"langchain": "^1.2.16",
"lucide-react": "^0.563.0",
"next": "16.1.5",
"next-themes": "^0.4.6",
"pdfjs-dist": "^5.4.530",
"react": "19.2.3",
"react-dom": "19.2.3",
@@ -9955,6 +9956,16 @@
}
}
},
"node_modules/next-themes": {
"version": "0.4.6",
"resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz",
"integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/next/node_modules/postcss": {
"version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
+1
View File
@@ -40,6 +40,7 @@
"langchain": "^1.2.16",
"lucide-react": "^0.563.0",
"next": "16.1.5",
"next-themes": "^0.4.6",
"pdfjs-dist": "^5.4.530",
"react": "19.2.3",
"react-dom": "19.2.3",