diff --git a/components/extensions/CategoryBadge.tsx b/components/extensions/CategoryBadge.tsx
index 0304df55..3dc01238 100644
--- a/components/extensions/CategoryBadge.tsx
+++ b/components/extensions/CategoryBadge.tsx
@@ -5,10 +5,10 @@ import { cn } from '@/lib/utils'
import type { ExtensionCategory } from '@/lib/extensions/types'
const CATEGORY_CONFIG: Record = {
- 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 }) {
diff --git a/components/extensions/construction/ProjectCostWorkspace.tsx b/components/extensions/construction/ProjectCostWorkspace.tsx
index dbd58372..77604b8a 100644
--- a/components/extensions/construction/ProjectCostWorkspace.tsx
+++ b/components/extensions/construction/ProjectCostWorkspace.tsx
@@ -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) }))
+ .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) }))
+ .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) }))
+ .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(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(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
+
+ // --- Render helper for budget alert banner ---
+ const renderBudgetAlert = (p: (typeof projectStats)[number]) => {
+ if (p.budget <= 0) return null
+ if (p.budgetStatus === 'danger') {
+ return (
+
+
+
Kostnaden overskrider budgeten ({p.budgetUsed}% anvant)
+
+ )
+ }
+ if (p.budgetStatus === 'warning') {
+ return (
+
+
+
Budgetvarning: {p.budgetUsed}% av budgeten anvand
+
+ )
+ }
+ return null
+ }
+
+ // --- Render helper for category breakdown ---
+ const renderCategoryBreakdown = (categoryTotals: { category: string; total: number; pct: number }[]) => {
+ if (categoryTotals.length === 0) return null
+ return (
+
+
Kostnadsfordelning per kategori
+
+
+
+
+ Kategori
+ Belopp
+ Andel
+
+
+
+ {categoryTotals.map(ct => (
+
+ {ct.category}
+
+ {ct.total.toLocaleString('sv-SE')} kr
+
+ {ct.pct}%
+
+ ))}
+
+
+
+
+ )
+ }
+
+ // --- Render project card for the Projects tab ---
+ const renderProjectCard = (p: (typeof projectStats)[number]) => {
+ const isExpanded = expandedProject === p.id
+ return (
+
+
+
+
setExpandedProject(isExpanded ? null : p.id)}
+ >
+
+ {p.name}
+
+ {p.status === 'active' ? 'Aktiv' : 'Avslutad'}
+
+
+
+
+
{
+ e.stopPropagation()
+ setEditProject({
+ id: p.id,
+ name: p.name,
+ budget: String(p.budget),
+ })
+ }}
+ >
+
+
+
{
+ e.stopPropagation()
+ setDeleteTarget({ type: 'project', id: p.id, label: p.name })
+ }}
+ >
+
+
+
setExpandedProject(isExpanded ? null : p.id)}
+ >
+ {isExpanded ? : }
+
+
+
+
+ {isExpanded && (
+
+ {/* Budget alert banner */}
+ {renderBudgetAlert(p)}
+
+ {/* Stats row */}
+
+
+
Kostnad
+
{p.totalCost.toLocaleString('sv-SE')} kr
+
+
+
Intakt
+
{p.totalRevenue.toLocaleString('sv-SE')} kr
+
+
+
Marginal
+
{p.margin}%
+
+
+
+ {/* Budget progress */}
+ {p.budget > 0 && (
+
+
+ Budget anvand
+ {p.budgetUsed}% av {p.budget.toLocaleString('sv-SE')} kr
+
+
+
+ )}
+
+ {/* Category breakdown */}
+ {renderCategoryBreakdown(p.categoryTotals)}
+
+ {/* Cost entries */}
+
+
Kostnader
+
+ setCostDesc(e.target.value)} className="max-w-xs" />
+ setCostAmount(e.target.value)} className="w-28" />
+
+
+
+ {COST_CATEGORIES.map(c => {c} )}
+
+
+ handleAddCost(p.id)} disabled={isSubmitting}>Lagg till
+
+ {p.costs.length > 0 && (
+
+
+
+
+ Datum
+ Beskrivning
+ Kategori
+ Belopp
+
+
+
+
+ {p.costs.map(c => (
+
+ {c.date}
+ {c.description}
+ {c.category}
+ {c.amount.toLocaleString('sv-SE')} kr
+
+
+
setEditEntry({
+ type: 'cost',
+ id: c.id,
+ projectId: c.projectId,
+ description: c.description,
+ amount: String(c.amount),
+ date: c.date,
+ category: c.category,
+ })}
+ >
+
+
+
setDeleteTarget({
+ type: 'cost',
+ id: c.id,
+ label: c.description || 'kostnad',
+ })}
+ >
+
+
+
+
+
+ ))}
+
+
+
+ )}
+
+
+ {/* Revenue entries */}
+
+
Intakter
+
+ setRevDesc(e.target.value)} className="max-w-xs" />
+ setRevAmount(e.target.value)} className="w-28" />
+ handleAddRevenue(p.id)} disabled={isSubmitting}>Lagg till
+
+ {p.revenues.length > 0 && (
+
+
+
+
+ Datum
+ Beskrivning
+ Belopp
+
+
+
+
+ {p.revenues.map(r => (
+
+ {r.date}
+ {r.description}
+ {r.amount.toLocaleString('sv-SE')} kr
+
+
+
setEditEntry({
+ type: 'revenue',
+ id: r.id,
+ projectId: r.projectId,
+ description: r.description,
+ amount: String(r.amount),
+ date: r.date,
+ })}
+ >
+
+
+
setDeleteTarget({
+ type: 'revenue',
+ id: r.id,
+ label: r.description || 'intakt',
+ })}
+ >
+
+
+
+
+
+ ))}
+
+
+
+ )}
+
+
+ {/* Complete project button (active only) */}
+ {p.status === 'active' && (
+
+ 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"
+ >
+
+ Avsluta projekt
+
+
+ )}
+
+ )}
+
+ )
+ }
-export default function ProjectCostWorkspace() {
return (
- }
- />
+
+
+
+ Oversikt
+ Projekt
+
+
+
+ setDateRange({ start, end })} />
+
+
+
+
+
+
+
+
+ {/* Active projects */}
+ {activeProjects.length > 0 && (
+
+
Aktiva projekt
+ {activeProjects.map(p => (
+
+
+ {/* Budget alert */}
+ {renderBudgetAlert(p)}
+
+
+
+
+ Marginal:
+ {p.margin}%
+
+
+
+ Kostnad: {p.totalCost.toLocaleString('sv-SE')} kr
+ Intakt: {p.totalRevenue.toLocaleString('sv-SE')} kr
+ {p.budget > 0 && Budget: {p.budget.toLocaleString('sv-SE')} kr }
+
+ {p.budget > 0 && (
+
+ )}
+
+
+ ))}
+
+ )}
+
+ {/* Completed projects */}
+ {completedProjects.length > 0 && (
+
+
Avslutade projekt
+ {completedProjects.map(p => (
+
+
+
+
+
+
= 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'
+ )}>
+ {p.margin}% marginal
+
+
+
+
+ Kostnad: {p.totalCost.toLocaleString('sv-SE')} kr
+ Intakt: {p.totalRevenue.toLocaleString('sv-SE')} kr
+ Resultat: {(Math.round((p.totalRevenue - p.totalCost) * 100) / 100).toLocaleString('sv-SE')} kr
+
+
+
+ ))}
+
+ )}
+
+
+
+ {!showNewProject ? (
+ setShowNewProject(true)}>
+ Nytt projekt
+
+ ) : (
+
+
+
+ setNewProjectName(e.target.value)} className="max-w-xs" />
+ setNewProjectBudget(e.target.value)} className="max-w-xs" />
+ Skapa
+ setShowNewProject(false)}>Avbryt
+
+
+
+ )}
+
+ {activeProjects.length > 0 && (
+
+
Aktiva projekt
+ {activeProjects.map(p => renderProjectCard(p))}
+
+ )}
+
+ {completedProjects.length > 0 && (
+
+
Avslutade projekt
+ {completedProjects.map(p => renderProjectCard(p))}
+
+ )}
+
+
+
+ {/* Delete confirmation dialog */}
+
{ 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 */}
+ { if (!open) setEditEntry(null) }}
+ title={editEntry?.type === 'cost' ? 'Redigera kostnad' : 'Redigera intakt'}
+ description="Andra uppgifterna och klicka Spara."
+ onSave={handleSaveEditEntry}
+ isSaving={isSavingEdit}
+ >
+ {editEntry && (
+
+
+ Beskrivning
+ setEditEntry({ ...editEntry, description: e.target.value })}
+ />
+
+
+ Belopp (kr)
+ setEditEntry({ ...editEntry, amount: e.target.value })}
+ />
+
+
+ Datum
+ setEditEntry({ ...editEntry, date: e.target.value })}
+ />
+
+ {editEntry.type === 'cost' && (
+
+ Kategori
+ setEditEntry({ ...editEntry, category: val })}
+ >
+
+
+ {COST_CATEGORIES.map(c => {c} )}
+
+
+
+ )}
+
+ )}
+
+
+ {/* Edit project dialog */}
+ { if (!open) setEditProject(null) }}
+ title="Redigera projekt"
+ description="Andra projektnamn och budget."
+ onSave={handleSaveEditProject}
+ isSaving={isSavingProject}
+ >
+ {editProject && (
+
+ )}
+
+
+ {/* Complete project confirmation dialog */}
+ { if (!open) setCompleteProjectId(null) }}
+ title="Avsluta projekt"
+ description={`Vill du markera projektet som avslutat? Projektet flyttas till "Avslutade" och kan inte ateraktiveras.`}
+ onConfirm={handleCompleteProject}
+ />
+
)
}
diff --git a/components/extensions/construction/RotCalculatorWorkspace.tsx b/components/extensions/construction/RotCalculatorWorkspace.tsx
index 72c34994..f7f707d5 100644
--- a/components/extensions/construction/RotCalculatorWorkspace.tsx
+++ b/components/extensions/construction/RotCalculatorWorkspace.tsx
@@ -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(() =>
+ 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(() =>
+ data.filter(d => d.key.startsWith('job:'))
+ .map(d => ({ id: d.key.replace('job:', ''), ...(d.value as Omit) }))
+ .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(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(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(null)
+ const [isDeletingJob, setIsDeletingJob] = useState(false)
+
+ // Per-customer used quota for selected year (only completed jobs count)
+ const customerYearlyUsed = useMemo(() => {
+ const map = new Map()
+ 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
+
+ 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 (
- }
- />
+
+
+
+ Kalkylator
+ Kunder
+ Jobb
+
+
+
+
+ Ar:
+
+
+
+
+
+ {yearOptions.map(y => (
+ {y}
+ ))}
+
+
+
+
+ {customers.length === 0 ? (
+
+
+ Lagg till kunder under fliken "Kunder" for att borja berakna ROT-avdrag.
+
+
+ ) : (
+ <>
+
+
+
+ Kund
+
+
+
+ {customers.map(c => {c.name} )}
+
+
+
+
+ Beskrivning
+ setDescription(e.target.value)} />
+
+
+ Totalt belopp (inkl. moms)
+ setTotal(e.target.value)} />
+
+
+ Materialkostnad
+ setMaterial(e.target.value)} />
+
+
+
+ {totalNum > 0 && (
+
+
+
+ Arbetskostnad:
+ {labor.toLocaleString('sv-SE')} kr
+ ROT-avdrag (30%):
+ -{rotDeduction.toLocaleString('sv-SE')} kr
+ Kunden betalar:
+ {customerPays.toLocaleString('sv-SE')} kr
+ Kvarvarande kvot:
+ {Math.max(remainingQuota - rotDeduction, 0).toLocaleString('sv-SE')} kr
+
+
+
+ )}
+
+ >
+ )}
+
+
+
+
+
setNewCustomerName(e.target.value)} className="max-w-xs" />
+
+
setNewCustomerPnr(e.target.value)}
+ className="max-w-xs"
+ />
+ {newCustomerPnrError && (
+
{newCustomerPnrError}
+ )}
+
+
+ Lagg till
+
+
+
+ {customers.length === 0 ? (
+ Inga kunder tillagda annu.
+ ) : (
+
+ {customers.map(cust => {
+ const used = customerYearlyUsed.get(cust.id) ?? 0
+ const pct = Math.min(Math.round((used / MAX_ROT_YEARLY) * 100), 100)
+ return (
+
+
+
+
+
{cust.name}
+ {cust.personalNumber && (
+
{cust.personalNumber}
+ )}
+
+
+
+ {used.toLocaleString('sv-SE')} / {MAX_ROT_YEARLY.toLocaleString('sv-SE')} kr
+
+
openEditCustomer(cust)}>
+
+
+
+
+
+
+
+ )
+ })}
+
+ )}
+
+
+
+
+
+ Ar:
+
+
+
+
+
+ {yearOptions.map(y => (
+ {y}
+ ))}
+
+
+
+ {completedJobCount > 0 && (
+
+ Exportera CSV
+
+ )}
+
+
+
+
+
+
+
+
+ {jobs.length === 0 ? (
+ Inga jobb registrerade for {selectedYear}.
+ ) : (
+
+
+
+
+ Datum
+ Kund
+ Beskrivning
+ Totalt
+ ROT-avdrag
+ Status
+
+
+
+
+ {jobs.map(job => (
+
+ {job.date}
+ {job.customerName}
+ {job.description}
+ {job.total.toLocaleString('sv-SE')} kr
+ {job.rotDeduction.toLocaleString('sv-SE')} kr
+
+
+ {job.status === 'completed' ? 'Klar' : 'Utkast'}
+
+
+
+
+ {job.status === 'draft' && (
+
handleMarkCompleted(job)} title="Markera som klar">
+
+
+ )}
+
openEditJob(job)} title="Redigera">
+
+
+
setDeletingJobId(job.id)} title="Ta bort">
+
+
+
+
+
+ ))}
+
+
+
+ )}
+
+
+
+ {/* Edit Customer Dialog */}
+
{ if (!open) setEditingCustomer(null) }}
+ title="Redigera kund"
+ description="Uppdatera kunduppgifter."
+ onSave={handleSaveCustomer}
+ isSaving={isSavingCustomer}
+ >
+
+ Namn
+ setEditCustomerName(e.target.value)} />
+
+
+
Personnummer
+
setEditCustomerPnr(e.target.value)}
+ />
+ {editCustomerPnrError && (
+
{editCustomerPnrError}
+ )}
+
+
+
+ {/* Edit Job Dialog */}
+
{ if (!open) setEditingJob(null) }}
+ title="Redigera jobb"
+ description="Uppdatera jobbdetaljer. ROT-avdrag beraknas om automatiskt."
+ onSave={handleSaveJob}
+ isSaving={isSavingJob}
+ >
+
+ Kund
+
+
+
+ {customers.map(c => {c.name} )}
+
+
+
+
+ Beskrivning
+ setEditJobDescription(e.target.value)} />
+
+
+ Totalt belopp (inkl. moms)
+ setEditJobTotal(e.target.value)} />
+
+
+ Materialkostnad
+ setEditJobMaterial(e.target.value)} />
+
+ {(() => {
+ 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 ? (
+
+
+ Arbetskostnad:
+ {editLabor.toLocaleString('sv-SE')} kr
+
+
+ ROT-avdrag (30%):
+ -{editRot.toLocaleString('sv-SE')} kr
+
+
+ ) : null
+ })()}
+
+
+ {/* Delete Job Confirmation */}
+
{ 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}
+ />
+
)
}
diff --git a/components/extensions/ecommerce/MultichannelRevenueWorkspace.tsx b/components/extensions/ecommerce/MultichannelRevenueWorkspace.tsx
index b72e8db3..f4b59869 100644
--- a/components/extensions/ecommerce/MultichannelRevenueWorkspace.tsx
+++ b/components/extensions/ecommerce/MultichannelRevenueWorkspace.tsx
@@ -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 (
+
+
+ 0%
+
+ )
+ }
+ if (previous === 0) {
+ return (
+
+
+ Ny
+
+ )
+ }
+ const pctChange = Math.round(((current - previous) / previous) * 1000) / 10
+ if (pctChange === 0) {
+ return (
+
+
+ 0%
+
+ )
+ }
+ const improving = pctChange > 0
return (
- }
- />
+
+ {improving
+ ?
+ :
+ }
+ {pctChange > 0 ? '+' : ''}{pctChange}%
+
+ )
+}
+
+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),
+ }))
+ , [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('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(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(null)
+ const [isDeleting, setIsDeleting] = useState(false)
+
+ // Rename channel dialog state
+ const [renameDialogOpen, setRenameDialogOpen] = useState(false)
+ const [renamingChannel, setRenamingChannel] = useState(null)
+ const [newName, setNewName] = useState('')
+ const [isSavingRename, setIsSavingRename] = useState(false)
+
+ // Color picker state
+ const [colorPickerChannel, setColorPickerChannel] = useState(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()
+ 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()
+ 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()
+ 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>()
+ 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,
+ 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) => {
+ 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
+
+ if (channels.length === 0) {
+ return (
+
+ )
+ }
+
+ return (
+
+
setDateRange({ start, end })} />
+
+ {/* KPI Cards */}
+
+ 0 ? {
+ value: Math.round(((totalRevenue - prevYearTotalRevenue) / prevYearTotalRevenue) * 1000) / 10,
+ label: 'mot fg ar',
+ } : undefined}
+ />
+
+ 0 ? overallAOV.toLocaleString('sv-SE') : '-'}
+ suffix={overallAOV > 0 ? 'kr' : undefined}
+ />
+
+
+
+ {/* Channel management */}
+
+
Kanaler
+
+ {channels.map(ch => (
+
+ {/* Color swatch - clickable for color picker */}
+
setColorPickerChannel(
+ colorPickerChannel === ch.name ? null : ch.name
+ )}
+ title="Byt farg"
+ />
+ {ch.name}
+ handleStartRename(ch.name)}
+ title="Byt namn"
+ >
+
+
+ handleRemoveChannel(ch.name)}
+ title="Ta bort kanal"
+ >
+
+
+
+ {/* Color picker dropdown */}
+ {colorPickerChannel === ch.name && (
+
+
+ {COLOR_PRESETS.map(color => (
+ handleChangeChannelColor(ch.name, color)}
+ />
+ ))}
+
+
+ )}
+
+ ))}
+
+
+
setNewChannelName(e.target.value)}
+ className="max-w-xs"
+ onKeyDown={e => {
+ if (e.key === 'Enter') {
+ e.preventDefault()
+ handleAddChannel()
+ }
+ }}
+ />
+
+ Lagg till
+
+
+
+
+ {/* Entry form */}
+
+
+
+ Manad
+ setEntryMonth(e.target.value)} />
+
+
+ Kanal
+
+
+
+ {channels.map(c => {c.name} )}
+
+
+
+
+ Intakt (kr)
+ setEntryRevenue(e.target.value)} />
+
+
+ Antal ordrar
+ setEntryOrders(e.target.value)} />
+
+
+
+
+ {/* Duplicate confirmation dialog */}
+
+
+
+ Post finns redan
+
+ Det finns redan en post for {pendingEntry?.channel} i {pendingEntry?.month}.
+ Vill du uppdatera den befintliga posten eller skapa en ny?
+
+
+
+ setDuplicateDialogOpen(false)} disabled={isSubmitting}>
+ Avbryt
+
+
+ Skapa ny
+
+
+ {isSubmitting ? 'Sparar...' : 'Uppdatera befintlig'}
+
+
+
+
+
+ {/* Channel comparison bar chart */}
+ {sortedChannelTotals.length > 0 && (
+
+
+
Kanaljamforelse
+
+ Sortera:
+ setSortMode('revenue')}
+ >
+ Intakt
+
+ setSortMode('growth')}
+ >
+
+ Tillvaxt
+
+
+
+
+ {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 (
+
+
+
{ct.channel}
+
+
+ AOV: {aov > 0 ? aov.toLocaleString('sv-SE') + ' kr' : '-'}
+
+
+ {formatCurrency(ct.revenue)} kr
+
+
+
+
+ )
+ })}
+
+
+ )}
+
+ {/* Entries table with edit/delete */}
+ {entries.length > 0 && (
+
+
Registrerade poster
+
+
+
+
+ Manad
+ Kanal
+ Intakt
+ Ordrar
+ AOV
+ Atgarder
+
+
+
+ {entries.map(entry => {
+ const channelConfig = channels.find(c => c.name === entry.channel)
+ return (
+ handleStartEdit(entry)}
+ >
+ {entry.month}
+
+
+
+
+ {formatCurrency(entry.revenue)} kr
+
+
+ {entry.orderCount}
+
+
+ {formatAOV(entry.revenue, entry.orderCount)} {entry.orderCount > 0 ? 'kr' : ''}
+
+
+ e.stopPropagation()}>
+
handleStartEdit(entry)}
+ title="Redigera"
+ >
+
+
+
handleStartDelete(entry.id)}
+ title="Ta bort"
+ >
+
+
+
+
+
+ )
+ })}
+
+
+
+
+ )}
+
+ {/* Monthly comparison table */}
+ {monthlyComparison.length > 0 && (
+
+
Manadsjamforelse
+
+
+
+
+ Manad
+ {channels.map(ch => (
+ {ch.name}
+ ))}
+ Total
+ AOV
+
+
+
+ {monthlyComparison.map(row => (
+
+ {row.month}
+ {channels.map(ch => {
+ const chData = row.channels[ch.name]
+ return (
+
+ {chData ? formatCurrency(chData.revenue) : '0'}
+
+ )
+ })}
+
+ {formatCurrency(row.total)}
+
+
+ {row.totalOrders > 0
+ ? Math.round(row.total / row.totalOrders).toLocaleString('sv-SE') + ' kr'
+ : '-'}
+
+
+ ))}
+
+
+
+
+ )}
+
+ {/* Period comparison: previous year */}
+ {(prevYearEntries.length > 0 || channelTotals.length > 0) && (
+
+
Arsjamforelse per kanal
+
+
+
+
+ Kanal
+ Nuvarande period
+ Foregaende ar
+ Tillvaxt
+ AOV (nu)
+ AOV (fg ar)
+
+
+
+ {sortedChannelTotals.map(ct => {
+ const prevData = prevYearChannelTotals.get(ct.channel)
+ const prevRev = prevData?.revenue ?? 0
+ const prevOrd = prevData?.orders ?? 0
+ return (
+
+
+
+
c.name === ct.channel)?.color ?? '#3b82f6' }}
+ />
+ {ct.channel}
+
+
+
+ {formatCurrency(ct.revenue)} kr
+
+
+ {prevRev > 0 ? formatCurrency(prevRev) + ' kr' : '-'}
+
+
+
+
+
+ {formatAOV(ct.revenue, ct.orders)} {ct.orders > 0 ? 'kr' : ''}
+
+
+ {formatAOV(prevRev, prevOrd)} {prevOrd > 0 ? 'kr' : ''}
+
+
+ )
+ })}
+ {/* Totals row */}
+
+ Totalt
+
+ {formatCurrency(totalRevenue)} kr
+
+
+ {prevYearTotalRevenue > 0 ? formatCurrency(prevYearTotalRevenue) + ' kr' : '-'}
+
+
+
+
+
+ {overallAOV > 0 ? overallAOV.toLocaleString('sv-SE') + ' kr' : '-'}
+
+
+ {(() => {
+ const prevTotalOrders = prevYearEntries.reduce((s, e) => s + e.orderCount, 0)
+ return prevTotalOrders > 0
+ ? Math.round(prevYearTotalRevenue / prevTotalOrders).toLocaleString('sv-SE') + ' kr'
+ : '-'
+ })()}
+
+
+
+
+
+
+ )}
+
+ {/* Edit entry dialog */}
+
+
+
+ Manad
+ setEditMonth(e.target.value)}
+ />
+
+
+ Kanal
+
+
+
+ {channels.map(c => (
+ {c.name}
+ ))}
+
+
+
+
+ Intakt (kr)
+ setEditRevenue(e.target.value)}
+ />
+
+
+ Antal ordrar
+ setEditOrders(e.target.value)}
+ />
+
+
+
+
+ {/* Delete confirmation dialog */}
+
+
+ {/* Rename channel dialog */}
+
+
+ Nytt namn
+ setNewName(e.target.value)}
+ placeholder="Kanalnamn"
+ />
+
+
+
)
}
diff --git a/components/extensions/ecommerce/ShopifyImportWorkspace.tsx b/components/extensions/ecommerce/ShopifyImportWorkspace.tsx
index 66f335b0..31172855 100644
--- a/components/extensions/ecommerce/ShopifyImportWorkspace.tsx
+++ b/components/extensions/ecommerce/ShopifyImportWorkspace.tsx
@@ -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 = {
+ 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) }))
+ .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) }))
+ .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(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(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()
+ 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()
+ 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[]) => {
+ 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()
+ 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()
+ 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()
+ 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()
+ 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
-export default function ShopifyImportWorkspace() {
return (
- }
- />
+
+ {/* Edit order dialog */}
+
{ if (!open) setEditOrder(null) }}
+ title="Redigera order"
+ description="Andra uppgifterna for denna order."
+ onSave={handleSaveEdit}
+ isSaving={isSavingEdit}
+ >
+
+
+
+ {/* Delete order dialog */}
+
{ 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}
+ />
+
+
+
+ Import
+ Ordrar
+ Statistik
+
+
+ {/* ------------------------------------------------------------------ */}
+ {/* Import tab */}
+ {/* ------------------------------------------------------------------ */}
+
+
+
+ {/* Manual order entry */}
+
+
+
+
+ {imports.length > 0 && (
+
+
Importhistorik
+
+
+
+
+ Datum
+ Ordrar
+
+
+
+ {imports.map(imp => (
+
+ {imp.date}
+ {imp.rowCount}
+
+ ))}
+
+
+
+
+ )}
+
+
+ {/* ------------------------------------------------------------------ */}
+ {/* Orders tab */}
+ {/* ------------------------------------------------------------------ */}
+
+ {/* Filters */}
+
+
+
+ Sok
+ { setSearchQuery(e.target.value); resetPage() }}
+ className="w-48"
+ />
+
+
+ Fran datum
+ { setDateFrom(e.target.value); resetPage() }}
+ className="w-40"
+ />
+
+
+ Till datum
+ { setDateTo(e.target.value); resetPage() }}
+ className="w-40"
+ />
+
+
+ Betalmetod
+ { setPaymentFilter(v); resetPage() }}>
+
+
+
+
+ Alla
+ {paymentMethods.map(m => (
+ {m}
+ ))}
+
+
+
+
+ Leveransstatus
+ { setFulfillmentFilter(v); resetPage() }}>
+
+
+
+
+ Alla
+ {fulfillmentStatuses.map(s => (
+ {s}
+ ))}
+
+
+
+
+
+ {activeFilterCount > 0 && (
+
+ {activeFilterCount} aktiva filter
+
+ Rensa filter
+
+
+ )}
+
+
+ {filteredOrders.length === 0 ? (
+ Inga ordrar hittades.
+ ) : (
+ <>
+
+
+
+
+ Order
+ Datum
+ Total
+ Frakt
+ Moms
+ Betalning
+ Status
+
+
+
+
+ {paginatedOrders.map(o => (
+
+ {o.name}
+ {o.createdAt}
+ {o.total.toLocaleString('sv-SE')} kr
+ {o.shipping.toLocaleString('sv-SE')} kr
+ {o.taxes.toLocaleString('sv-SE')} kr
+ {o.paymentMethod}
+
+
+ {o.fulfillmentStatus || 'Okant'}
+
+
+
+
+
openEditOrder(o)}>
+
+
+
setDeleteOrderId(o.id)}>
+
+
+
+
+
+ ))}
+
+
+
+
+ {/* Pagination */}
+
+
+ {filteredOrders.length} ordrar totalt
+
+
+ setCurrentPage(p => Math.max(1, p - 1))}
+ >
+
+ Foregaende
+
+
+ Sida {safePage} av {totalPages}
+
+ = totalPages}
+ onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
+ >
+ Nasta
+
+
+
+
+ >
+ )}
+
+
+ {/* ------------------------------------------------------------------ */}
+ {/* Stats tab */}
+ {/* ------------------------------------------------------------------ */}
+
+
+
+
+
+
+
+ {/* VAT analytics */}
+
+
Momsanalys
+
+
+
+
+
+ {monthlyVat.length > 0 && (
+
+
+
+
+ Manad
+ Subtotal
+ Moms
+ Momssats
+
+
+
+ {monthlyVat.map(m => (
+
+ {m.month}
+ {m.subtotal.toLocaleString('sv-SE')} kr
+ {m.taxes.toLocaleString('sv-SE')} kr
+ {m.rate}%
+
+ ))}
+
+
+
+ )}
+
+
+ {monthlyTrend.length > 0 && (
+
+
Intakt per manad
+
+
+ )}
+
+ {paymentBreakdown.length > 0 && (
+
+
Per betalmetod
+
+
+
+
+ Betalmetod
+ Ordrar
+ Total
+
+
+
+ {paymentBreakdown.map(p => (
+
+ {p.method}
+ {p.count}
+ {p.total.toLocaleString('sv-SE')} kr
+
+ ))}
+
+
+
+
+ )}
+
+ {fulfillmentBreakdown.length > 0 && (
+
+
Per leveransstatus
+
+
+
+
+ Status
+ Ordrar
+
+
+
+ {fulfillmentBreakdown.map(f => (
+
+ {f.status}
+ {f.count}
+
+ ))}
+
+
+
+
+ )}
+
+
+
)
}
diff --git a/components/extensions/hotel/OccupancyWorkspace.tsx b/components/extensions/hotel/OccupancyWorkspace.tsx
index ce7fff0c..586005c0 100644
--- a/components/extensions/hotel/OccupancyWorkspace.tsx
+++ b/components/extensions/hotel/OccupancyWorkspace.tsx
@@ -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 (
+
+
+ 0 pp
+
+ )
+ }
+ // For occupancy: higher is better, so positive delta = green (improving)
+ const improving = delta > 0
return (
- }
- />
+
+ {delta > 0
+ ?
+ :
+ }
+ {delta > 0 ? '+' : ''}{delta} pp
+
+ )
+}
+
+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('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('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) => {
+ 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
+
+ if (!totalRooms) {
+ return (
+
+ )
+ }
+
+ return (
+
+
+ setDateRange({ start, end })} />
+
+
+ {totalRooms} rum
+
+
+
+ {/* KPI Cards */}
+
+
+
+
+
+
+
+ {/* Period comparison */}
+
+
Periodjamforelse
+
+
+
Belaggning (nuvarande)
+
+ {occupancyPct}%
+
+
+
+
+
Belaggning (foregaende)
+
{prevOccupancyPct}%
+
+
+
Foregaende period
+
{prevPeriod.start} — {prevPeriod.end}
+
+
+
+
+
Belagda rum (foregaende)
+
+ {prevDaysInRange > 0
+ ? Math.round(prevTotalOccupied / prevDaysInRange)
+ : 0} snitt / dag
+
+
+
+
Dagar med data (foregaende)
+
{prevDaysInRange} dagar
+
+
+
+
+ {/* Entry form */}
+
+
+
+ Datum
+ setEntryDate(e.target.value)} />
+
+
+ Belagda rum
+ setRoomsOccupied(e.target.value)}
+ />
+
+
+ Ur drift
+ setRoomsOutOfOrder(e.target.value)}
+ />
+
+
+ Orsak (ur drift)
+ setOooReason(val as OooReason)}>
+
+
+
+
+ {OOO_REASONS.map(r => (
+ {r}
+ ))}
+
+
+
+
+ {formExceedsTotal && (
+
+ Belagda rum ({formOccupied}) + ur drift ({formOoo}) = {formOccupied + formOoo} overstiger totalt antal rum ({totalRooms}).
+
+ )}
+
+
+ {/* Calendar heatmap */}
+ {calendarData.length > 0 && (
+
+
Belaggningskalender
+
+
+ {['Man', 'Tis', 'Ons', 'Tor', 'Fre', 'Lor', 'Son'].map(d => (
+
{d}
+ ))}
+
+
+ {/* Offset for first day of month */}
+ {calendarData.length > 0 && Array.from({ length: (calendarData[0].dayOfWeek + 6) % 7 }).map((_, i) => (
+
+ ))}
+ {calendarData.map(day => (
+
0 ? 'text-white' : 'text-muted-foreground'
+ )}
+ title={`${day.date}: ${day.occupancyPct}%`}
+ >
+ {parseInt(day.date.slice(-2))}
+
+ ))}
+
+
+
+
+ )}
+
+ {/* Daily data table */}
+
+
Daglig data
+ {entries.length === 0 ? (
+
Ingen data registrerad i vald period.
+ ) : (
+
+
+
+
+ Datum
+ Belagda
+ Ur drift
+ Orsak
+ Lediga
+ Belaggning
+
+
+
+
+ {entries.map(e => {
+ const pct = totalRooms > 0 ? Math.round((e.roomsOccupied / totalRooms) * 100) : 0
+ const available = totalRooms - e.roomsOccupied - e.roomsOutOfOrder
+ return (
+
+ {e.date}
+ {e.roomsOccupied} / {totalRooms}
+ {e.roomsOutOfOrder}
+
+ {e.roomsOutOfOrder > 0 ? (e.reason ?? '-') : '-'}
+
+ {available}
+ {pct}%
+
+
+
openEditDialog(e)}>
+
+
+
openDeleteDialog(e.date)}>
+
+
+
+
+
+ )
+ })}
+
+
+
+ )}
+
+
+ {/* Edit entry dialog */}
+
+
+
+ Datum
+
+
+
+ Belagda rum
+ setEditOccupied(e.target.value)}
+ />
+
+
+ Ur drift
+ setEditOoo(e.target.value)}
+ />
+
+
+ Orsak (ur drift)
+ setEditReason(val as OooReason)}>
+
+
+
+
+ {OOO_REASONS.map(r => (
+ {r}
+ ))}
+
+
+
+ {editExceedsTotal && (
+
+ Belagda rum ({editOccupiedNum}) + ur drift ({editOooNum}) = {editOccupiedNum + editOooNum} overstiger totalt antal rum ({totalRooms}).
+
+ )}
+
+
+
+ {/* Confirm delete dialog */}
+
+
+ {/* Settings dialog */}
+
+
+ Totalt antal rum
+ setNewTotalRooms(e.target.value)}
+ />
+
+
+
)
}
diff --git a/components/extensions/hotel/RevparWorkspace.tsx b/components/extensions/hotel/RevparWorkspace.tsx
index c5946835..5ec762ae 100644
--- a/components/extensions/hotel/RevparWorkspace.tsx
+++ b/components/extensions/hotel/RevparWorkspace.tsx
@@ -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 (
+
+
+ 0
+
+ )
+ }
+ const improving = higherIsBetter ? delta > 0 : delta < 0
return (
- }
- />
+
+ {delta > 0
+ ?
+ :
+ }
+ {delta > 0 ? '+' : ''}{delta.toLocaleString('sv-SE')}
+
+ )
+}
+
+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()
+ 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) => {
+ await save('settings', { totalRooms: parseInt(values.totalRooms) || 0 })
+ }
+
+ if (isLoading) return
+
+ if (!totalRooms) {
+ return (
+
+ )
+ }
+
+ return (
+
+
+ setDateRange({ start, end })} />
+
+
+ {totalRooms} rum
+
+
+
+ {/* KPI Cards with delta indicators */}
+
+
+
+
+
+
+
+ {/* Period comparison */}
+
+
Periodjamforelse
+
+
+
RevPAR
+
+
+ {current.revpar.toLocaleString('sv-SE')} kr
+
+
+
+
+ Foregaende: {prev.revpar.toLocaleString('sv-SE')} kr
+
+
+
+
ADR
+
+
+ {current.adr.toLocaleString('sv-SE')} kr
+
+
+
+
+ Foregaende: {prev.adr.toLocaleString('sv-SE')} kr
+
+
+
+
Belaggning
+
+
+ {current.occupancyPct}%
+
+
+
+
+ Foregaende: {prev.occupancyPct}%
+
+
+
+
+
+ Foregaende period: {prevPeriod.start} — {prevPeriod.end}
+
+
+
+
+ {/* Data entry form with validation */}
+
+
+
+ Datum
+ setEntryDate(e.target.value)}
+ />
+
+
+
Rum salda
+
setRoomsSold(e.target.value)}
+ className={cn(roomsSoldError && 'border-red-500')}
+ />
+ {roomsSoldError && (
+
{roomsSoldError}
+ )}
+
+
+
Rumsintakt (kr)
+
setRoomRevenue(e.target.value)}
+ className={cn(roomRevenueError && 'border-red-500')}
+ />
+ {roomRevenueError && (
+
{roomRevenueError}
+ )}
+
+
+
+
+ {/* Monthly trend with RevPAR, ADR, Occupancy */}
+ {monthlyTrend.length > 0 && (
+
+
Manadstrend
+
+
+
+
+ Period
+ RevPAR
+ ADR
+ Belaggning
+
+
+
+ {monthlyTrend.map(row => (
+
+ {row.month}
+
+ {row.revpar.toLocaleString('sv-SE')} kr
+
+
+ {row.adr.toLocaleString('sv-SE')} kr
+
+
+ {row.occupancy}%
+
+
+ ))}
+
+
+
+
+ )}
+
+ {/* Daily data table with edit and delete */}
+
+
Daglig data
+ {entries.length === 0 ? (
+
Ingen data registrerad i vald period.
+ ) : (
+
+
+
+
+ Datum
+ Rum salda
+ Intakt
+ ADR
+
+
+
+
+ {entries.map(e => (
+
+ {e.date}
+
+ {e.roomsSold} / {totalRooms}
+
+
+ {e.roomRevenue.toLocaleString('sv-SE')} kr
+
+
+ {e.roomsSold > 0
+ ? Math.round(e.roomRevenue / e.roomsSold).toLocaleString('sv-SE')
+ : 0} kr
+
+
+
+
openEditDialog(e)}
+ >
+
+
+
openDeleteDialog(e.date)}
+ >
+
+
+
+
+
+ ))}
+
+
+
+ )}
+
+
+ {/* Edit entry dialog */}
+
+
+
+ Datum
+
+
+
+
Rum salda
+
setEditRoomsSold(e.target.value)}
+ className={cn(editRoomsSoldError && 'border-red-500')}
+ />
+ {editRoomsSoldError && (
+
{editRoomsSoldError}
+ )}
+
+
+
Rumsintakt (kr)
+
setEditRoomRevenue(e.target.value)}
+ className={cn(editRoomRevenueError && 'border-red-500')}
+ />
+ {editRoomRevenueError && (
+
{editRoomRevenueError}
+ )}
+
+
+
+
+ {/* Confirm delete dialog */}
+
+
+ {/* Settings dialog */}
+
+
+
Antal rum
+
setSettingsRoomCount(e.target.value)}
+ />
+ {settingsRoomCount !== '' && !settingsValid && (
+
Ange ett giltigt antal rum (minst 1)
+ )}
+
+
+
)
}
diff --git a/components/extensions/restaurant/EarningsPerLiterWorkspace.tsx b/components/extensions/restaurant/EarningsPerLiterWorkspace.tsx
index ec1a55eb..27918563 100644
--- a/components/extensions/restaurant/EarningsPerLiterWorkspace.tsx
+++ b/components/extensions/restaurant/EarningsPerLiterWorkspace.tsx
@@ -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(0)
- const [totalLiters, setTotalLiters] = useState(0)
- const [totalRevenue, setTotalRevenue] = useState(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 = {
+ 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
+ const delta = Math.round(((current - previous) / previous) * 10000) / 100
+ if (delta > 0) {
+ return (
+
+ +{delta}%
+
+ )
+ }
+ if (delta < 0) {
+ return (
+
+ {delta}%
+
+ )
+ }
+ return (
+
+ 0%
+
+ )
+}
+
+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>({})
+ const [isBatchSubmitting, setIsBatchSubmitting] = useState(false)
+
+ // Edit state
+ const [editingEntry, setEditingEntry] = useState(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(null)
+ const [isDeleting, setIsDeleting] = useState(false)
+
+ // Category management state
+ const [newCategoryName, setNewCategoryName] = useState('')
+ const [editingPricing, setEditingPricing] = useState(false)
+ const [pricingInputs, setPricingInputs] = useState>({})
+
+ // 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()
+ 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()
+ 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
+ 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 = {}
+ for (const cat of categories) {
+ inputs[cat] = String(pricing[cat] ?? 100)
+ }
+ setPricingInputs(inputs)
+ setEditingPricing(true)
+ }
+
+ if (dataLoading || revenueLoading) return
return (
-
setDateRange({ start, end })}
- />
+
+
+ Registrera
+ Oversikt
+ Installningar
+
-
-
-
-
-
+ {/* ---- REGISTER TAB ---- */}
+
+ setDateRange({ start, end })} />
-
-
-
-
Datum
-
setEntryDate(e.target.value)}
+ {/* KPI row */}
+
+ 0 ? {
+ value: Math.round(((earningsPerLiter - prevEarningsPerLiter) / prevEarningsPerLiter) * 10000) / 100,
+ label: 'mot foreg. period',
+ } : undefined}
+ />
+ 0 ? {
+ value: Math.round(((totalLiters - prevTotalLiters) / prevTotalLiters) * 10000) / 100,
+ label: 'mot foreg. period',
+ } : undefined}
+ />
+
+
+
+ {/* Revenue comparison */}
+ {estimatedRevenue > 0 && alcoholRevenue > 0 && (
+
+
+
+
Uppskattad vs bokford intakt
+
+ Differens baserat pa snittkr/liter per kategori
+
+
+
+
+ {(Math.round((estimatedRevenue - alcoholRevenue) * 100) / 100).toLocaleString('sv-SE')} kr
+
+
+
+
+
+ )}
+
+ {/* Single entry form / batch mode toggle */}
+
+ setBatchMode(!batchMode)}
+ >
+ {batchMode ? 'Enkel registrering' : 'Registrera flera'}
+
+
+
+ {batchMode ? (
+
+
+
+ Datum
+ setBatchDate(e.target.value)}
+ className="max-w-xs"
+ />
+
+
+ {categories.map(cat => (
+
+
+ {cat} (liter)
+
+ setBatchLiters(prev => ({ ...prev, [cat]: e.target.value }))}
+ />
+
+ ))}
+
+
+
+ ) : (
+
+
+
+ Datum
+ setEntryDate(e.target.value)} />
+
+
+ Kategori
+
+
+
+ {categories.map(c => {c} )}
+
+
+
+
+ Antal liter
+ setLiters(e.target.value)} />
+
+
+
+ )}
+
+ {/* Entry history */}
+
+
Senaste registreringar
+ {entries.length === 0 ? (
+
Inga registreringar i vald period.
+ ) : (
+
+
+
+
+ Datum
+ Kategori
+ Liter
+ Uppsk. intakt
+
+
+
+
+ {entries.slice(0, 20).map(e => {
+ const entryRevenue = Math.round(e.liters * (pricing[e.category] ?? 100) * 100) / 100
+ return (
+
+ {e.date}
+ {e.category}
+ {e.liters.toLocaleString('sv-SE')} l
+ {entryRevenue.toLocaleString('sv-SE')} kr
+
+
+
openEdit(e)}>
+
+
+
setDeletingEntry(e)}>
+
+
+
+
+
+ )
+ })}
+
+
+
+ )}
+
+
+
+ {/* ---- OVERVIEW TAB ---- */}
+
+ setDateRange({ start, end })} />
+
+ {/* Period comparison KPIs */}
+
+
+
Intakt/liter (nuvarande)
+
{earningsPerLiter.toLocaleString('sv-SE')} kr/l
+ {prevEarningsPerLiter > 0 && (
+
+ Foreg: {prevEarningsPerLiter.toLocaleString('sv-SE')} kr/l
+
+
+ )}
+
+
+
Liter (nuvarande)
+
{(Math.round(totalLiters * 100) / 100).toLocaleString('sv-SE')} l
+ {prevTotalLiters > 0 && (
+
+ Foreg: {(Math.round(prevTotalLiters * 100) / 100).toLocaleString('sv-SE')} l
+
+
+ )}
+
+
+
Uppsk. intakt (nuvarande)
+
{estimatedRevenue.toLocaleString('sv-SE')} kr
+ {prevEstimatedRevenue > 0 && (
+
+ Foreg: {prevEstimatedRevenue.toLocaleString('sv-SE')} kr
+
+
+ )}
+
+
+
+ {/* Category breakdown */}
+ {categoryBreakdown.some(c => c.liters > 0) && (
+
+
Per kategori
+
+
+
+
+ Kategori
+ Liter
+ Snittpris/l
+ Uppsk. intakt
+ Kr/liter
+
+
+
+ {categoryBreakdown.map(c => (
+
+ {c.category}
+ {c.liters.toLocaleString('sv-SE')} l
+ {c.avgPrice.toLocaleString('sv-SE')} kr
+ {c.estimatedRevenue.toLocaleString('sv-SE')} kr
+ {c.earningsPerLiter.toLocaleString('sv-SE')} kr/l
+
+ ))}
+
+ Totalt
+ {(Math.round(totalLiters * 100) / 100).toLocaleString('sv-SE')} l
+
+ {estimatedRevenue.toLocaleString('sv-SE')} kr
+ {earningsPerLiter.toLocaleString('sv-SE')} kr/l
+
+
+
+
+
+ )}
+
+ {/* Monthly trend */}
+ {monthlyTrend.length > 0 && (
+
+
Manadstrend
+
+
+ )}
+
+
+ {/* ---- SETTINGS TAB ---- */}
+
+ {/* Category management */}
+
+
+
Kategorier
+
Lagg till, ta bort eller byt namn pa dryckkategorier.
+
+
+
+
setNewCategoryName(e.target.value)}
+ onKeyDown={e => e.key === 'Enter' && handleAddCategory()}
+ className="max-w-xs"
+ />
+
+ Lagg till
+
+
+
+ {categories.length > 0 && (
+
+
+
+
+ Namn
+
+
+
+
+ {categories.map(cat => (
+ 1}
+ onRename={(newName) => handleRenameCategory(cat, newName)}
+ onRemove={() => handleRemoveCategory(cat)}
+ />
+ ))}
+
+
+
+ )}
+
+
+ {/* Pricing settings */}
+
+
+
+
Snittpris per liter
+
+ Anvands for att berakna uppskattad intakt per kategori.
+
+
+ {!editingPricing && (
+
+ Andra
+
+ )}
+
+
+ {editingPricing ? (
+
+
+ {categories.map(cat => (
+
+ {cat} (kr/liter)
+ setPricingInputs(prev => ({ ...prev, [cat]: e.target.value }))}
+ />
+
+ ))}
+
+
+ Spara
+ setEditingPricing(false)}>Avbryt
+
+
+ ) : (
+
+
+
+
+ Kategori
+ Pris (kr/l)
+
+
+
+ {categories.map(cat => (
+
+ {cat}
+ {(pricing[cat] ?? 100).toLocaleString('sv-SE')} kr
+
+ ))}
+
+
+
+ )}
+
+
+
+
+ {/* Edit entry dialog */}
+
{ if (!open) setEditingEntry(null) }}
+ title="Redigera registrering"
+ description="Andra datum, kategori eller antal liter."
+ onSave={handleSaveEdit}
+ isSaving={isSavingEdit}
+ >
+
-
+
-
-
Så fungerar det
-
- 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.
-
-
+ {/* Delete confirmation dialog */}
+
{ 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}
+ />
)
}
+
+// Inline sub-component for category row with rename support
+function CategoryRow({
+ name,
+ canRemove,
+ onRename,
+ onRemove,
+}: {
+ name: string
+ canRemove: boolean
+ onRename: (newName: string) => Promise
+ onRemove: () => Promise
+}) {
+ const [isRenaming, setIsRenaming] = useState(false)
+ const [newName, setNewName] = useState(name)
+
+ const handleRename = async () => {
+ await onRename(newName)
+ setIsRenaming(false)
+ }
+
+ return (
+
+
+ {isRenaming ? (
+
+ setNewName(e.target.value)}
+ onKeyDown={e => e.key === 'Enter' && handleRename()}
+ className="h-8 max-w-[200px]"
+ autoFocus
+ />
+ Spara
+ { setIsRenaming(false); setNewName(name) }}>Avbryt
+
+ ) : (
+ {name}
+ )}
+
+
+
+ {!isRenaming && (
+
setIsRenaming(true)}>
+
+
+ )}
+ {canRemove && !isRenaming && (
+
+
+
+ )}
+
+
+
+ )
+}
diff --git a/components/extensions/restaurant/FoodCostWorkspace.tsx b/components/extensions/restaurant/FoodCostWorkspace.tsx
index 6312c1da..1ae12642 100644
--- a/components/extensions/restaurant/FoodCostWorkspace.tsx
+++ b/components/extensions/restaurant/FoodCostWorkspace.tsx
@@ -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(0)
- const [revenue, setRevenue] = useState(0)
- const [purchases, setPurchases] = useState(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 (
+
+
+ 0 pp
+
+ )
+ }
+ // For food cost: lower is better, so negative delta = green (improving)
+ const improving = delta < 0
+ return (
+
+ {delta < 0
+ ?
+ :
+ }
+ {delta > 0 ? '+' : ''}{delta} pp
+
+ )
+}
+
+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(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 = {}
+ 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 = {}
+ 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
return (
-
setDateRange({ start, end })}
- />
+ setDateRange({ start, end })} />
+ {/* KPI Cards with period comparison */}
-
-
Så fungerar det
-
- 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%.
-
+ {/* Period comparison */}
+
+
Periodjamforelse
+
+
+
Food Cost % (nuvarande)
+
+ {foodCostPct}%
+
+
+
+
+
Food Cost % (foregaende)
+
{prevFoodCostPct}%
+
+
+
Foregaende period
+
{prevPeriod.start} — {prevPeriod.end}
+
+
+
+
+
Varuinkop (foregaende)
+
{prevTotalPurchases.toLocaleString('sv-SE')} kr
+
+
+
Intakter (foregaende)
+
{prevTotalRevenue.toLocaleString('sv-SE')} kr
+
+
+
+ {/* Target setting */}
+
+
+
+
Malvarde
+
+ Riktvarde for food cost (vanligtvis 25-35%)
+
+
+ {editingTarget ? (
+
+ setTargetPct(e.target.value)}
+ className="w-20 h-8 text-sm"
+ />
+ %
+ Spara
+ setEditingTarget(false)}>Avbryt
+
+ ) : (
+
{
+ setNewTargetInput(String(target))
+ setEditTargetDialogOpen(true)
+ }}
+ >
+ {target}% — Andra
+
+ )}
+
+
+
+ {/* Edit target dialog (saves history) */}
+
+
+ Nytt malvarde (%)
+ setNewTargetInput(e.target.value)}
+ className="w-32"
+ />
+
+
+
+ {/* Notes per period */}
+
+
+
+
Anteckningar for {currentMonth}
+
+ Notera avvikelser och forklaringar for perioden
+
+
+ {!editingNote && (
+
+ {currentNote?.text ? 'Redigera' : 'Lagg till'}
+
+ )}
+
+ {editingNote ? (
+
+ ) : currentNote?.text ? (
+
{currentNote.text}
+ ) : (
+
Inga anteckningar for denna period.
+ )}
+
+
+
+
+ {/* Monthly trend */}
+
+
Manadstrend {now.getFullYear()}
+
+
+
+ {/* Category breakdown */}
+ {categoryBreakdown.length > 0 && (
+
+
Varuinkop per kategori
+
+
+
+
+ Kategori
+ Belopp
+ Andel av inkop
+ Andel av intakter
+
+
+
+ {categoryBreakdown.map(g => (
+
+ {g.category}
+
+ {(Math.round(g.total * 100) / 100).toLocaleString('sv-SE')} kr
+
+
+ {totalPurchases > 0
+ ? Math.round((g.total / totalPurchases) * 100)
+ : 0}%
+
+
+ {totalRevenue > 0
+ ? Math.round((g.total / totalRevenue) * 10000) / 100
+ : 0}%
+
+
+ ))}
+
+
+
+
+ )}
+
+ {/* Account breakdown with category assignment */}
+ {purchaseTotals.length > 0 && (
+
+
Varuinkop per konto
+
+
+
+
+ Konto
+ Kategori
+ Debet
+ Andel
+
+
+
+ {purchaseTotals.map(t => (
+
+ {t.account_number}
+
+ handleCategoryChange(t.account_number, val)}
+ >
+
+
+
+
+ Ingen
+ {FOOD_CATEGORIES.map(cat => (
+ {cat}
+ ))}
+
+
+
+
+ {t.debit.toLocaleString('sv-SE')} kr
+
+
+ {totalPurchases > 0
+ ? Math.round((t.debit / totalPurchases) * 100)
+ : 0}%
+
+
+ ))}
+
+
+
+
+ )}
)
}
diff --git a/components/extensions/restaurant/PosImportWorkspace.tsx b/components/extensions/restaurant/PosImportWorkspace.tsx
index 25ef58ca..9063073f 100644
--- a/components/extensions/restaurant/PosImportWorkspace.tsx
+++ b/components/extensions/restaurant/PosImportWorkspace.tsx
@@ -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 = {
+ 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(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(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) }))
+ .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) }))
+ .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[]) => {
+ 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()
+ 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()
+ 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
-export default function PosImportWorkspace() {
return (
- }
- />
+
+
+
+ Import
+ Registrera
+ Historik
+
+
+ {/* CSV Import tab */}
+
+
+
+
+ {/* Manual entry tab */}
+
+
+
+
+ {showManualWarning && (
+
+
+
+ Kontant + Kort + Swish ({manualPaymentSum.toLocaleString('sv-SE')} kr) avviker {manualDiffPct}% fran Totalt ({manualTotal.toLocaleString('sv-SE')} kr).
+
+
+ )}
+
+
+
+ {/* History tab */}
+
+ {/* KPI cards */}
+
+
+
+
+
+
+
+
+ {/* VAT alert */}
+ {vatOutOfRange && dailySales.length > 0 && (
+
+
+
+ Momsandelen ({vatPct}%) ligger utanfor forvantat intervall (20-30%) for svenska restauranger. Kontrollera att moms registreras korrekt.
+
+
+ )}
+
+ {/* Import history */}
+ {imports.length > 0 && (
+
+
Importer
+
+
+
+
+ Datum
+ Fil
+ Rader
+
+
+
+ {imports.map(imp => (
+
+ {imp.date}
+ {imp.fileName}
+ {imp.rowCount}
+
+ ))}
+
+
+
+
+ )}
+
+ {/* VAT per month */}
+ {vatMonthly.length > 0 && (
+
+
Moms per manad
+
+
+
+
+ Period
+ Forsaljning
+ Moms
+ Momsandel
+
+
+
+ {vatMonthly.map(row => (
+
+ {row.month}
+ {row.total.toLocaleString('sv-SE')} kr
+ {row.vat.toLocaleString('sv-SE')} kr
+
+ 0 && (row.vatPct < 20 || row.vatPct > 30) ? 'text-yellow-600 dark:text-yellow-400' : ''}>
+ {row.vatPct}%
+
+
+
+ ))}
+
+
+
+
+ )}
+
+ {/* Payment method trend */}
+ {paymentMonthly.length > 0 && (
+
+
Betalmetoder per manad
+
+
+
+
+ Period
+ Totalt
+ Kontant
+ % Kontant
+ Kort
+ % Kort
+ Swish
+ % Swish
+
+
+
+ {paymentMonthly.map(row => (
+
+ {row.month}
+ {row.total.toLocaleString('sv-SE')} kr
+ {row.cash.toLocaleString('sv-SE')} kr
+ {row.cashPct}%
+ {row.card.toLocaleString('sv-SE')} kr
+ {row.cardPct}%
+ {row.swish.toLocaleString('sv-SE')} kr
+ {row.swishPct}%
+
+ ))}
+
+
+
+
+ )}
+
+ {/* Daily sales table with pagination */}
+
+
Daglig forsaljning
+ {dailySales.length === 0 ? (
+
Ingen data importerad annu.
+ ) : (
+ <>
+
+
+
+
+ Datum
+ Totalt
+ Kontant
+ Kort
+ Swish
+ Moms
+ Moms %
+
+
+
+
+ {paginatedSales.map(d => {
+ const rowVatPct = d.total > 0
+ ? Math.round(d.vat / d.total * 10000) / 100
+ : 0
+ return (
+
+ {d.date}
+ {d.total.toLocaleString('sv-SE')}
+ {d.cash.toLocaleString('sv-SE')}
+ {d.card.toLocaleString('sv-SE')}
+ {d.swish.toLocaleString('sv-SE')}
+ {d.vat.toLocaleString('sv-SE')}
+
+ 0 && (rowVatPct < 20 || rowVatPct > 30) ? 'text-yellow-600 dark:text-yellow-400' : ''}>
+ {rowVatPct}%
+
+
+
+
+
openEdit(d)}>
+
+
+
setDeleteEntry(d)}>
+
+
+
+
+
+ )
+ })}
+
+
+
+
+ {/* Pagination */}
+ {totalPages > 1 && (
+
+
+ Visar {page * PAGE_SIZE + 1}-{Math.min((page + 1) * PAGE_SIZE, dailySales.length)} av {dailySales.length} rader
+
+
+ setPage(p => Math.max(0, p - 1))}
+ disabled={page === 0}
+ >
+
+ Foregaende
+
+
+ Sida {page + 1} av {totalPages}
+
+ setPage(p => Math.min(totalPages - 1, p + 1))}
+ disabled={page >= totalPages - 1}
+ >
+ Nasta
+
+
+
+
+ )}
+ >
+ )}
+
+
+
+
+ {/* Edit dialog */}
+
{ if (!open) setEditEntry(null) }}
+ title="Redigera dagskassa"
+ description={editEntry ? `Redigera data for ${editEntry.date}` : ''}
+ onSave={handleEditSave}
+ isSaving={isSaving}
+ >
+
+
+ {showEditWarning && (
+
+
+
+ Kontant + Kort + Swish ({editPaymentSum.toLocaleString('sv-SE')} kr) avviker {editDiffPct}% fran Totalt ({editTotalVal.toLocaleString('sv-SE')} kr).
+
+
+ )}
+
+
+ {/* Delete confirmation dialog */}
+
{ 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}
+ />
+
)
}
diff --git a/components/extensions/restaurant/TipTrackingWorkspace.tsx b/components/extensions/restaurant/TipTrackingWorkspace.tsx
index 07f366ff..c072f50c 100644
--- a/components/extensions/restaurant/TipTrackingWorkspace.tsx
+++ b/components/extensions/restaurant/TipTrackingWorkspace.tsx
@@ -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),
+ }))
+ .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
+ }, [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(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(null)
+ const [isDeleting, setIsDeleting] = useState(false)
+
+ // Edit employee dialog
+ const [editEmployee, setEditEmployee] = useState(null)
+ const [editEmployeeName, setEditEmployeeName] = useState('')
+ const [isSavingEmployee, setIsSavingEmployee] = useState(false)
+
+ // Per-employee expanded view in overview
+ const [expandedEmployeeId, setExpandedEmployeeId] = useState(null)
+
+ // Tip pool state
+ const [poolAmount, setPoolAmount] = useState('')
+ const [poolSelectedIds, setPoolSelectedIds] = useState>(new Set())
+ const [splitMethod, setSplitMethod] = useState('equal')
+ const [hoursMap, setHoursMap] = useState>({})
+ const [customPctMap, setCustomPctMap] = useState>({})
+
+ // ---------------------------------------------------------------------------
+ // 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()
+ 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()
+ 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
+ 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
-export default function TipTrackingWorkspace() {
return (
- }
- />
+
+ {/* Edit entry dialog */}
+
{ if (!open) setEditEntry(null) }}
+ title="Redigera dricksregistrering"
+ description="Andra uppgifterna for denna registrering."
+ onSave={handleSaveEdit}
+ isSaving={isSavingEdit}
+ >
+
+
+ Datum
+ setEditDate(e.target.value)} />
+
+
+ Skift
+
+
+
+ {SHIFTS.map(s => {s} )}
+
+
+
+
+ Anstalld
+
+
+
+ {activeEmployees.map(e => (
+ {e.name}
+ ))}
+
+
+
+
+ Belopp (kr)
+ setEditAmount(e.target.value)} />
+
+
+
+
+ {/* Delete confirmation dialog */}
+
{ 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 */}
+ { if (!open) setEditEmployee(null) }}
+ title="Redigera anstalld"
+ description="Andra namn pa den anstallda."
+ onSave={handleSaveEmployee}
+ isSaving={isSavingEmployee}
+ >
+
+ Namn
+ setEditEmployeeName(e.target.value)} />
+
+
+
+
+
+ Registrera
+ Oversikt
+ Anstallda
+ Drickspool
+
+
+ {/* ------------------------------------------------------------------ */}
+ {/* Register tab */}
+ {/* ------------------------------------------------------------------ */}
+
+ {activeEmployees.length === 0 ? (
+
+
+ Lagg till anstallda under fliken "Anstallda" for att borja registrera dricks.
+
+
+ ) : (
+
+
+
+ Datum
+ setEntryDate(e.target.value)} />
+
+
+ Skift
+
+
+
+ {SHIFTS.map(s => {s} )}
+
+
+
+
+ Anstalld
+
+
+
+ {activeEmployees.map(e => (
+ {e.name}
+ ))}
+
+
+
+
+ Belopp (kr)
+ setAmount(e.target.value)} />
+
+
+
+ )}
+
+ {/* Recent entries */}
+
+
Senaste registreringar
+ {entries.length === 0 ? (
+
Inga registreringar i vald period.
+ ) : (
+
+
+
+
+ Datum
+ Skift
+ Anstalld
+ Belopp
+
+
+
+
+ {entries.slice(0, 20).map(e => (
+
+ {e.date}
+ {e.shift}
+ {e.employeeName}
+ {e.amount.toLocaleString('sv-SE')} kr
+
+
+
openEditEntry(e)}>
+
+
+
setDeleteKey(e.id)}>
+
+
+
+
+
+ ))}
+
+
+
+ )}
+
+
+
+ {/* ------------------------------------------------------------------ */}
+ {/* Overview tab */}
+ {/* ------------------------------------------------------------------ */}
+
+
+ setDateRange({ start, end })} />
+
+
+ Exportera CSV
+
+
+
+
+
+
+
+
+
+ {monthlyTrend.length > 0 && (
+
+
Manadstrend
+
+
+ )}
+
+ {employeeTotals.length > 0 && (
+
+
Per anstalld
+
+
+
+
+ Anstalld
+ Total
+ Antal skift
+ Snitt/skift
+
+
+
+
+ {employeeTotals.map(e => {
+ const isExpanded = expandedEmployeeId === e.id
+ return (
+
+
+ toggleExpandedEmployee(e.id)}
+ >
+ {isExpanded
+ ?
+ :
+ }
+ {e.name}
+
+
+ {e.total.toLocaleString('sv-SE')} kr
+ {e.count}
+ {Math.round(e.total / e.count).toLocaleString('sv-SE')} kr
+
+
+ )
+ })}
+
+
+
+
+ {/* Expanded employee detail */}
+ {expandedEmployeeId && expandedEmployeeEntries.length > 0 && (
+
+
+ Drickshistorik for {employeeTotals.find(e => e.id === expandedEmployeeId)?.name}
+
+
+
+
+
+ Datum
+ Skift
+ Belopp
+
+
+
+ {expandedEmployeeEntries.map(e => (
+
+ {e.date}
+ {e.shift}
+ {e.amount.toLocaleString('sv-SE')} kr
+
+ ))}
+
+ Totalt / Snitt
+
+ {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)
+
+
+
+
+
+
+ )}
+
+ )}
+
+
+ {/* ------------------------------------------------------------------ */}
+ {/* Employees tab */}
+ {/* ------------------------------------------------------------------ */}
+
+
+
setNewEmployeeName(e.target.value)}
+ onKeyDown={e => e.key === 'Enter' && handleAddEmployee()}
+ className="max-w-xs"
+ />
+
+ Lagg till
+
+
+
+ {employees.length === 0 ? (
+ Inga anstallda tillagda annu.
+ ) : (
+
+
+
+
+ Namn
+ Status
+
+
+
+
+ {employees.map(emp => (
+
+ {emp.name}
+
+
+ {emp.active ? 'Aktiv' : 'Inaktiv'}
+
+
+
+
+
openEditEmployee(emp)}>
+
+
+
handleToggleEmployee(emp)}>
+ {emp.active ? 'Inaktivera' : 'Aktivera'}
+
+
+
+
+ ))}
+
+
+
+ )}
+
+
+ {/* ------------------------------------------------------------------ */}
+ {/* Tip pool tab */}
+ {/* ------------------------------------------------------------------ */}
+
+
+
+
+
Drickspool
+
+ Fordela dricks fran en gemensam pool till utvalda anstallda.
+
+
+
+ {settings.poolingEnabled ? 'Aktiverad' : 'Inaktiverad'}
+
+
+
+ {Boolean(settings.poolingEnabled) && (
+
+ {/* Pool amount */}
+
+ Total poolbelopp (kr)
+ setPoolAmount(e.target.value)}
+ className="max-w-xs"
+ />
+
+
+ {/* Select employees */}
+
+
Valj anstallda
+ {activeEmployees.length === 0 ? (
+
Inga aktiva anstallda.
+ ) : (
+
+ {activeEmployees.map(emp => (
+
+ togglePoolEmployee(emp.id)}
+ />
+
+ {emp.name}
+
+
+ ))}
+
+ )}
+
+
+ {/* Split method */}
+ {poolSelectedEmployees.length > 0 && (
+
+ Fordelningsmetod
+ setSplitMethod(v as SplitMethod)}>
+
+
+
+
+ Lika
+ Per timmar
+ Anpassad %
+
+
+
+ )}
+
+ {/* Hours input (when split by hours) */}
+ {splitMethod === 'hours' && poolSelectedEmployees.length > 0 && (
+
+
Timmar per anstalld
+ {poolSelectedEmployees.map(emp => (
+
+ {emp.name}
+ setHoursMap(prev => ({ ...prev, [emp.id]: e.target.value }))}
+ />
+ timmar
+
+ ))}
+
+ )}
+
+ {/* Custom percentage input */}
+ {splitMethod === 'custom' && poolSelectedEmployees.length > 0 && (
+
+
Procent per anstalld
+ {poolSelectedEmployees.map(emp => (
+
+ {emp.name}
+ setCustomPctMap(prev => ({ ...prev, [emp.id]: e.target.value }))}
+ />
+ %
+
+ ))}
+
+ Summa: {customPctTotal}% {Math.abs(customPctTotal - 100) >= 0.01 && '(maste vara 100%)'}
+
+
+ )}
+
+ {/* Distribution result */}
+ {poolDistribution.length > 0 && poolTotal > 0 && (
+
+
Fordelningsresultat
+
+
+
+
+ Anstalld
+ Andel (kr)
+
+
+
+ {poolDistribution.map(d => (
+
+ {d.name}
+ {d.share.toLocaleString('sv-SE')} kr
+
+ ))}
+
+ Totalt
+
+ {poolDistribution.reduce((s, d) => s + d.share, 0).toLocaleString('sv-SE')} kr
+
+
+
+
+
+
+ )}
+
+ )}
+
+
+
+
)
}
diff --git a/components/extensions/shared/ConfirmDeleteDialog.tsx b/components/extensions/shared/ConfirmDeleteDialog.tsx
new file mode 100644
index 00000000..1c31c6a1
--- /dev/null
+++ b/components/extensions/shared/ConfirmDeleteDialog.tsx
@@ -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
+ 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 (
+
+
+
+
+
+
+ {title}
+ {description}
+
+
+
+
+ onOpenChange(false)} disabled={isDeleting}>
+ Avbryt
+
+
+ {isDeleting ? (
+ <>
+
+ Tar bort...
+ >
+ ) : (
+ 'Ta bort'
+ )}
+
+
+
+
+ )
+}
diff --git a/components/extensions/shared/CsvImportWizard.tsx b/components/extensions/shared/CsvImportWizard.tsx
new file mode 100644
index 00000000..dda8f413
--- /dev/null
+++ b/components/extensions/shared/CsvImportWizard.tsx
@@ -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
+ onImport: (rows: Record[]) => Promise
+ 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([])
+ const [rows, setRows] = useState([])
+ const [mappings, setMappings] = useState>({})
+ const [isImporting, setIsImporting] = useState(false)
+ const [importCount, setImportCount] = useState(0)
+ const [fileName, setFileName] = useState('')
+
+ const handleFileSelect = useCallback((e: React.ChangeEvent) => {
+ 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 = {}
+ 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 = {}
+ 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 (
+
+
+
+ {step === 1 && <> Steg 1: Valj fil>}
+ {step === 2 && <> Steg 2: Kolumnmappning>}
+ {step === 3 && <> Import klar>}
+
+
+
+ {step === 1 && (
+
+
+
+
+ Valj en CSV-fil att importera
+
+
+
+ Valj fil
+
+
+
+
+
+ )}
+
+ {step === 2 && (
+
+
+ {fileName} - {rows.length} rader hittades. Mappa kolumner:
+
+
+ {targetFields.map(field => (
+
+
+ {field.label}{field.required && ' *'}
+
+ setMappings(prev => ({ ...prev, [field.key]: val }))}
+ >
+
+
+
+
+ {headers.map(h => (
+ {h}
+ ))}
+
+
+
+ ))}
+
+
+ {rows.length > 0 && (
+
+
+
+
+ {headers.map(h => (
+ {h}
+ ))}
+
+
+
+ {rows.slice(0, 5).map((row, i) => (
+
+ {row.map((cell, j) => (
+ {cell}
+ ))}
+
+ ))}
+
+
+
+ )}
+
+
+
+ Tillbaka
+
+
+ {isImporting ? 'Importerar...' : `Importera ${rows.length} rader`}
+
+
+
+ )}
+
+ {step === 3 && (
+
+
+
+
+
{importCount} rader importerades
+
+ Fran {fileName}
+
+
+ Importera fler
+
+
+ )}
+
+
+ )
+}
diff --git a/components/extensions/shared/EditEntryDialog.tsx b/components/extensions/shared/EditEntryDialog.tsx
new file mode 100644
index 00000000..f8d4e8d6
--- /dev/null
+++ b/components/extensions/shared/EditEntryDialog.tsx
@@ -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
+ 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 (
+
+
+
+ {title}
+ {description && {description} }
+
+
+ {children}
+
+
+ onOpenChange(false)} disabled={isSaving}>
+ Avbryt
+
+
+ {isSaving ? (
+ <>
+
+ Sparar...
+ >
+ ) : (
+ 'Spara'
+ )}
+
+
+
+
+ )
+}
diff --git a/components/extensions/shared/MonthlyTrendTable.tsx b/components/extensions/shared/MonthlyTrendTable.tsx
new file mode 100644
index 00000000..8a5a6f34
--- /dev/null
+++ b/components/extensions/shared/MonthlyTrendTable.tsx
@@ -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 (
+
+ Ingen data att visa
+
+ )
+ }
+
+ const maxValue = Math.max(...rows.map(r => Math.abs(r.value)), 1)
+ const fmt = formatValue ?? ((v: number) => v.toLocaleString('sv-SE'))
+
+ return (
+
+
+
+
+ Period
+ {valueLabel}
+
+
+
+
+ {rows.map((row) => {
+ const barWidth = Math.round((Math.abs(row.value) / maxValue) * 100)
+ return (
+
+ {row.label ?? row.month}
+
+ {fmt(row.value)} {valueSuffix}
+
+
+
+
+
+ )
+ })}
+
+
+
+ )
+}
diff --git a/components/extensions/shared/SetupPrompt.tsx b/components/extensions/shared/SetupPrompt.tsx
new file mode 100644
index 00000000..ed36af10
--- /dev/null
+++ b/components/extensions/shared/SetupPrompt.tsx
@@ -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) => Promise
+}
+
+export default function SetupPrompt({ title, description, fields, onSave }: SetupPromptProps) {
+ const [values, setValues] = useState>({})
+ 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 (
+
+
+
+
+
+
+
+
{title}
+
{description}
+
+
+
+
+
+ )
+}
diff --git a/components/extensions/tech/BillableHoursWorkspace.tsx b/components/extensions/tech/BillableHoursWorkspace.tsx
index 73c1ae85..bfc2dd7a 100644
--- a/components/extensions/tech/BillableHoursWorkspace.tsx
+++ b/components/extensions/tech/BillableHoursWorkspace.tsx
@@ -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),
+ }))
+ , [data])
+
+ const entries = useMemo(() =>
+ data.filter(d => d.key.startsWith('entry:'))
+ .map(d => ({ id: d.key.replace('entry:', ''), ...(d.value as Omit) }))
+ .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(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(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(null)
+ const [weekCellDay, setWeekCellDay] = useState(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()
+ 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()
+ 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()
+ 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>()
+ for (const p of activeProjects) {
+ const dayMap = new Map()
+ 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()
+ 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) => {
+ 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
+
+ if (!hourlyRate) {
+ return (
+
+ )
+ }
+
+ const visibleProjectStats = showArchived
+ ? projectStats
+ : projectStats.filter(p => p.effectiveStatus !== 'archived')
-export default function BillableHoursWorkspace() {
return (
- }
- />
+
+ {/* Settings gear button */}
+
+
{
+ setShowSettings(open)
+ if (open) setSettingsRate(String(hourlyRate))
+ }}>
+
+
+
+ Installningar
+
+
+
+
+ Installningar
+ Andra ditt globala timpris.
+
+
+
+ Timpris (kr/h)
+ setSettingsRate(e.target.value)}
+ placeholder="T.ex. 1000"
+ />
+
+
+
+ setShowSettings(false)}>Avbryt
+
+ Spara
+
+
+
+
+
+
+
+
+ Tidrapport
+ Veckorapport
+ Oversikt
+ Projekt
+
+
+ {/* --- Timesheet Tab --- */}
+
+ {activeProjects.length === 0 ? (
+
+
+ Lagg till projekt under fliken "Projekt" for att borja rapportera tid.
+
+
+ ) : (
+
+
+
+ Datum
+ setEntryDate(e.target.value)} />
+
+
+ Projekt
+
+
+
+ {activeProjects.map(p => {p.name} )}
+
+
+
+
+ Timmar
+ setHours(e.target.value)} />
+
+
+ Beskrivning
+ setDescription(e.target.value)} />
+
+
+
+
+ Debiterbar
+
+
+ )}
+
+ {/* Today's entries */}
+
+
Idag ({todayStr})
+ {todayEntries.length === 0 ? (
+
Ingen tid registrerad idag.
+ ) : (
+
+
+
+
+ Projekt
+ Timmar
+ Typ
+ Beskrivning
+
+
+
+
+ {todayEntries.map(e => (
+
+ {e.projectName}
+ {e.hours}h
+ {e.billable ? 'Debiterbar' : 'Intern'}
+ {e.description}
+
+
+
openEditEntry(e)}>
+
+
+
setDeleteEntryId(e.id)}>
+
+
+
+
+
+ ))}
+
+
+
+ )}
+
+
+
+ {/* --- Weekly Tab --- */}
+
+ {activeProjects.length === 0 ? (
+
+
+ Lagg till projekt under fliken "Projekt" for att borja rapportera tid.
+
+
+ ) : (
+ <>
+
+ setWeekStart(prev => addDays(prev, -7))}
+ >
+
+ Foregaende
+
+ {weekLabel}
+ setWeekStart(prev => addDays(prev, 7))}
+ >
+ Nasta
+
+
+
+
+
+ >
+ )}
+
+
+ {/* --- Overview Tab --- */}
+
+
+
+
+
+
+
+
+ {monthlyTrend.length > 0 && (
+
+
Belaggning per manad
+
+
+ )}
+
+ {/* Period summary */}
+ {periodSummary.length > 0 && (
+
+
Manatlig sammanstallning
+
+
+
+
+ Period
+ Debiterbara
+ Icke-debiterbara
+ Totalt
+ Intakt
+
+
+
+ {periodSummary.map(row => (
+
+ {row.month}
+ {row.billable}h
+ {row.nonBillable}h
+
+ {Math.round((row.billable + row.nonBillable) * 100) / 100}h
+
+
+ {Math.round(row.revenue).toLocaleString('sv-SE')} kr
+
+
+ ))}
+
+
+
+
+ )}
+
+
+ {/* --- Projects Tab --- */}
+
+
+
+
+
+ Nytt projekt
+
+
+
+
+ Nytt projekt
+ Lagg till ett nytt projekt att rapportera tid pa.
+
+
+
+ Projektnamn
+ setNewProjectName(e.target.value)}
+ />
+
+
+ Kund (valfritt)
+ setNewProjectClient(e.target.value)}
+ />
+
+
+
Timpris (kr/h, valfritt)
+
setNewProjectRate(e.target.value)}
+ />
+
+ Om tomt anvands det globala timpriset ({hourlyRate} kr/h).
+
+
+
+
+ Skapa
+
+
+
+
+
+
+ Visa arkiverade
+
+
+
+ {visibleProjectStats.length === 0 ? (
+ Inga projekt att visa.
+ ) : (
+
+
+
+
+ Projekt
+ Kund
+ Timpris
+ Timmar (manad)
+ Debiterbara
+ Belaggning
+ Status
+
+
+
+
+ {visibleProjectStats.map(p => {
+ const status = p.effectiveStatus
+ return (
+
+ {p.name}
+ {p.client || '-'}
+
+ {p.hourlyRate ? `${p.hourlyRate} kr/h` : `${hourlyRate} kr/h`}
+ {p.hourlyRate ? (
+ (projekt)
+ ) : null}
+
+ {p.total}h
+ {p.billable}h
+ {p.utilization}%
+
+
+ {statusLabel(status)}
+
+
+
+
+ {status === 'active' && (
+
handleProjectStatusChange(p.id, 'completed')}
+ >
+
+
+ )}
+ {status === 'completed' && (
+
handleProjectStatusChange(p.id, 'active')}
+ >
+
+
+ )}
+ {status !== 'archived' && (
+
handleProjectStatusChange(p.id, 'archived')}
+ >
+
+
+ )}
+ {status === 'archived' && (
+
handleProjectStatusChange(p.id, 'active')}
+ >
+
+
+ )}
+
+
+
+ )
+ })}
+
+
+
+ )}
+
+
+
+ {/* Edit Entry Dialog */}
+
{ if (!open) setEditEntry(null) }}
+ title="Redigera tidpost"
+ description="Andra uppgifterna for den registrerade tiden."
+ onSave={handleSaveEdit}
+ isSaving={isSavingEdit}
+ >
+
+ Datum
+ setEditDate(e.target.value)} />
+
+
+ Projekt
+
+
+
+ {activeProjects.map(p => {p.name} )}
+
+
+
+
+ Timmar
+ setEditHours(e.target.value)}
+ />
+
+
+ Beskrivning
+ setEditDescription(e.target.value)}
+ />
+
+
+
+ Debiterbar
+
+
+
+ {/* Confirm Delete Dialog */}
+
{ 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}
+ />
+
)
}
diff --git a/components/extensions/tech/ProjectBillingWorkspace.tsx b/components/extensions/tech/ProjectBillingWorkspace.tsx
index e8c5b1a4..baf5183d 100644
--- a/components/extensions/tech/ProjectBillingWorkspace.tsx
+++ b/components/extensions/tech/ProjectBillingWorkspace.tsx
@@ -1,14 +1,1002 @@
'use client'
-import { Layers } 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 { 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 { 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 {
+ Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogFooter, DialogDescription,
+} from '@/components/ui/dialog'
+import { Pencil, Plus, ChevronDown, ChevronUp, Trash2, CheckCircle } from 'lucide-react'
+import { cn } from '@/lib/utils'
+
+// ---------------------------------------------------------------------------
+// Types
+// ---------------------------------------------------------------------------
+
+interface Project {
+ id: string
+ name: string
+ budget: number
+ status: 'active' | 'completed'
+ startDate: string
+}
+
+interface BillingEntry {
+ id: string
+ projectId: string
+ description: string
+ amount: number
+ date: string
+ invoiced: boolean
+}
+
+const COST_CATEGORIES = ['Lon', 'Material', 'Licenser', 'Ovrigt'] as const
+type CostCategory = typeof COST_CATEGORIES[number]
+
+interface CostEntry {
+ id: string
+ projectId: string
+ description: string
+ amount: number
+ date: string
+ category: CostCategory
+}
+
+// ---------------------------------------------------------------------------
+// Component
+// ---------------------------------------------------------------------------
+
+export default function ProjectBillingWorkspace({}: WorkspaceComponentProps) {
+ const { data, save, remove, refresh, isLoading } = useExtensionData('tech', 'project-billing')
+
+ // ---------------------------------------------------------------------------
+ // Derived data
+ // ---------------------------------------------------------------------------
+
+ const projects = useMemo(() =>
+ data.filter(d => d.key.startsWith('project:'))
+ .map(d => ({ id: d.key.replace('project:', ''), ...(d.value as Omit) }))
+ .sort((a, b) => b.startDate.localeCompare(a.startDate))
+ , [data])
+
+ const billings = useMemo(() =>
+ data.filter(d => d.key.startsWith('billing:'))
+ .map(d => ({ id: d.key.replace('billing:', ''), ...(d.value as Omit) }))
+ .sort((a, b) => b.date.localeCompare(a.date))
+ , [data])
+
+ const costs = useMemo(() =>
+ data.filter(d => d.key.startsWith('cost:'))
+ .map(d => ({ id: d.key.replace('cost:', ''), ...(d.value as Omit) }))
+ .sort((a, b) => b.date.localeCompare(a.date))
+ , [data])
+
+ // ---------------------------------------------------------------------------
+ // UI state
+ // ---------------------------------------------------------------------------
+
+ const [expandedProject, setExpandedProject] = useState(null)
+
+ // New project dialog
+ const [newProjectName, setNewProjectName] = useState('')
+ const [newProjectBudget, setNewProjectBudget] = useState('')
+ const [showNewProject, setShowNewProject] = useState(false)
+
+ // Billing entry form (inline in expanded project)
+ const [billingDesc, setBillingDesc] = useState('')
+ const [billingAmount, setBillingAmount] = useState('')
+ const [isSubmitting, setIsSubmitting] = useState(false)
+
+ // Cost entry form (inline in expanded project)
+ const [costDesc, setCostDesc] = useState('')
+ const [costAmount, setCostAmount] = useState('')
+ const [costCategory, setCostCategory] = useState('Ovrigt')
+ const [isSubmittingCost, setIsSubmittingCost] = useState(false)
+
+ // Edit project dialog
+ const [editProjectOpen, setEditProjectOpen] = useState(false)
+ const [editProjectId, setEditProjectId] = useState(null)
+ const [editProjectName, setEditProjectName] = useState('')
+ const [editProjectBudget, setEditProjectBudget] = useState('')
+ const [isSavingProject, setIsSavingProject] = useState(false)
+
+ // Edit billing dialog
+ const [editBillingOpen, setEditBillingOpen] = useState(false)
+ const [editBillingId, setEditBillingId] = useState(null)
+ const [editBillingDesc, setEditBillingDesc] = useState('')
+ const [editBillingAmount, setEditBillingAmount] = useState('')
+ const [isSavingBilling, setIsSavingBilling] = useState(false)
+
+ // Edit cost dialog
+ const [editCostOpen, setEditCostOpen] = useState(false)
+ const [editCostId, setEditCostId] = useState(null)
+ const [editCostDesc, setEditCostDesc] = useState('')
+ const [editCostAmount, setEditCostAmount] = useState('')
+ const [editCostCategory, setEditCostCategory] = useState('Ovrigt')
+ const [isSavingCost, setIsSavingCost] = useState(false)
+
+ // Delete dialogs
+ const [deleteBillingOpen, setDeleteBillingOpen] = useState(false)
+ const [deleteBillingId, setDeleteBillingId] = useState(null)
+ const [isDeletingBilling, setIsDeletingBilling] = useState(false)
+
+ const [deleteCostOpen, setDeleteCostOpen] = useState(false)
+ const [deleteCostId, setDeleteCostId] = useState(null)
+ const [isDeletingCost, setIsDeletingCost] = useState(false)
+
+ const [deleteProjectOpen, setDeleteProjectOpen] = useState(false)
+ const [deleteProjectId, setDeleteProjectId] = useState(null)
+ const [isDeletingProject, setIsDeletingProject] = useState(false)
+
+ // Complete project dialog
+ const [completeProjectOpen, setCompleteProjectOpen] = useState(false)
+ const [completeProjectId, setCompleteProjectId] = useState(null)
+
+ // ---------------------------------------------------------------------------
+ // Project stats with correct margin calculation
+ // ---------------------------------------------------------------------------
+
+ const projectStats = useMemo(() => {
+ return projects.map(p => {
+ const projectBillings = billings.filter(b => b.projectId === p.id)
+ const projectCosts = costs.filter(c => c.projectId === p.id)
+ const totalBilled = projectBillings.reduce((s, b) => s + b.amount, 0)
+ const totalCosts = projectCosts.reduce((s, c) => s + c.amount, 0)
+ const uninvoiced = projectBillings.filter(b => !b.invoiced).reduce((s, b) => s + b.amount, 0)
+ const budgetRemaining = Math.max(Math.round((p.budget - totalBilled) * 100) / 100, 0)
+ const budgetUsed = p.budget > 0 ? Math.min(Math.round((totalBilled / p.budget) * 100), 100) : 0
+ // Correct margin: (revenue - costs) / revenue * 100
+ const revenue = totalBilled
+ const margin = revenue > 0 ? Math.round(((revenue - totalCosts) / revenue) * 100) : 0
+ return {
+ ...p,
+ totalBilled,
+ totalCosts,
+ uninvoiced,
+ budgetRemaining,
+ budgetUsed,
+ margin,
+ billings: projectBillings,
+ costs: projectCosts,
+ }
+ })
+ }, [projects, billings, costs])
+
+ const activeProjects = useMemo(() => projectStats.filter(p => p.status === 'active'), [projectStats])
+ const completedProjects = useMemo(() => projectStats.filter(p => p.status === 'completed'), [projectStats])
+
+ // ---------------------------------------------------------------------------
+ // KPIs
+ // ---------------------------------------------------------------------------
+
+ const totalBilled = billings.reduce((s, b) => s + b.amount, 0)
+ const totalCostsAll = costs.reduce((s, c) => s + c.amount, 0)
+ const totalUninvoiced = billings.filter(b => !b.invoiced).reduce((s, b) => s + b.amount, 0)
+ const avgMargin = activeProjects.length > 0
+ ? Math.round(activeProjects.reduce((s, p) => s + p.margin, 0) / activeProjects.length)
+ : 0
+ const activeCount = activeProjects.length
+
+ // ---------------------------------------------------------------------------
+ // Handlers: Projects
+ // ---------------------------------------------------------------------------
+
+ const handleAddProject = async () => {
+ if (!newProjectName.trim()) return
+ const id = crypto.randomUUID()
+ await save(`project:${id}`, {
+ name: newProjectName.trim(),
+ budget: parseFloat(newProjectBudget) || 0,
+ status: 'active',
+ startDate: new Date().toISOString().slice(0, 10),
+ })
+ setNewProjectName('')
+ setNewProjectBudget('')
+ setShowNewProject(false)
+ await refresh()
+ }
+
+ const openEditProject = (p: Project) => {
+ setEditProjectId(p.id)
+ setEditProjectName(p.name)
+ setEditProjectBudget(String(p.budget))
+ setEditProjectOpen(true)
+ }
+
+ const handleSaveProject = async () => {
+ if (!editProjectId || !editProjectName.trim()) return
+ setIsSavingProject(true)
+ const existing = projects.find(p => p.id === editProjectId)
+ if (existing) {
+ await save(`project:${editProjectId}`, {
+ name: editProjectName.trim(),
+ budget: parseFloat(editProjectBudget) || 0,
+ status: existing.status,
+ startDate: existing.startDate,
+ })
+ await refresh()
+ }
+ setIsSavingProject(false)
+ }
+
+ const handleCompleteProject = async () => {
+ if (!completeProjectId) return
+ const existing = projects.find(p => p.id === completeProjectId)
+ if (existing) {
+ await save(`project:${completeProjectId}`, {
+ name: existing.name,
+ budget: existing.budget,
+ status: 'completed',
+ startDate: existing.startDate,
+ })
+ await refresh()
+ }
+ setCompleteProjectOpen(false)
+ setCompleteProjectId(null)
+ }
+
+ const handleDeleteProject = async () => {
+ if (!deleteProjectId) return
+ setIsDeletingProject(true)
+ // Remove associated billings and costs
+ const projBillings = billings.filter(b => b.projectId === deleteProjectId)
+ const projCosts = costs.filter(c => c.projectId === deleteProjectId)
+ for (const b of projBillings) {
+ await remove(`billing:${b.id}`)
+ }
+ for (const c of projCosts) {
+ await remove(`cost:${c.id}`)
+ }
+ await remove(`project:${deleteProjectId}`)
+ await refresh()
+ setIsDeletingProject(false)
+ setDeleteProjectOpen(false)
+ setDeleteProjectId(null)
+ if (expandedProject === deleteProjectId) {
+ setExpandedProject(null)
+ }
+ }
+
+ // ---------------------------------------------------------------------------
+ // Handlers: Billing entries
+ // ---------------------------------------------------------------------------
+
+ const handleAddBilling = async (projectId: string) => {
+ const amt = parseFloat(billingAmount)
+ if (isNaN(amt) || amt <= 0) return
+ setIsSubmitting(true)
+ const id = crypto.randomUUID()
+ await save(`billing:${id}`, {
+ projectId,
+ description: billingDesc,
+ amount: Math.round(amt * 100) / 100,
+ date: new Date().toISOString().slice(0, 10),
+ invoiced: false,
+ })
+ setBillingDesc('')
+ setBillingAmount('')
+ await refresh()
+ setIsSubmitting(false)
+ }
+
+ const handleToggleInvoiced = async (b: BillingEntry) => {
+ await save(`billing:${b.id}`, {
+ projectId: b.projectId,
+ description: b.description,
+ amount: b.amount,
+ date: b.date,
+ invoiced: !b.invoiced,
+ })
+ await refresh()
+ }
+
+ const openEditBilling = (b: BillingEntry) => {
+ setEditBillingId(b.id)
+ setEditBillingDesc(b.description)
+ setEditBillingAmount(String(b.amount))
+ setEditBillingOpen(true)
+ }
+
+ const handleSaveBilling = async () => {
+ if (!editBillingId) return
+ setIsSavingBilling(true)
+ const existing = billings.find(b => b.id === editBillingId)
+ if (existing) {
+ const amt = parseFloat(editBillingAmount)
+ if (!isNaN(amt) && amt > 0) {
+ await save(`billing:${editBillingId}`, {
+ projectId: existing.projectId,
+ description: editBillingDesc,
+ amount: Math.round(amt * 100) / 100,
+ date: existing.date,
+ invoiced: existing.invoiced,
+ })
+ await refresh()
+ }
+ }
+ setIsSavingBilling(false)
+ }
+
+ const handleDeleteBilling = async () => {
+ if (!deleteBillingId) return
+ setIsDeletingBilling(true)
+ await remove(`billing:${deleteBillingId}`)
+ await refresh()
+ setIsDeletingBilling(false)
+ setDeleteBillingOpen(false)
+ setDeleteBillingId(null)
+ }
+
+ // ---------------------------------------------------------------------------
+ // Handlers: Cost entries
+ // ---------------------------------------------------------------------------
+
+ const handleAddCost = async (projectId: string) => {
+ const amt = parseFloat(costAmount)
+ if (isNaN(amt) || amt <= 0) return
+ setIsSubmittingCost(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('')
+ setCostCategory('Ovrigt')
+ await refresh()
+ setIsSubmittingCost(false)
+ }
+
+ const openEditCost = (c: CostEntry) => {
+ setEditCostId(c.id)
+ setEditCostDesc(c.description)
+ setEditCostAmount(String(c.amount))
+ setEditCostCategory(c.category)
+ setEditCostOpen(true)
+ }
+
+ const handleSaveCost = async () => {
+ if (!editCostId) return
+ setIsSavingCost(true)
+ const existing = costs.find(c => c.id === editCostId)
+ if (existing) {
+ const amt = parseFloat(editCostAmount)
+ if (!isNaN(amt) && amt > 0) {
+ await save(`cost:${editCostId}`, {
+ projectId: existing.projectId,
+ description: editCostDesc,
+ amount: Math.round(amt * 100) / 100,
+ date: existing.date,
+ category: editCostCategory,
+ })
+ await refresh()
+ }
+ }
+ setIsSavingCost(false)
+ }
+
+ const handleDeleteCost = async () => {
+ if (!deleteCostId) return
+ setIsDeletingCost(true)
+ await remove(`cost:${deleteCostId}`)
+ await refresh()
+ setIsDeletingCost(false)
+ setDeleteCostOpen(false)
+ setDeleteCostId(null)
+ }
+
+ // ---------------------------------------------------------------------------
+ // Render helpers
+ // ---------------------------------------------------------------------------
+
+ if (isLoading) return
+
+ const formatKr = (v: number) => Math.round(v * 100) / 100
+
+ /** Render a single project card (used for both active and completed) */
+ const renderProjectCard = (p: typeof projectStats[number]) => {
+ const isExpanded = expandedProject === p.id
+ const isActive = p.status === 'active'
+
+ return (
+
+ setExpandedProject(isExpanded ? null : p.id)}
+ >
+
+
+
{p.name}
+
+ {isActive ? 'Aktiv' : 'Avslutad'}
+
+ {isActive && (
+ <>
+
{
+ e.stopPropagation()
+ openEditProject(p)
+ }}
+ >
+
+
+
{
+ e.stopPropagation()
+ setDeleteProjectId(p.id)
+ setDeleteProjectOpen(true)
+ }}
+ >
+
+
+ >
+ )}
+
+
+ {isActive && (
+ {
+ e.stopPropagation()
+ setCompleteProjectId(p.id)
+ setCompleteProjectOpen(true)
+ }}
+ >
+
+ Avsluta
+
+ )}
+ {isExpanded ? : }
+
+
+
+ {/* Budget visualization */}
+
+
+
Budget
+
{p.budget.toLocaleString('sv-SE')} kr
+
+
+
Fakturerat
+
{formatKr(p.totalBilled).toLocaleString('sv-SE')} kr
+
+
+
Kostnad
+
{formatKr(p.totalCosts).toLocaleString('sv-SE')} kr
+
+
+
Marginal
+
= 0 ? 'text-green-600' : 'text-red-600'
+ )}>
+ {p.margin}%
+
+
+
+
+ {p.budget > 0 && (
+
+
+ Budgetanvandning
+ {p.budgetUsed}% ({formatKr(p.budgetRemaining).toLocaleString('sv-SE')} kr kvar)
+
+
+
+ )}
+
+
+ {isExpanded && (
+
+ {/* ---- Billing section ---- */}
+
+
Fakturering
+
+ {isActive && (
+
+ )}
+
+ {p.billings.length > 0 && (
+
+
+
+
+ Fakt.
+ Datum
+ Beskrivning
+ Belopp
+
+
+
+
+ {p.billings.map(b => (
+
+
+ handleToggleInvoiced(b)}
+ aria-label="Markera som fakturerad"
+ />
+
+ {b.date}
+ {b.description}
+
+ {formatKr(b.amount).toLocaleString('sv-SE')} kr
+
+
+
+
openEditBilling(b)}
+ >
+
+
+
{
+ setDeleteBillingId(b.id)
+ setDeleteBillingOpen(true)
+ }}
+ >
+
+
+
+
+
+ ))}
+
+
+
+ )}
+
+ {p.billings.length === 0 && (
+
Inga faktureringsrader annu.
+ )}
+
+
+ {/* ---- Cost section ---- */}
+
+
Kostnader
+
+ {isActive && (
+
+ )}
+
+ {p.costs.length > 0 && (
+
+
+
+
+ Datum
+ Beskrivning
+ Kategori
+ Belopp
+
+
+
+
+ {p.costs.map(c => (
+
+ {c.date}
+ {c.description}
+
+ {c.category}
+
+
+ {formatKr(c.amount).toLocaleString('sv-SE')} kr
+
+
+
+
openEditCost(c)}
+ >
+
+
+
{
+ setDeleteCostId(c.id)
+ setDeleteCostOpen(true)
+ }}
+ >
+
+
+
+
+
+ ))}
+
+
+
+ )}
+
+ {p.costs.length === 0 && (
+
Inga kostnader registrerade annu.
+ )}
+
+
+ )}
+
+ )
+ }
-export default function ProjectBillingWorkspace() {
return (
- }
- />
+
+
+
+ Projekt
+ Fakturering
+
+
+ {/* ==== Projects tab ==== */}
+
+
+
+
+
+
+
+
+
+
+
+ Nytt projekt
+
+
+
+
+ Nytt projekt
+ Skapa ett nytt projekt att fakturera mot.
+
+
+
+ Skapa
+
+
+
+
+ {/* Active projects */}
+ {activeProjects.length > 0 && (
+
+ {activeProjects.map(renderProjectCard)}
+
+ )}
+
+ {activeProjects.length === 0 && (
+ Inga aktiva projekt. Skapa ett nytt projekt ovan.
+ )}
+
+ {/* Completed projects */}
+ {completedProjects.length > 0 && (
+
+
Avslutade projekt
+ {completedProjects.map(renderProjectCard)}
+
+ )}
+
+
+ {/* ==== Billing tab ==== */}
+
+
+
+
+
+
+
+ All fakturering
+ {billings.length === 0 ? (
+ Ingen fakturering registrerad annu.
+ ) : (
+
+
+
+
+ Fakt.
+ Datum
+ Projekt
+ Beskrivning
+ Belopp
+
+
+
+ {billings.map(b => {
+ const proj = projects.find(p => p.id === b.projectId)
+ return (
+
+
+ handleToggleInvoiced(b)}
+ aria-label="Markera som fakturerad"
+ />
+
+ {b.date}
+ {proj?.name ?? 'Okant'}
+ {b.description}
+
+ {formatKr(b.amount).toLocaleString('sv-SE')} kr
+
+
+ )
+ })}
+
+
+
+ )}
+
+
+
+ {/* ==== Dialogs ==== */}
+
+ {/* Edit project dialog */}
+
+
+ Projektnamn
+ setEditProjectName(e.target.value)}
+ />
+
+
+ Budget (kr)
+ setEditProjectBudget(e.target.value)}
+ />
+
+
+
+ {/* Edit billing dialog */}
+
+
+ Beskrivning
+ setEditBillingDesc(e.target.value)}
+ />
+
+
+ Belopp (kr)
+ setEditBillingAmount(e.target.value)}
+ />
+
+
+
+ {/* Edit cost dialog */}
+
+
+ Beskrivning
+ setEditCostDesc(e.target.value)}
+ />
+
+
+ Belopp (kr)
+ setEditCostAmount(e.target.value)}
+ />
+
+
+ Kategori
+ setEditCostCategory(v as CostCategory)}>
+
+
+
+
+ {COST_CATEGORIES.map(cat => (
+ {cat}
+ ))}
+
+
+
+
+
+ {/* Complete project confirmation */}
+
+
+
+ Avsluta projekt
+
+ Ar du saker pa att du vill markera projektet som avslutat? Du kan inte langre lagga till rader.
+
+
+
+ setCompleteProjectOpen(false)}>
+ Avbryt
+
+
+
+ Avsluta
+
+
+
+
+
+ {/* Delete billing confirmation */}
+
+
+ {/* Delete cost confirmation */}
+
+
+ {/* Delete project confirmation */}
+
+
)
}
diff --git a/components/theme-provider.tsx b/components/theme-provider.tsx
new file mode 100644
index 00000000..68b4bd19
--- /dev/null
+++ b/components/theme-provider.tsx
@@ -0,0 +1,11 @@
+'use client'
+
+import { ThemeProvider as NextThemesProvider } from 'next-themes'
+import type { ComponentProps } from 'react'
+
+export function ThemeProvider({
+ children,
+ ...props
+}: ComponentProps) {
+ return {children}
+}
diff --git a/components/ui/card.tsx b/components/ui/card.tsx
index 8373ac6f..ad825792 100644
--- a/components/ui/card.tsx
+++ b/components/ui/card.tsx
@@ -8,7 +8,7 @@ const Card = React.forwardRef<
{
+ 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)
+ })
+})
diff --git a/extensions/construction/project-cost/lib/project-cost-calculator.ts b/extensions/construction/project-cost/lib/project-cost-calculator.ts
new file mode 100644
index 00000000..79657010
--- /dev/null
+++ b/extensions/construction/project-cost/lib/project-cost-calculator.ts
@@ -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
()
+ 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(
+ entries: T[],
+ start: string,
+ end: string
+): T[] {
+ return entries.filter(e => e.date >= start && e.date <= end)
+}
diff --git a/extensions/construction/rot-calculator/lib/__tests__/rot-calculator.test.ts b/extensions/construction/rot-calculator/lib/__tests__/rot-calculator.test.ts
new file mode 100644
index 00000000..fbdf9c6a
--- /dev/null
+++ b/extensions/construction/rot-calculator/lib/__tests__/rot-calculator.test.ts
@@ -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)
+ })
+})
diff --git a/extensions/construction/rot-calculator/lib/rot-calculator.ts b/extensions/construction/rot-calculator/lib/rot-calculator.ts
new file mode 100644
index 00000000..6d22e854
--- /dev/null
+++ b/extensions/construction/rot-calculator/lib/rot-calculator.ts
@@ -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 {
+ const yearJobs = filterJobsByYear(jobs, year).filter(
+ j => j.status === 'completed'
+ )
+
+ const quotas = new Map()
+
+ 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 {
+ 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')
+}
diff --git a/extensions/ecommerce/multichannel-revenue/lib/__tests__/multichannel-calculator.test.ts b/extensions/ecommerce/multichannel-revenue/lib/__tests__/multichannel-calculator.test.ts
new file mode 100644
index 00000000..f221beda
--- /dev/null
+++ b/extensions/ecommerce/multichannel-revenue/lib/__tests__/multichannel-calculator.test.ts
@@ -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)
+ })
+})
diff --git a/extensions/ecommerce/multichannel-revenue/lib/multichannel-calculator.ts b/extensions/ecommerce/multichannel-revenue/lib/multichannel-calculator.ts
new file mode 100644
index 00000000..9d7eceda
--- /dev/null
+++ b/extensions/ecommerce/multichannel-revenue/lib/multichannel-calculator.ts
@@ -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 // 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()
+
+ 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()
+ for (const entry of currentEntries) {
+ currentByChannel.set(
+ entry.channel,
+ (currentByChannel.get(entry.channel) ?? 0) + entry.revenue
+ )
+ }
+
+ const previousByChannel = new Map()
+ 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>()
+
+ for (const entry of entries) {
+ if (!monthMap.has(entry.month)) {
+ const channels: Record = {}
+ 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)
+}
diff --git a/extensions/ecommerce/shopify-import/lib/__tests__/shopify-calculator.test.ts b/extensions/ecommerce/shopify-import/lib/__tests__/shopify-calculator.test.ts
new file mode 100644
index 00000000..5c96f122
--- /dev/null
+++ b/extensions/ecommerce/shopify-import/lib/__tests__/shopify-calculator.test.ts
@@ -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 {
+ 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)
+ })
+})
diff --git a/extensions/ecommerce/shopify-import/lib/shopify-calculator.ts b/extensions/ecommerce/shopify-import/lib/shopify-calculator.ts
new file mode 100644
index 00000000..2c1fcff0
--- /dev/null
+++ b/extensions/ecommerce/shopify-import/lib/shopify-calculator.ts
@@ -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()
+
+ 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()
+
+ 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()
+
+ 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
+}
diff --git a/extensions/hotel/occupancy/lib/__tests__/occupancy-calculator.test.ts b/extensions/hotel/occupancy/lib/__tests__/occupancy-calculator.test.ts
new file mode 100644
index 00000000..bed243dc
--- /dev/null
+++ b/extensions/hotel/occupancy/lib/__tests__/occupancy-calculator.test.ts
@@ -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')
+ })
+})
diff --git a/extensions/hotel/occupancy/lib/occupancy-calculator.ts b/extensions/hotel/occupancy/lib/occupancy-calculator.ts
new file mode 100644
index 00000000..1a02aec5
--- /dev/null
+++ b/extensions/hotel/occupancy/lib/occupancy-calculator.ts
@@ -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}`
+}
diff --git a/extensions/hotel/revpar/lib/__tests__/revpar-calculator.test.ts b/extensions/hotel/revpar/lib/__tests__/revpar-calculator.test.ts
new file mode 100644
index 00000000..838523eb
--- /dev/null
+++ b/extensions/hotel/revpar/lib/__tests__/revpar-calculator.test.ts
@@ -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)
+ })
+})
diff --git a/extensions/hotel/revpar/lib/revpar-calculator.ts b/extensions/hotel/revpar/lib/revpar-calculator.ts
new file mode 100644
index 00000000..e1426b0c
--- /dev/null
+++ b/extensions/hotel/revpar/lib/revpar-calculator.ts
@@ -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()
+
+ 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}`
+}
diff --git a/extensions/restaurant/pos-import/lib/__tests__/pos-calculator.test.ts b/extensions/restaurant/pos-import/lib/__tests__/pos-calculator.test.ts
new file mode 100644
index 00000000..efd3145f
--- /dev/null
+++ b/extensions/restaurant/pos-import/lib/__tests__/pos-calculator.test.ts
@@ -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)
+ })
+})
diff --git a/extensions/restaurant/pos-import/lib/pos-calculator.ts b/extensions/restaurant/pos-import/lib/pos-calculator.ts
new file mode 100644
index 00000000..23d81767
--- /dev/null
+++ b/extensions/restaurant/pos-import/lib/pos-calculator.ts
@@ -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()
+
+ 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
+}
diff --git a/extensions/restaurant/tip-tracking/lib/__tests__/tip-calculator.test.ts b/extensions/restaurant/tip-tracking/lib/__tests__/tip-calculator.test.ts
new file mode 100644
index 00000000..414ccc0e
--- /dev/null
+++ b/extensions/restaurant/tip-tracking/lib/__tests__/tip-calculator.test.ts
@@ -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([])
+ })
+})
diff --git a/extensions/restaurant/tip-tracking/lib/tip-calculator.ts b/extensions/restaurant/tip-tracking/lib/tip-calculator.ts
new file mode 100644
index 00000000..0b8ed417
--- /dev/null
+++ b/extensions/restaurant/tip-tracking/lib/tip-calculator.ts
@@ -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()
+
+ 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()
+
+ 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))
+}
diff --git a/extensions/tech/billable-hours/lib/__tests__/billable-hours-calculator.test.ts b/extensions/tech/billable-hours/lib/__tests__/billable-hours-calculator.test.ts
new file mode 100644
index 00000000..d9d1c84c
--- /dev/null
+++ b/extensions/tech/billable-hours/lib/__tests__/billable-hours-calculator.test.ts
@@ -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)
+ })
+})
diff --git a/extensions/tech/billable-hours/lib/billable-hours-calculator.ts b/extensions/tech/billable-hours/lib/billable-hours-calculator.ts
new file mode 100644
index 00000000..921fd9e9
--- /dev/null
+++ b/extensions/tech/billable-hours/lib/billable-hours-calculator.ts
@@ -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()
+
+ 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()
+ 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()
+
+ 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
+}
diff --git a/extensions/tech/project-billing/lib/__tests__/billing-calculator.test.ts b/extensions/tech/project-billing/lib/__tests__/billing-calculator.test.ts
new file mode 100644
index 00000000..c87def15
--- /dev/null
+++ b/extensions/tech/project-billing/lib/__tests__/billing-calculator.test.ts
@@ -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 => ({
+ projectId: 'proj-1',
+ amount: 10000,
+ date: '2025-03-15',
+ invoiced: true,
+ ...overrides,
+ })
+
+ const makeCost = (overrides: Partial = {}): 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)
+ })
+})
diff --git a/extensions/tech/project-billing/lib/billing-calculator.ts b/extensions/tech/project-billing/lib/billing-calculator.ts
new file mode 100644
index 00000000..63af8950
--- /dev/null
+++ b/extensions/tech/project-billing/lib/billing-calculator.ts
@@ -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,
+ }
+}
diff --git a/lib/extensions/__tests__/validation.test.ts b/lib/extensions/__tests__/validation.test.ts
new file mode 100644
index 00000000..81cdac6a
--- /dev/null
+++ b/lib/extensions/__tests__/validation.test.ts
@@ -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')
+ })
+})
diff --git a/lib/extensions/use-account-totals.ts b/lib/extensions/use-account-totals.ts
new file mode 100644
index 00000000..4e44798c
--- /dev/null
+++ b/lib/extensions/use-account-totals.ts
@@ -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([])
+ const [monthly, setMonthly] = useState([])
+ 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,
+ }
+}
diff --git a/lib/extensions/use-extension-data.ts b/lib/extensions/use-extension-data.ts
new file mode 100644
index 00000000..a4731ee1
--- /dev/null
+++ b/lib/extensions/use-extension-data.ts
@@ -0,0 +1,86 @@
+'use client'
+
+import { useState, useEffect, useCallback, useRef } from 'react'
+
+interface ExtensionDataRecord {
+ id: string
+ key: string
+ value: Record
+ created_at: string
+ updated_at: string
+}
+
+export function useExtensionData(sector: string, slug: string) {
+ const [data, setData] = useState([])
+ 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) => {
+ 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 => {
+ 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 }
+}
diff --git a/lib/extensions/validation.ts b/lib/extensions/validation.ts
new file mode 100644
index 00000000..4d4335c9
--- /dev/null
+++ b/lib/extensions/validation.ts
@@ -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
+}
diff --git a/package-lock.json b/package-lock.json
index 605773df..915c351d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index 02c71d04..43e32e11 100644
--- a/package.json
+++ b/package.json
@@ -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",