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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Emil
2026-02-21 15:21:49 +01:00
co-authored by Claude Opus 4.6
parent 026497ed75
commit 6a5b2b7792
52 changed files with 14130 additions and 222 deletions
@@ -1,109 +1,817 @@
'use client'
import { useState, useEffect } from 'react'
import { useState, useMemo } from 'react'
import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
import { useAccountTotals } from '@/lib/extensions/use-account-totals'
import { useExtensionData } from '@/lib/extensions/use-extension-data'
import KPICard from '@/components/extensions/shared/KPICard'
import DateRangeFilter from '@/components/extensions/shared/DateRangeFilter'
import MonthlyTrendTable from '@/components/extensions/shared/MonthlyTrendTable'
import DataEntryForm from '@/components/extensions/shared/DataEntryForm'
import ExtensionLoadingSkeleton from '@/components/extensions/shared/ExtensionLoadingSkeleton'
import ConfirmDeleteDialog from '@/components/extensions/shared/ConfirmDeleteDialog'
import EditEntryDialog from '@/components/extensions/shared/EditEntryDialog'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select'
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from '@/components/ui/table'
import { Trash2, Pencil, ArrowUp, ArrowDown, Minus, Settings, Plus, X } from 'lucide-react'
export default function EarningsPerLiterWorkspace({ userId }: WorkspaceComponentProps) {
const [isLoading, setIsLoading] = useState(true)
const [earningsPerLiter, setEarningsPerLiter] = useState<number>(0)
const [totalLiters, setTotalLiters] = useState<number>(0)
const [totalRevenue, setTotalRevenue] = useState<number>(0)
const DEFAULT_CATEGORIES = ['Ol', 'Vin', 'Sprit']
// Data entry state
const [liters, setLiters] = useState('')
const [entryDate, setEntryDate] = useState(new Date().toISOString().slice(0, 10))
const [isSubmitting, setIsSubmitting] = useState(false)
const DEFAULT_PRICING: Record<string, number> = {
Ol: 80,
Vin: 120,
Sprit: 200,
}
interface LiterEntry {
id: string
date: string
category: string
liters: number
}
interface Pricing {
[category: string]: number
}
function DeltaIndicator({ current, previous }: { current: number; previous: number }) {
if (previous === 0) return <Minus className="h-3.5 w-3.5 text-muted-foreground inline" />
const delta = Math.round(((current - previous) / previous) * 10000) / 100
if (delta > 0) {
return (
<span className="inline-flex items-center gap-0.5 text-xs text-green-600">
<ArrowUp className="h-3 w-3" />+{delta}%
</span>
)
}
if (delta < 0) {
return (
<span className="inline-flex items-center gap-0.5 text-xs text-red-600">
<ArrowDown className="h-3 w-3" />{delta}%
</span>
)
}
return (
<span className="inline-flex items-center gap-0.5 text-xs text-muted-foreground">
<Minus className="h-3 w-3" />0%
</span>
)
}
export default function EarningsPerLiterWorkspace({}: WorkspaceComponentProps) {
const now = new Date()
const [dateRange, setDateRange] = useState({
start: new Date(now.getFullYear(), now.getMonth(), 1).toISOString().slice(0, 10),
end: new Date(now.getFullYear(), now.getMonth() + 1, 0).toISOString().slice(0, 10),
})
useEffect(() => {
// In a real implementation, this would fetch liter entries and revenue data
setIsLoading(false)
}, [dateRange, userId])
const { data, save, remove, refresh, isLoading: dataLoading } = useExtensionData('restaurant', 'earnings-per-liter')
// Settings: categories
const settings = data.find(d => d.key === 'settings')?.value as { categories?: string[] } | undefined
const categories = settings?.categories ?? DEFAULT_CATEGORIES
// Pricing per category (kr per liter)
const pricingData = data.find(d => d.key === 'pricing')?.value as Pricing | undefined
const pricing: Pricing = useMemo(() => {
const base: Pricing = {}
for (const cat of categories) {
base[cat] = pricingData?.[cat] ?? DEFAULT_PRICING[cat] ?? 100
}
return base
}, [categories, pricingData])
// Entries filtered by date range
const entries: LiterEntry[] = useMemo(() =>
data.filter(d => d.key.startsWith('entry:'))
.map(d => ({
id: d.key,
...(d.value as { date: string; category: string; liters: number }),
}))
.filter(e => e.date >= dateRange.start && e.date <= dateRange.end)
.sort((a, b) => b.date.localeCompare(a.date))
, [data, dateRange])
// Previous period entries for comparison
const prevPeriodEntries: LiterEntry[] = useMemo(() => {
const startDate = new Date(dateRange.start)
const endDate = new Date(dateRange.end)
const durationMs = endDate.getTime() - startDate.getTime()
const prevStart = new Date(startDate.getTime() - durationMs - 86400000)
const prevEnd = new Date(startDate.getTime() - 86400000)
const prevStartStr = prevStart.toISOString().slice(0, 10)
const prevEndStr = prevEnd.toISOString().slice(0, 10)
return data.filter(d => d.key.startsWith('entry:'))
.map(d => ({
id: d.key,
...(d.value as { date: string; category: string; liters: number }),
}))
.filter(e => e.date >= prevStartStr && e.date <= prevEndStr)
}, [data, dateRange])
// Form state - single entry
const [entryDate, setEntryDate] = useState(now.toISOString().slice(0, 10))
const [category, setCategory] = useState(categories[0])
const [liters, setLiters] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
// Batch entry mode
const [batchMode, setBatchMode] = useState(false)
const [batchDate, setBatchDate] = useState(now.toISOString().slice(0, 10))
const [batchLiters, setBatchLiters] = useState<Record<string, string>>({})
const [isBatchSubmitting, setIsBatchSubmitting] = useState(false)
// Edit state
const [editingEntry, setEditingEntry] = useState<LiterEntry | null>(null)
const [editDate, setEditDate] = useState('')
const [editCategory, setEditCategory] = useState('')
const [editLiters, setEditLiters] = useState('')
const [isSavingEdit, setIsSavingEdit] = useState(false)
// Delete confirmation state
const [deletingEntry, setDeletingEntry] = useState<LiterEntry | null>(null)
const [isDeleting, setIsDeleting] = useState(false)
// Category management state
const [newCategoryName, setNewCategoryName] = useState('')
const [editingPricing, setEditingPricing] = useState(false)
const [pricingInputs, setPricingInputs] = useState<Record<string, string>>({})
// Alcohol revenue from bookkeeping (accounts 3000-3999)
const { totalCredit: alcoholRevenue, isLoading: revenueLoading } = useAccountTotals({
from: '3000', to: '3999',
dateFrom: dateRange.start, dateTo: dateRange.end,
})
// Calculations
const totalLiters = entries.reduce((s, e) => s + e.liters, 0)
const earningsPerLiter = totalLiters > 0
? Math.round((alcoholRevenue / totalLiters) * 100) / 100
: 0
// Estimated revenue based on pricing
const estimatedRevenue = useMemo(() => {
let total = 0
for (const e of entries) {
const price = pricing[e.category] ?? 100
total += e.liters * price
}
return Math.round(total * 100) / 100
}, [entries, pricing])
// Previous period calculations
const prevTotalLiters = prevPeriodEntries.reduce((s, e) => s + e.liters, 0)
const prevEstimatedRevenue = useMemo(() => {
let total = 0
for (const e of prevPeriodEntries) {
const price = pricing[e.category] ?? 100
total += e.liters * price
}
return Math.round(total * 100) / 100
}, [prevPeriodEntries, pricing])
const prevEarningsPerLiter = prevTotalLiters > 0
? Math.round((prevEstimatedRevenue / prevTotalLiters) * 100) / 100
: 0
// Category breakdown with per-category revenue = liters x avg price
const categoryBreakdown = useMemo(() => {
const map = new Map<string, number>()
for (const e of entries) {
map.set(e.category, (map.get(e.category) ?? 0) + e.liters)
}
return categories.map(cat => {
const catLiters = map.get(cat) ?? 0
const avgPrice = pricing[cat] ?? 100
const estimatedRev = Math.round(catLiters * avgPrice * 100) / 100
const eplCategory = catLiters > 0
? Math.round((estimatedRev / catLiters) * 100) / 100
: 0
return {
category: cat,
liters: catLiters,
avgPrice,
estimatedRevenue: estimatedRev,
earningsPerLiter: eplCategory,
}
})
}, [entries, categories, pricing])
// Monthly trend
const monthlyTrend = useMemo(() => {
const map = new Map<string, number>()
for (const e of entries) {
const month = e.date.slice(0, 7)
map.set(month, (map.get(month) ?? 0) + e.liters)
}
return Array.from(map.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([month, liters]) => ({ month, value: liters }))
}, [entries])
// --- Handlers ---
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
const val = parseFloat(liters)
if (isNaN(val) || val <= 0) return
setIsSubmitting(true)
// In a real implementation, this would save the liter entry via API
setIsSubmitting(false)
const id = crypto.randomUUID()
await save(`entry:${id}`, { date: entryDate, category, liters: val })
setLiters('')
await refresh()
setIsSubmitting(false)
}
if (isLoading) return <ExtensionLoadingSkeleton />
const handleBatchSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setIsBatchSubmitting(true)
for (const cat of categories) {
const val = parseFloat(batchLiters[cat] ?? '')
if (!isNaN(val) && val > 0) {
const id = crypto.randomUUID()
await save(`entry:${id}`, { date: batchDate, category: cat, liters: val })
}
}
setBatchLiters({})
await refresh()
setIsBatchSubmitting(false)
}
const openEdit = (entry: LiterEntry) => {
setEditingEntry(entry)
setEditDate(entry.date)
setEditCategory(entry.category)
setEditLiters(String(entry.liters))
}
const handleSaveEdit = async () => {
if (!editingEntry) return
const val = parseFloat(editLiters)
if (isNaN(val) || val <= 0) return
setIsSavingEdit(true)
await save(editingEntry.id, { date: editDate, category: editCategory, liters: val })
await refresh()
setIsSavingEdit(false)
setEditingEntry(null)
}
const handleConfirmDelete = async () => {
if (!deletingEntry) return
setIsDeleting(true)
await remove(deletingEntry.id)
setIsDeleting(false)
setDeletingEntry(null)
}
// Category management
const handleAddCategory = async () => {
const name = newCategoryName.trim()
if (!name || categories.includes(name)) return
const updated = [...categories, name]
await save('settings', { ...settings, categories: updated })
setNewCategoryName('')
await refresh()
}
const handleRemoveCategory = async (cat: string) => {
const updated = categories.filter(c => c !== cat)
if (updated.length === 0) return
await save('settings', { ...settings, categories: updated })
// Update pricing to remove the category
if (pricingData) {
const updatedPricing = { ...pricingData }
delete updatedPricing[cat]
await save('pricing', updatedPricing)
}
await refresh()
}
const handleRenameCategory = async (oldName: string, newName: string) => {
if (!newName.trim() || oldName === newName.trim()) return
const trimmed = newName.trim()
const updated = categories.map(c => c === oldName ? trimmed : c)
await save('settings', { ...settings, categories: updated })
// Update pricing key
if (pricingData) {
const updatedPricing = { ...pricingData }
if (updatedPricing[oldName] !== undefined) {
updatedPricing[trimmed] = updatedPricing[oldName]
delete updatedPricing[oldName]
}
await save('pricing', updatedPricing)
}
await refresh()
}
const handleSavePricing = async () => {
const updated: Pricing = {}
for (const cat of categories) {
const val = parseFloat(pricingInputs[cat] ?? '')
updated[cat] = !isNaN(val) && val > 0 ? Math.round(val * 100) / 100 : (pricing[cat] ?? 100)
}
await save('pricing', updated)
setEditingPricing(false)
await refresh()
}
const startEditPricing = () => {
const inputs: Record<string, string> = {}
for (const cat of categories) {
inputs[cat] = String(pricing[cat] ?? 100)
}
setPricingInputs(inputs)
setEditingPricing(true)
}
if (dataLoading || revenueLoading) return <ExtensionLoadingSkeleton />
return (
<div className="space-y-6">
<DateRangeFilter
onRangeChange={(start, end) => setDateRange({ start, end })}
/>
<Tabs defaultValue="register">
<TabsList>
<TabsTrigger value="register">Registrera</TabsTrigger>
<TabsTrigger value="overview">Oversikt</TabsTrigger>
<TabsTrigger value="settings">Installningar</TabsTrigger>
</TabsList>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<KPICard
label="Intäkt per liter"
value={earningsPerLiter.toLocaleString('sv-SE')}
suffix="kr/l"
/>
<KPICard
label="Totalt liter"
value={totalLiters.toLocaleString('sv-SE')}
suffix="l"
/>
<KPICard
label="Alkoholintäkter"
value={totalRevenue.toLocaleString('sv-SE')}
suffix="kr"
/>
</div>
{/* ---- REGISTER TAB ---- */}
<TabsContent value="register" className="space-y-6 mt-4">
<DateRangeFilter onRangeChange={(start, end) => setDateRange({ start, end })} />
<DataEntryForm
title="Registrera daglig literförsäljning"
onSubmit={handleSubmit}
submitLabel="Registrera"
isSubmitting={isSubmitting}
>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="entry-date">Datum</Label>
<Input
id="entry-date"
type="date"
value={entryDate}
onChange={e => setEntryDate(e.target.value)}
{/* KPI row */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<KPICard
label="Intakt per liter"
value={earningsPerLiter.toLocaleString('sv-SE')}
suffix="kr/l"
trend={prevEarningsPerLiter > 0 ? {
value: Math.round(((earningsPerLiter - prevEarningsPerLiter) / prevEarningsPerLiter) * 10000) / 100,
label: 'mot foreg. period',
} : undefined}
/>
<KPICard
label="Totalt liter"
value={(Math.round(totalLiters * 100) / 100).toLocaleString('sv-SE')}
suffix="l"
trend={prevTotalLiters > 0 ? {
value: Math.round(((totalLiters - prevTotalLiters) / prevTotalLiters) * 10000) / 100,
label: 'mot foreg. period',
} : undefined}
/>
<KPICard
label="Uppskattad intakt"
value={estimatedRevenue.toLocaleString('sv-SE')}
suffix="kr"
/>
<KPICard
label="Bokford intakt"
value={alcoholRevenue.toLocaleString('sv-SE')}
suffix="kr"
/>
</div>
{/* Revenue comparison */}
{estimatedRevenue > 0 && alcoholRevenue > 0 && (
<div className="rounded-xl border p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">Uppskattad vs bokford intakt</p>
<p className="text-xs text-muted-foreground">
Differens baserat pa snittkr/liter per kategori
</p>
</div>
<div className="text-right">
<p className="text-lg font-semibold tabular-nums">
{(Math.round((estimatedRevenue - alcoholRevenue) * 100) / 100).toLocaleString('sv-SE')} kr
</p>
<DeltaIndicator current={estimatedRevenue} previous={alcoholRevenue} />
</div>
</div>
</div>
)}
{/* Single entry form / batch mode toggle */}
<div className="flex items-center gap-2 justify-end">
<Button
variant={batchMode ? 'default' : 'outline'}
size="sm"
onClick={() => setBatchMode(!batchMode)}
>
{batchMode ? 'Enkel registrering' : 'Registrera flera'}
</Button>
</div>
{batchMode ? (
<DataEntryForm
title="Registrera flera kategorier"
onSubmit={handleBatchSubmit}
submitLabel="Registrera alla"
isSubmitting={isBatchSubmitting}
>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="epl-batch-date">Datum</Label>
<Input
id="epl-batch-date"
type="date"
value={batchDate}
onChange={e => setBatchDate(e.target.value)}
className="max-w-xs"
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{categories.map(cat => (
<div key={cat} className="space-y-2">
<Label htmlFor={`epl-batch-${cat}`}>
{cat} (liter)
</Label>
<Input
id={`epl-batch-${cat}`}
type="number"
step="0.01"
min="0"
placeholder="0.00"
value={batchLiters[cat] ?? ''}
onChange={e => setBatchLiters(prev => ({ ...prev, [cat]: e.target.value }))}
/>
</div>
))}
</div>
</div>
</DataEntryForm>
) : (
<DataEntryForm
title="Registrera literforbrukning"
onSubmit={handleSubmit}
submitLabel="Registrera"
isSubmitting={isSubmitting}
>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="epl-date">Datum</Label>
<Input id="epl-date" type="date" value={entryDate} onChange={e => setEntryDate(e.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="epl-category">Kategori</Label>
<Select value={category} onValueChange={setCategory}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{categories.map(c => <SelectItem key={c} value={c}>{c}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="epl-liters">Antal liter</Label>
<Input id="epl-liters" type="number" step="0.01" min="0" placeholder="0.00" value={liters} onChange={e => setLiters(e.target.value)} />
</div>
</div>
</DataEntryForm>
)}
{/* Entry history */}
<div>
<h3 className="text-sm font-semibold mb-3">Senaste registreringar</h3>
{entries.length === 0 ? (
<p className="text-sm text-muted-foreground">Inga registreringar i vald period.</p>
) : (
<div className="rounded-xl border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Datum</TableHead>
<TableHead>Kategori</TableHead>
<TableHead className="text-right">Liter</TableHead>
<TableHead className="text-right">Uppsk. intakt</TableHead>
<TableHead className="w-20"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{entries.slice(0, 20).map(e => {
const entryRevenue = Math.round(e.liters * (pricing[e.category] ?? 100) * 100) / 100
return (
<TableRow key={e.id}>
<TableCell>{e.date}</TableCell>
<TableCell>{e.category}</TableCell>
<TableCell className="text-right tabular-nums">{e.liters.toLocaleString('sv-SE')} l</TableCell>
<TableCell className="text-right tabular-nums">{entryRevenue.toLocaleString('sv-SE')} kr</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" onClick={() => openEdit(e)}>
<Pencil className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
<Button variant="ghost" size="sm" onClick={() => setDeletingEntry(e)}>
<Trash2 className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
</div>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
)}
</div>
</TabsContent>
{/* ---- OVERVIEW TAB ---- */}
<TabsContent value="overview" className="space-y-6 mt-4">
<DateRangeFilter onRangeChange={(start, end) => setDateRange({ start, end })} />
{/* Period comparison KPIs */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="rounded-xl border p-4 space-y-1">
<p className="text-xs text-muted-foreground">Intakt/liter (nuvarande)</p>
<p className="text-2xl font-semibold tabular-nums">{earningsPerLiter.toLocaleString('sv-SE')} kr/l</p>
{prevEarningsPerLiter > 0 && (
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">Foreg: {prevEarningsPerLiter.toLocaleString('sv-SE')} kr/l</span>
<DeltaIndicator current={earningsPerLiter} previous={prevEarningsPerLiter} />
</div>
)}
</div>
<div className="rounded-xl border p-4 space-y-1">
<p className="text-xs text-muted-foreground">Liter (nuvarande)</p>
<p className="text-2xl font-semibold tabular-nums">{(Math.round(totalLiters * 100) / 100).toLocaleString('sv-SE')} l</p>
{prevTotalLiters > 0 && (
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">Foreg: {(Math.round(prevTotalLiters * 100) / 100).toLocaleString('sv-SE')} l</span>
<DeltaIndicator current={totalLiters} previous={prevTotalLiters} />
</div>
)}
</div>
<div className="rounded-xl border p-4 space-y-1">
<p className="text-xs text-muted-foreground">Uppsk. intakt (nuvarande)</p>
<p className="text-2xl font-semibold tabular-nums">{estimatedRevenue.toLocaleString('sv-SE')} kr</p>
{prevEstimatedRevenue > 0 && (
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">Foreg: {prevEstimatedRevenue.toLocaleString('sv-SE')} kr</span>
<DeltaIndicator current={estimatedRevenue} previous={prevEstimatedRevenue} />
</div>
)}
</div>
</div>
{/* Category breakdown */}
{categoryBreakdown.some(c => c.liters > 0) && (
<div>
<h3 className="text-sm font-semibold mb-3">Per kategori</h3>
<div className="rounded-xl border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Kategori</TableHead>
<TableHead className="text-right">Liter</TableHead>
<TableHead className="text-right">Snittpris/l</TableHead>
<TableHead className="text-right">Uppsk. intakt</TableHead>
<TableHead className="text-right">Kr/liter</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{categoryBreakdown.map(c => (
<TableRow key={c.category}>
<TableCell className="font-medium">{c.category}</TableCell>
<TableCell className="text-right tabular-nums">{c.liters.toLocaleString('sv-SE')} l</TableCell>
<TableCell className="text-right tabular-nums">{c.avgPrice.toLocaleString('sv-SE')} kr</TableCell>
<TableCell className="text-right tabular-nums">{c.estimatedRevenue.toLocaleString('sv-SE')} kr</TableCell>
<TableCell className="text-right tabular-nums">{c.earningsPerLiter.toLocaleString('sv-SE')} kr/l</TableCell>
</TableRow>
))}
<TableRow className="font-semibold">
<TableCell>Totalt</TableCell>
<TableCell className="text-right tabular-nums">{(Math.round(totalLiters * 100) / 100).toLocaleString('sv-SE')} l</TableCell>
<TableCell className="text-right"></TableCell>
<TableCell className="text-right tabular-nums">{estimatedRevenue.toLocaleString('sv-SE')} kr</TableCell>
<TableCell className="text-right tabular-nums">{earningsPerLiter.toLocaleString('sv-SE')} kr/l</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</div>
)}
{/* Monthly trend */}
{monthlyTrend.length > 0 && (
<div>
<h3 className="text-sm font-semibold mb-3">Manadstrend</h3>
<MonthlyTrendTable rows={monthlyTrend} valueLabel="Liter" valueSuffix="l" />
</div>
)}
</TabsContent>
{/* ---- SETTINGS TAB ---- */}
<TabsContent value="settings" className="space-y-6 mt-4">
{/* Category management */}
<div className="rounded-xl border p-4 space-y-4">
<div>
<h3 className="text-sm font-semibold">Kategorier</h3>
<p className="text-xs text-muted-foreground">Lagg till, ta bort eller byt namn pa dryckkategorier.</p>
</div>
<div className="flex gap-2">
<Input
placeholder="Ny kategori"
value={newCategoryName}
onChange={e => setNewCategoryName(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleAddCategory()}
className="max-w-xs"
/>
<Button size="sm" onClick={handleAddCategory} disabled={!newCategoryName.trim()}>
<Plus className="h-4 w-4 mr-1" /> Lagg till
</Button>
</div>
{categories.length > 0 && (
<div className="rounded-xl border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Namn</TableHead>
<TableHead className="w-24"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{categories.map(cat => (
<CategoryRow
key={cat}
name={cat}
canRemove={categories.length > 1}
onRename={(newName) => handleRenameCategory(cat, newName)}
onRemove={() => handleRemoveCategory(cat)}
/>
))}
</TableBody>
</Table>
</div>
)}
</div>
{/* Pricing settings */}
<div className="rounded-xl border p-4 space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold">Snittpris per liter</h3>
<p className="text-xs text-muted-foreground">
Anvands for att berakna uppskattad intakt per kategori.
</p>
</div>
{!editingPricing && (
<Button size="sm" variant="ghost" onClick={startEditPricing}>
<Settings className="h-4 w-4 mr-1" /> Andra
</Button>
)}
</div>
{editingPricing ? (
<div className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{categories.map(cat => (
<div key={cat} className="space-y-2">
<Label htmlFor={`pricing-${cat}`}>{cat} (kr/liter)</Label>
<Input
id={`pricing-${cat}`}
type="number"
step="0.01"
min="0"
value={pricingInputs[cat] ?? ''}
onChange={e => setPricingInputs(prev => ({ ...prev, [cat]: e.target.value }))}
/>
</div>
))}
</div>
<div className="flex gap-2">
<Button size="sm" onClick={handleSavePricing}>Spara</Button>
<Button size="sm" variant="ghost" onClick={() => setEditingPricing(false)}>Avbryt</Button>
</div>
</div>
) : (
<div className="rounded-xl border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Kategori</TableHead>
<TableHead className="text-right">Pris (kr/l)</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{categories.map(cat => (
<TableRow key={cat}>
<TableCell className="font-medium">{cat}</TableCell>
<TableCell className="text-right tabular-nums">{(pricing[cat] ?? 100).toLocaleString('sv-SE')} kr</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
</TabsContent>
</Tabs>
{/* Edit entry dialog */}
<EditEntryDialog
open={editingEntry !== null}
onOpenChange={(open) => { if (!open) setEditingEntry(null) }}
title="Redigera registrering"
description="Andra datum, kategori eller antal liter."
onSave={handleSaveEdit}
isSaving={isSavingEdit}
>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="liters">Antal liter</Label>
<Input
id="liters"
type="number"
step="0.01"
min="0"
placeholder="0.00"
value={liters}
onChange={e => setLiters(e.target.value)}
/>
<Label htmlFor="edit-date">Datum</Label>
<Input id="edit-date" type="date" value={editDate} onChange={e => setEditDate(e.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="edit-category">Kategori</Label>
<Select value={editCategory} onValueChange={setEditCategory}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{categories.map(c => <SelectItem key={c} value={c}>{c}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="edit-liters">Antal liter</Label>
<Input id="edit-liters" type="number" step="0.01" min="0" value={editLiters} onChange={e => setEditLiters(e.target.value)} />
</div>
</div>
</DataEntryForm>
</EditEntryDialog>
<div className="rounded-xl border p-6">
<h3 className="text-sm font-semibold mb-4">Så fungerar det</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
Intäkt per liter beräknas genom att dividera alkoholintäkter med totalt antal sålda
liter. Registrera daglig literförsäljning ovan. Alkoholintäkter hämtas automatiskt
från bokföringen.
</p>
</div>
{/* Delete confirmation dialog */}
<ConfirmDeleteDialog
open={deletingEntry !== null}
onOpenChange={(open) => { if (!open) setDeletingEntry(null) }}
title="Ta bort registrering"
description={deletingEntry ? `Vill du ta bort ${deletingEntry.liters} l ${deletingEntry.category} fran ${deletingEntry.date}?` : ''}
onConfirm={handleConfirmDelete}
isDeleting={isDeleting}
/>
</div>
)
}
// Inline sub-component for category row with rename support
function CategoryRow({
name,
canRemove,
onRename,
onRemove,
}: {
name: string
canRemove: boolean
onRename: (newName: string) => Promise<void>
onRemove: () => Promise<void>
}) {
const [isRenaming, setIsRenaming] = useState(false)
const [newName, setNewName] = useState(name)
const handleRename = async () => {
await onRename(newName)
setIsRenaming(false)
}
return (
<TableRow>
<TableCell>
{isRenaming ? (
<div className="flex items-center gap-2">
<Input
value={newName}
onChange={e => setNewName(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleRename()}
className="h-8 max-w-[200px]"
autoFocus
/>
<Button size="sm" variant="outline" onClick={handleRename}>Spara</Button>
<Button size="sm" variant="ghost" onClick={() => { setIsRenaming(false); setNewName(name) }}>Avbryt</Button>
</div>
) : (
<span className="font-medium">{name}</span>
)}
</TableCell>
<TableCell>
<div className="flex items-center gap-1 justify-end">
{!isRenaming && (
<Button variant="ghost" size="sm" onClick={() => setIsRenaming(true)}>
<Pencil className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
)}
{canRemove && !isRenaming && (
<Button variant="ghost" size="sm" onClick={onRemove}>
<X className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
)}
</div>
</TableCell>
</TableRow>
)
}
@@ -1,64 +1,552 @@
'use client'
import { useState, useEffect } from 'react'
import { useState, useMemo, useCallback } from 'react'
import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
import { useAccountTotals } from '@/lib/extensions/use-account-totals'
import { useExtensionData } from '@/lib/extensions/use-extension-data'
import KPICard from '@/components/extensions/shared/KPICard'
import DateRangeFilter from '@/components/extensions/shared/DateRangeFilter'
import MonthlyTrendTable from '@/components/extensions/shared/MonthlyTrendTable'
import ExtensionLoadingSkeleton from '@/components/extensions/shared/ExtensionLoadingSkeleton'
import ConfirmDeleteDialog from '@/components/extensions/shared/ConfirmDeleteDialog'
import EditEntryDialog from '@/components/extensions/shared/EditEntryDialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select'
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from '@/components/ui/table'
import { ArrowUp, ArrowDown, Minus } from 'lucide-react'
import { cn } from '@/lib/utils'
export default function FoodCostWorkspace({ userId }: WorkspaceComponentProps) {
const [isLoading, setIsLoading] = useState(true)
const [foodCost, setFoodCost] = useState<number>(0)
const [revenue, setRevenue] = useState<number>(0)
const [purchases, setPurchases] = useState<number>(0)
const FOOD_CATEGORIES = ['Kott', 'Fisk', 'Gronsaker', 'Mejeri', 'Drycker', 'Ovrigt'] as const
type FoodCategory = typeof FOOD_CATEGORIES[number]
// Set initial date range to current month
function computePreviousPeriod(start: string, end: string): { start: string; end: string } {
const startDate = new Date(start + 'T00:00:00')
const endDate = new Date(end + 'T00:00:00')
const durationMs = endDate.getTime() - startDate.getTime()
const prevEnd = new Date(startDate.getTime() - 1)
const prevStart = new Date(prevEnd.getTime() - durationMs)
return {
start: prevStart.toISOString().slice(0, 10),
end: prevEnd.toISOString().slice(0, 10),
}
}
function DeltaArrow({ current, previous }: { current: number; previous: number }) {
const delta = Math.round((current - previous) * 100) / 100
if (delta === 0 || (previous === 0 && current === 0)) {
return (
<span className="inline-flex items-center gap-0.5 text-xs text-muted-foreground">
<Minus className="h-3 w-3" />
<span>0 pp</span>
</span>
)
}
// For food cost: lower is better, so negative delta = green (improving)
const improving = delta < 0
return (
<span className={cn(
'inline-flex items-center gap-0.5 text-xs',
improving ? 'text-green-600' : 'text-red-600'
)}>
{delta < 0
? <ArrowDown className="h-3 w-3" />
: <ArrowUp className="h-3 w-3" />
}
<span>{delta > 0 ? '+' : ''}{delta} pp</span>
</span>
)
}
export default function FoodCostWorkspace({}: WorkspaceComponentProps) {
const now = new Date()
const [dateRange, setDateRange] = useState({
start: new Date(now.getFullYear(), now.getMonth(), 1).toISOString().slice(0, 10),
end: new Date(now.getFullYear(), now.getMonth() + 1, 0).toISOString().slice(0, 10),
})
useEffect(() => {
// In a real implementation, this would fetch journal_entry_lines
// and calculate food cost via the API
setIsLoading(false)
}, [dateRange, userId])
const prevPeriod = useMemo(
() => computePreviousPeriod(dateRange.start, dateRange.end),
[dateRange.start, dateRange.end]
)
// Yearly range for monthly trend
const yearStart = `${now.getFullYear()}-01-01`
const yearEnd = `${now.getFullYear()}-12-31`
const { data: extData, save, remove, isLoading: settingsLoading } = useExtensionData('restaurant', 'food-cost')
const settings = extData.find(d => d.key === 'settings')?.value as { targetPct?: number } | undefined
const [targetPctInput, setTargetPct] = useState<string | null>(null)
const [editingTarget, setEditingTarget] = useState(false)
const targetPct = targetPctInput ?? (settings?.targetPct != null ? String(settings.targetPct) : '')
// Category assignments from extension_data (key = "category:{accountNumber}")
const categoryMap = useMemo(() => {
const map: Record<string, FoodCategory> = {}
for (const d of extData) {
if (d.key.startsWith('category:')) {
const account = d.key.replace('category:', '')
map[account] = (d.value as { category: FoodCategory }).category
}
}
return map
}, [extData])
// Notes from extension_data (key = "note:YYYY-MM")
const currentMonth = dateRange.start.slice(0, 7)
const currentNote = extData.find(d => d.key === `note:${currentMonth}`)?.value as { text: string } | undefined
const [noteText, setNoteText] = useState('')
const [editingNote, setEditingNote] = useState(false)
const [savingNote, setSavingNote] = useState(false)
const [deleteNoteOpen, setDeleteNoteOpen] = useState(false)
const [deletingNote, setDeletingNote] = useState(false)
// Target edit dialog state
const [editTargetDialogOpen, setEditTargetDialogOpen] = useState(false)
const [newTargetInput, setNewTargetInput] = useState('')
const [savingTarget, setSavingTarget] = useState(false)
// --- Current period account totals ---
const { totals: purchaseTotals, isLoading: purchasesLoading } = useAccountTotals({
from: '4000', to: '4999',
dateFrom: dateRange.start, dateTo: dateRange.end,
})
const { totals: revenueTotals, isLoading: revenueLoading } = useAccountTotals({
from: '3000', to: '3999',
dateFrom: dateRange.start, dateTo: dateRange.end,
})
// --- Previous period account totals ---
const { totals: prevPurchaseTotals, isLoading: prevPurchasesLoading } = useAccountTotals({
from: '4000', to: '4999',
dateFrom: prevPeriod.start, dateTo: prevPeriod.end,
})
const { totals: prevRevenueTotals, isLoading: prevRevenueLoading } = useAccountTotals({
from: '3000', to: '3999',
dateFrom: prevPeriod.start, dateTo: prevPeriod.end,
})
// Monthly trend data
const { monthly: purchaseMonthly } = useAccountTotals({
from: '4000', to: '4999',
dateFrom: yearStart, dateTo: yearEnd,
groupBy: 'month',
})
const { monthly: revenueMonthly } = useAccountTotals({
from: '3000', to: '3999',
dateFrom: yearStart, dateTo: yearEnd,
groupBy: 'month',
})
// Current period calculations
const totalPurchases = purchaseTotals.reduce((sum, t) => sum + t.debit, 0)
const totalRevenue = revenueTotals.reduce((sum, t) => sum + t.credit, 0)
const foodCostPct = totalRevenue > 0
? Math.round((totalPurchases / totalRevenue) * 10000) / 100
: 0
// Previous period calculations
const prevTotalPurchases = prevPurchaseTotals.reduce((sum, t) => sum + t.debit, 0)
const prevTotalRevenue = prevRevenueTotals.reduce((sum, t) => sum + t.credit, 0)
const prevFoodCostPct = prevTotalRevenue > 0
? Math.round((prevTotalPurchases / prevTotalRevenue) * 10000) / 100
: 0
const target = settings?.targetPct ?? 30
// Monthly trend rows
const monthlyTrend = useMemo(() => {
const months = new Set([
...purchaseMonthly.map(m => m.month),
...revenueMonthly.map(m => m.month),
])
return Array.from(months).sort().map(month => {
const purch = purchaseMonthly.filter(m => m.month === month).reduce((s, m) => s + m.debit, 0)
const rev = revenueMonthly.filter(m => m.month === month).reduce((s, m) => s + m.credit, 0)
const pct = rev > 0 ? Math.round((purch / rev) * 10000) / 100 : 0
return { month, value: pct }
})
}, [purchaseMonthly, revenueMonthly])
// Category breakdown
const categoryBreakdown = useMemo(() => {
const groups: Record<string, { category: FoodCategory; total: number; accounts: string[] }> = {}
for (const cat of FOOD_CATEGORIES) {
groups[cat] = { category: cat, total: 0, accounts: [] }
}
let uncategorizedTotal = 0
const uncategorizedAccounts: string[] = []
for (const t of purchaseTotals) {
const cat = categoryMap[t.account_number]
if (cat && groups[cat]) {
groups[cat].total += t.debit
groups[cat].accounts.push(t.account_number)
} else {
uncategorizedTotal += t.debit
uncategorizedAccounts.push(t.account_number)
}
}
const result = FOOD_CATEGORIES
.map(cat => groups[cat])
.filter(g => g.total > 0 || g.accounts.length > 0)
if (uncategorizedTotal > 0) {
result.push({ category: 'Ovrigt' as FoodCategory, total: uncategorizedTotal, accounts: uncategorizedAccounts })
}
return result
}, [purchaseTotals, categoryMap])
// --- Handlers ---
const saveTarget = async () => {
const val = parseFloat(targetPct)
if (!isNaN(val)) {
// Save target history before changing
const oldTarget = settings?.targetPct
if (oldTarget != null && oldTarget !== val) {
await save(`target-history:${Date.now()}`, {
previousTarget: oldTarget,
newTarget: val,
changedAt: new Date().toISOString(),
})
}
await save('settings', { targetPct: val })
setEditingTarget(false)
}
}
const handleSaveTargetDialog = async () => {
setSavingTarget(true)
const val = parseFloat(newTargetInput)
if (!isNaN(val)) {
const oldTarget = settings?.targetPct
if (oldTarget != null && oldTarget !== val) {
await save(`target-history:${Date.now()}`, {
previousTarget: oldTarget,
newTarget: val,
changedAt: new Date().toISOString(),
})
}
await save('settings', { targetPct: val })
}
setSavingTarget(false)
}
const handleCategoryChange = useCallback(async (accountNumber: string, category: string) => {
if (category === '__none__') {
await remove(`category:${accountNumber}`)
} else {
await save(`category:${accountNumber}`, { category })
}
}, [save, remove])
const handleSaveNote = async () => {
setSavingNote(true)
await save(`note:${currentMonth}`, { text: noteText })
setEditingNote(false)
setSavingNote(false)
}
const handleDeleteNote = async () => {
setDeletingNote(true)
await remove(`note:${currentMonth}`)
setNoteText('')
setEditingNote(false)
setDeletingNote(false)
}
const startEditNote = () => {
setNoteText(currentNote?.text ?? '')
setEditingNote(true)
}
const isLoading = purchasesLoading || revenueLoading || prevPurchasesLoading || prevRevenueLoading || settingsLoading
if (isLoading) return <ExtensionLoadingSkeleton />
return (
<div className="space-y-6">
<DateRangeFilter
onRangeChange={(start, end) => setDateRange({ start, end })}
/>
<DateRangeFilter onRangeChange={(start, end) => setDateRange({ start, end })} />
{/* KPI Cards with period comparison */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<KPICard
label="Food Cost %"
value={foodCost}
value={foodCostPct}
suffix="%"
className={cn(
foodCostPct <= target ? 'border-green-200' : 'border-red-200'
)}
/>
<KPICard
label="Varuinköp"
value={purchases.toLocaleString('sv-SE')}
label="Varuinkop"
value={totalPurchases.toLocaleString('sv-SE')}
suffix="kr"
/>
<KPICard
label="Livsmedelsintäkter"
value={revenue.toLocaleString('sv-SE')}
label="Livsmedelsintakter"
value={totalRevenue.toLocaleString('sv-SE')}
suffix="kr"
/>
</div>
<div className="rounded-xl border p-6">
<h3 className="text-sm font-semibold mb-4">Så fungerar det</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
Food Cost % beräknas automatiskt utifrån din bokföring. Varuinköp (konton 4000-4999)
divideras med livsmedelsintäkter (konton 3000-3999). En bra riktvärde för restauranger
är 25-35%.
</p>
{/* Period comparison */}
<div className="rounded-xl border p-4">
<h3 className="text-sm font-semibold mb-3">Periodjamforelse</h3>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="space-y-1">
<p className="text-xs text-muted-foreground">Food Cost % (nuvarande)</p>
<div className="flex items-center gap-2">
<span className="text-lg font-semibold tabular-nums">{foodCostPct}%</span>
<DeltaArrow current={foodCostPct} previous={prevFoodCostPct} />
</div>
</div>
<div className="space-y-1">
<p className="text-xs text-muted-foreground">Food Cost % (foregaende)</p>
<span className="text-lg font-semibold tabular-nums">{prevFoodCostPct}%</span>
</div>
<div className="space-y-1">
<p className="text-xs text-muted-foreground">Foregaende period</p>
<span className="text-sm tabular-nums">{prevPeriod.start} — {prevPeriod.end}</span>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mt-3 pt-3 border-t">
<div className="space-y-1">
<p className="text-xs text-muted-foreground">Varuinkop (foregaende)</p>
<span className="text-sm tabular-nums">{prevTotalPurchases.toLocaleString('sv-SE')} kr</span>
</div>
<div className="space-y-1">
<p className="text-xs text-muted-foreground">Intakter (foregaende)</p>
<span className="text-sm tabular-nums">{prevTotalRevenue.toLocaleString('sv-SE')} kr</span>
</div>
</div>
</div>
{/* Target setting */}
<div className="rounded-xl border p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">Malvarde</p>
<p className="text-xs text-muted-foreground">
Riktvarde for food cost (vanligtvis 25-35%)
</p>
</div>
{editingTarget ? (
<div className="flex items-center gap-2">
<Input
type="number"
step="0.1"
value={targetPct}
onChange={e => setTargetPct(e.target.value)}
className="w-20 h-8 text-sm"
/>
<Label className="text-sm">%</Label>
<Button size="sm" variant="outline" onClick={saveTarget}>Spara</Button>
<Button size="sm" variant="ghost" onClick={() => setEditingTarget(false)}>Avbryt</Button>
</div>
) : (
<Button
size="sm"
variant="ghost"
onClick={() => {
setNewTargetInput(String(target))
setEditTargetDialogOpen(true)
}}
>
{target}% — Andra
</Button>
)}
</div>
</div>
{/* Edit target dialog (saves history) */}
<EditEntryDialog
open={editTargetDialogOpen}
onOpenChange={setEditTargetDialogOpen}
title="Andra malvarde"
description={`Nuvarande malvarde: ${target}%. Andringshistorik sparas automatiskt.`}
onSave={handleSaveTargetDialog}
isSaving={savingTarget}
>
<div className="space-y-2">
<Label htmlFor="new-target">Nytt malvarde (%)</Label>
<Input
id="new-target"
type="number"
step="0.1"
value={newTargetInput}
onChange={e => setNewTargetInput(e.target.value)}
className="w-32"
/>
</div>
</EditEntryDialog>
{/* Notes per period */}
<div className="rounded-xl border p-4">
<div className="flex items-center justify-between mb-2">
<div>
<p className="text-sm font-medium">Anteckningar for {currentMonth}</p>
<p className="text-xs text-muted-foreground">
Notera avvikelser och forklaringar for perioden
</p>
</div>
{!editingNote && (
<Button size="sm" variant="ghost" onClick={startEditNote}>
{currentNote?.text ? 'Redigera' : 'Lagg till'}
</Button>
)}
</div>
{editingNote ? (
<div className="space-y-3">
<Textarea
value={noteText}
onChange={e => setNoteText(e.target.value)}
placeholder="Beskriv avvikelser, t.ex. menyandring, leverantorsbyte..."
rows={3}
/>
<div className="flex items-center gap-2">
<Button size="sm" onClick={handleSaveNote} disabled={savingNote}>
{savingNote ? 'Sparar...' : 'Spara'}
</Button>
<Button size="sm" variant="ghost" onClick={() => setEditingNote(false)}>
Avbryt
</Button>
{currentNote?.text && (
<Button
size="sm"
variant="ghost"
className="text-red-600 hover:text-red-700"
onClick={() => setDeleteNoteOpen(true)}
>
Ta bort
</Button>
)}
</div>
</div>
) : currentNote?.text ? (
<p className="text-sm whitespace-pre-wrap">{currentNote.text}</p>
) : (
<p className="text-sm text-muted-foreground">Inga anteckningar for denna period.</p>
)}
</div>
<ConfirmDeleteDialog
open={deleteNoteOpen}
onOpenChange={setDeleteNoteOpen}
title="Ta bort anteckning"
description={`Vill du ta bort anteckningen for ${currentMonth}?`}
onConfirm={handleDeleteNote}
isDeleting={deletingNote}
/>
{/* Monthly trend */}
<div>
<h3 className="text-sm font-semibold mb-3">Manadstrend {now.getFullYear()}</h3>
<MonthlyTrendTable
rows={monthlyTrend}
valueLabel="Food Cost %"
valueSuffix="%"
/>
</div>
{/* Category breakdown */}
{categoryBreakdown.length > 0 && (
<div>
<h3 className="text-sm font-semibold mb-3">Varuinkop per kategori</h3>
<div className="rounded-xl border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Kategori</TableHead>
<TableHead className="text-right">Belopp</TableHead>
<TableHead className="text-right">Andel av inkop</TableHead>
<TableHead className="text-right">Andel av intakter</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{categoryBreakdown.map(g => (
<TableRow key={g.category}>
<TableCell className="font-medium">{g.category}</TableCell>
<TableCell className="text-right tabular-nums">
{(Math.round(g.total * 100) / 100).toLocaleString('sv-SE')} kr
</TableCell>
<TableCell className="text-right tabular-nums">
{totalPurchases > 0
? Math.round((g.total / totalPurchases) * 100)
: 0}%
</TableCell>
<TableCell className="text-right tabular-nums">
{totalRevenue > 0
? Math.round((g.total / totalRevenue) * 10000) / 100
: 0}%
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)}
{/* Account breakdown with category assignment */}
{purchaseTotals.length > 0 && (
<div>
<h3 className="text-sm font-semibold mb-3">Varuinkop per konto</h3>
<div className="rounded-xl border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Konto</TableHead>
<TableHead>Kategori</TableHead>
<TableHead className="text-right">Debet</TableHead>
<TableHead className="text-right">Andel</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{purchaseTotals.map(t => (
<TableRow key={t.account_number}>
<TableCell className="font-medium">{t.account_number}</TableCell>
<TableCell>
<Select
value={categoryMap[t.account_number] ?? '__none__'}
onValueChange={(val) => handleCategoryChange(t.account_number, val)}
>
<SelectTrigger className="h-8 w-[130px] text-xs">
<SelectValue placeholder="Valj kategori" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">Ingen</SelectItem>
{FOOD_CATEGORIES.map(cat => (
<SelectItem key={cat} value={cat}>{cat}</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
<TableCell className="text-right tabular-nums">
{t.debit.toLocaleString('sv-SE')} kr
</TableCell>
<TableCell className="text-right tabular-nums">
{totalPurchases > 0
? Math.round((t.debit / totalPurchases) * 100)
: 0}%
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)}
</div>
)
}
@@ -1,14 +1,696 @@
'use client'
import { Receipt } from 'lucide-react'
import EmptyExtensionState from '@/components/extensions/shared/EmptyExtensionState'
import { useState, useMemo } from 'react'
import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
import { useExtensionData } from '@/lib/extensions/use-extension-data'
import KPICard from '@/components/extensions/shared/KPICard'
import CsvImportWizard from '@/components/extensions/shared/CsvImportWizard'
import ExtensionLoadingSkeleton from '@/components/extensions/shared/ExtensionLoadingSkeleton'
import ConfirmDeleteDialog from '@/components/extensions/shared/ConfirmDeleteDialog'
import EditEntryDialog from '@/components/extensions/shared/EditEntryDialog'
import DataEntryForm from '@/components/extensions/shared/DataEntryForm'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from '@/components/ui/table'
import { Pencil, Trash2, AlertTriangle, ChevronLeft, ChevronRight } from 'lucide-react'
interface DailySale {
date: string
total: number
cash: number
card: number
swish: number
vat: number
}
interface ImportRecord {
id: string
date: string
fileName: string
rowCount: number
}
const PAGE_SIZE = 20
const TARGET_FIELDS = [
{ key: 'date', label: 'Datum', required: true },
{ key: 'total', label: 'Totalt', required: true },
{ key: 'cash', label: 'Kontant' },
{ key: 'card', label: 'Kort' },
{ key: 'swish', label: 'Swish' },
{ key: 'vat', label: 'Moms' },
]
const DEFAULT_MAPPINGS: Record<string, string> = {
date: 'Datum',
total: 'Total',
cash: 'Kontant',
card: 'Kort',
swish: 'Swish',
vat: 'Moms',
}
const parseNum = (v?: string) => {
if (!v) return 0
return Math.round(parseFloat(v.replace(/\s/g, '').replace(',', '.')) * 100) / 100 || 0
}
export default function PosImportWorkspace({}: WorkspaceComponentProps) {
const { data, save, remove, refresh, isLoading } = useExtensionData('restaurant', 'pos-import')
// Pagination
const [page, setPage] = useState(0)
// Manual entry form state
const now = new Date()
const [entryDate, setEntryDate] = useState(now.toISOString().slice(0, 10))
const [entryTotal, setEntryTotal] = useState('')
const [entryCash, setEntryCash] = useState('')
const [entryCard, setEntryCard] = useState('')
const [entrySwish, setEntrySwish] = useState('')
const [entryVat, setEntryVat] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
// Edit dialog state
const [editEntry, setEditEntry] = useState<DailySale | null>(null)
const [editTotal, setEditTotal] = useState('')
const [editCash, setEditCash] = useState('')
const [editCard, setEditCard] = useState('')
const [editSwish, setEditSwish] = useState('')
const [editVat, setEditVat] = useState('')
const [isSaving, setIsSaving] = useState(false)
// Delete dialog state
const [deleteEntry, setDeleteEntry] = useState<DailySale | null>(null)
const [isDeleting, setIsDeleting] = useState(false)
const dailySales = useMemo(() =>
data.filter(d => d.key.startsWith('daily:'))
.map(d => ({ date: d.key.replace('daily:', ''), ...(d.value as Omit<DailySale, 'date'>) }))
.sort((a, b) => b.date.localeCompare(a.date))
, [data])
const imports = useMemo(() =>
data.filter(d => d.key.startsWith('import:'))
.map(d => ({ id: d.key, ...(d.value as Omit<ImportRecord, 'id'>) }))
.sort((a, b) => b.date.localeCompare(a.date))
, [data])
// Pagination calculations
const totalPages = Math.max(1, Math.ceil(dailySales.length / PAGE_SIZE))
const paginatedSales = dailySales.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE)
const handleImport = async (rows: Record<string, string>[]) => {
const importId = crypto.randomUUID()
let count = 0
for (const row of rows) {
const date = row.date
if (!date) continue
await save(`daily:${date}`, {
total: parseNum(row.total),
cash: parseNum(row.cash),
card: parseNum(row.card),
swish: parseNum(row.swish),
vat: parseNum(row.vat),
})
count++
}
await save(`import:${importId}`, {
date: new Date().toISOString().slice(0, 10),
fileName: `CSV-import`,
rowCount: count,
})
await refresh()
setPage(0)
}
// Manual entry validation
const manualPaymentSum = Math.round(
((parseFloat(entryCash) || 0) + (parseFloat(entryCard) || 0) + (parseFloat(entrySwish) || 0)) * 100
) / 100
const manualTotal = Math.round((parseFloat(entryTotal) || 0) * 100) / 100
const manualDiffPct = manualTotal > 0
? Math.round(Math.abs(manualPaymentSum - manualTotal) / manualTotal * 10000) / 100
: 0
const showManualWarning = manualTotal > 0 && manualPaymentSum > 0 && manualDiffPct > 5
const handleManualSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!entryDate || !entryTotal) return
setIsSubmitting(true)
const total = Math.round((parseFloat(entryTotal) || 0) * 100) / 100
const cash = Math.round((parseFloat(entryCash) || 0) * 100) / 100
const card = Math.round((parseFloat(entryCard) || 0) * 100) / 100
const swish = Math.round((parseFloat(entrySwish) || 0) * 100) / 100
const vat = Math.round((parseFloat(entryVat) || 0) * 100) / 100
await save(`daily:${entryDate}`, { total, cash, card, swish, vat })
await refresh()
setEntryTotal('')
setEntryCash('')
setEntryCard('')
setEntrySwish('')
setEntryVat('')
setIsSubmitting(false)
setPage(0)
}
// Edit handlers
const openEdit = (entry: DailySale) => {
setEditEntry(entry)
setEditTotal(String(entry.total))
setEditCash(String(entry.cash))
setEditCard(String(entry.card))
setEditSwish(String(entry.swish))
setEditVat(String(entry.vat))
}
const handleEditSave = async () => {
if (!editEntry) return
setIsSaving(true)
const total = Math.round((parseFloat(editTotal) || 0) * 100) / 100
const cash = Math.round((parseFloat(editCash) || 0) * 100) / 100
const card = Math.round((parseFloat(editCard) || 0) * 100) / 100
const swish = Math.round((parseFloat(editSwish) || 0) * 100) / 100
const vat = Math.round((parseFloat(editVat) || 0) * 100) / 100
await save(`daily:${editEntry.date}`, { total, cash, card, swish, vat })
await refresh()
setIsSaving(false)
}
// Edit dialog validation
const editPaymentSum = Math.round(
((parseFloat(editCash) || 0) + (parseFloat(editCard) || 0) + (parseFloat(editSwish) || 0)) * 100
) / 100
const editTotalVal = Math.round((parseFloat(editTotal) || 0) * 100) / 100
const editDiffPct = editTotalVal > 0
? Math.round(Math.abs(editPaymentSum - editTotalVal) / editTotalVal * 10000) / 100
: 0
const showEditWarning = editTotalVal > 0 && editPaymentSum > 0 && editDiffPct > 5
// Delete handler
const handleDelete = async () => {
if (!deleteEntry) return
setIsDeleting(true)
await remove(`daily:${deleteEntry.date}`)
await refresh()
setIsDeleting(false)
}
// KPI calculations
const totals = useMemo(() => {
const total = dailySales.reduce((s, d) => s + d.total, 0)
const cash = dailySales.reduce((s, d) => s + d.cash, 0)
const card = dailySales.reduce((s, d) => s + d.card, 0)
const swish = dailySales.reduce((s, d) => s + d.swish, 0)
const vat = dailySales.reduce((s, d) => s + d.vat, 0)
const avg = dailySales.length > 0 ? Math.round(total / dailySales.length) : 0
return { total, cash, card, swish, vat, avg }
}, [dailySales])
// VAT analytics
const vatPct = totals.total > 0
? Math.round(totals.vat / totals.total * 10000) / 100
: 0
const vatOutOfRange = vatPct > 0 && (vatPct < 20 || vatPct > 30)
// Payment method monthly breakdown
const paymentMonthly = useMemo(() => {
const map = new Map<string, { cash: number; card: number; swish: number; total: number }>()
for (const d of dailySales) {
const month = d.date.slice(0, 7)
const existing = map.get(month) ?? { cash: 0, card: 0, swish: 0, total: 0 }
existing.cash += d.cash
existing.card += d.card
existing.swish += d.swish
existing.total += d.total
map.set(month, existing)
}
return Array.from(map.entries())
.sort(([a], [b]) => b.localeCompare(a))
.map(([month, vals]) => ({
month,
cashPct: vals.total > 0 ? Math.round(vals.cash / vals.total * 10000) / 100 : 0,
cardPct: vals.total > 0 ? Math.round(vals.card / vals.total * 10000) / 100 : 0,
swishPct: vals.total > 0 ? Math.round(vals.swish / vals.total * 10000) / 100 : 0,
cash: vals.cash,
card: vals.card,
swish: vals.swish,
total: vals.total,
}))
}, [dailySales])
// VAT per month
const vatMonthly = useMemo(() => {
const map = new Map<string, { vat: number; total: number }>()
for (const d of dailySales) {
const month = d.date.slice(0, 7)
const existing = map.get(month) ?? { vat: 0, total: 0 }
existing.vat += d.vat
existing.total += d.total
map.set(month, existing)
}
return Array.from(map.entries())
.sort(([a], [b]) => b.localeCompare(a))
.map(([month, vals]) => ({
month,
vat: Math.round(vals.vat * 100) / 100,
total: Math.round(vals.total * 100) / 100,
vatPct: vals.total > 0 ? Math.round(vals.vat / vals.total * 10000) / 100 : 0,
}))
}, [dailySales])
if (isLoading) return <ExtensionLoadingSkeleton />
export default function PosImportWorkspace() {
return (
<EmptyExtensionState
title="Kassa Z-rapport import"
description="Stöd för import av Z-rapporter kommer snart. Du kommer kunna importera dagliga kassarapporter direkt från ditt kassasystem."
icon={<Receipt className="h-12 w-12 text-muted-foreground/40 mb-4" />}
/>
<div className="space-y-6">
<Tabs defaultValue="import">
<TabsList>
<TabsTrigger value="import">Import</TabsTrigger>
<TabsTrigger value="register">Registrera</TabsTrigger>
<TabsTrigger value="history">Historik</TabsTrigger>
</TabsList>
{/* CSV Import tab */}
<TabsContent value="import" className="space-y-6 mt-4">
<CsvImportWizard
targetFields={TARGET_FIELDS}
defaultMappings={DEFAULT_MAPPINGS}
onImport={handleImport}
/>
</TabsContent>
{/* Manual entry tab */}
<TabsContent value="register" className="space-y-6 mt-4">
<DataEntryForm
title="Registrera dagskassa"
onSubmit={handleManualSubmit}
submitLabel="Registrera"
isSubmitting={isSubmitting}
>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="pos-date">Datum</Label>
<Input
id="pos-date"
type="date"
value={entryDate}
onChange={e => setEntryDate(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="pos-total">Totalt (kr)</Label>
<Input
id="pos-total"
type="number"
step="0.01"
min="0"
placeholder="0"
value={entryTotal}
onChange={e => setEntryTotal(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="pos-cash">Kontant (kr)</Label>
<Input
id="pos-cash"
type="number"
step="0.01"
min="0"
placeholder="0"
value={entryCash}
onChange={e => setEntryCash(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="pos-card">Kort (kr)</Label>
<Input
id="pos-card"
type="number"
step="0.01"
min="0"
placeholder="0"
value={entryCard}
onChange={e => setEntryCard(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="pos-swish">Swish (kr)</Label>
<Input
id="pos-swish"
type="number"
step="0.01"
min="0"
placeholder="0"
value={entrySwish}
onChange={e => setEntrySwish(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="pos-vat">Moms (kr)</Label>
<Input
id="pos-vat"
type="number"
step="0.01"
min="0"
placeholder="0"
value={entryVat}
onChange={e => setEntryVat(e.target.value)}
/>
</div>
</div>
{showManualWarning && (
<div className="flex items-center gap-2 rounded-lg border border-yellow-300 bg-yellow-50 p-3 text-sm text-yellow-800 dark:border-yellow-700 dark:bg-yellow-950/30 dark:text-yellow-200">
<AlertTriangle className="h-4 w-4 shrink-0" />
<span>
Kontant + Kort + Swish ({manualPaymentSum.toLocaleString('sv-SE')} kr) avviker {manualDiffPct}% fran Totalt ({manualTotal.toLocaleString('sv-SE')} kr).
</span>
</div>
)}
</DataEntryForm>
</TabsContent>
{/* History tab */}
<TabsContent value="history" className="space-y-6 mt-4">
{/* KPI cards */}
<div className="grid grid-cols-1 sm:grid-cols-5 gap-4">
<KPICard label="Total forsaljning" value={totals.total.toLocaleString('sv-SE')} suffix="kr" />
<KPICard label="Snitt per dag" value={totals.avg.toLocaleString('sv-SE')} suffix="kr" />
<KPICard label="Kort" value={totals.card.toLocaleString('sv-SE')} suffix="kr" />
<KPICard label="Swish" value={totals.swish.toLocaleString('sv-SE')} suffix="kr" />
<KPICard
label="Snitt momsandel"
value={vatPct}
suffix="%"
className={vatOutOfRange ? 'border-yellow-300 dark:border-yellow-700' : ''}
/>
</div>
{/* VAT alert */}
{vatOutOfRange && dailySales.length > 0 && (
<div className="flex items-center gap-2 rounded-lg border border-yellow-300 bg-yellow-50 p-3 text-sm text-yellow-800 dark:border-yellow-700 dark:bg-yellow-950/30 dark:text-yellow-200">
<AlertTriangle className="h-4 w-4 shrink-0" />
<span>
Momsandelen ({vatPct}%) ligger utanfor forvantat intervall (20-30%) for svenska restauranger. Kontrollera att moms registreras korrekt.
</span>
</div>
)}
{/* Import history */}
{imports.length > 0 && (
<div>
<h3 className="text-sm font-semibold mb-3">Importer</h3>
<div className="rounded-xl border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Datum</TableHead>
<TableHead>Fil</TableHead>
<TableHead className="text-right">Rader</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{imports.map(imp => (
<TableRow key={imp.id}>
<TableCell>{imp.date}</TableCell>
<TableCell>{imp.fileName}</TableCell>
<TableCell className="text-right tabular-nums">{imp.rowCount}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)}
{/* VAT per month */}
{vatMonthly.length > 0 && (
<div>
<h3 className="text-sm font-semibold mb-3">Moms per manad</h3>
<div className="rounded-xl border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Period</TableHead>
<TableHead className="text-right">Forsaljning</TableHead>
<TableHead className="text-right">Moms</TableHead>
<TableHead className="text-right">Momsandel</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{vatMonthly.map(row => (
<TableRow key={row.month}>
<TableCell className="font-medium">{row.month}</TableCell>
<TableCell className="text-right tabular-nums">{row.total.toLocaleString('sv-SE')} kr</TableCell>
<TableCell className="text-right tabular-nums">{row.vat.toLocaleString('sv-SE')} kr</TableCell>
<TableCell className="text-right tabular-nums">
<span className={row.vatPct > 0 && (row.vatPct < 20 || row.vatPct > 30) ? 'text-yellow-600 dark:text-yellow-400' : ''}>
{row.vatPct}%
</span>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)}
{/* Payment method trend */}
{paymentMonthly.length > 0 && (
<div>
<h3 className="text-sm font-semibold mb-3">Betalmetoder per manad</h3>
<div className="rounded-xl border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Period</TableHead>
<TableHead className="text-right">Totalt</TableHead>
<TableHead className="text-right">Kontant</TableHead>
<TableHead className="text-right">% Kontant</TableHead>
<TableHead className="text-right">Kort</TableHead>
<TableHead className="text-right">% Kort</TableHead>
<TableHead className="text-right">Swish</TableHead>
<TableHead className="text-right">% Swish</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{paymentMonthly.map(row => (
<TableRow key={row.month}>
<TableCell className="font-medium">{row.month}</TableCell>
<TableCell className="text-right tabular-nums">{row.total.toLocaleString('sv-SE')} kr</TableCell>
<TableCell className="text-right tabular-nums">{row.cash.toLocaleString('sv-SE')} kr</TableCell>
<TableCell className="text-right tabular-nums">{row.cashPct}%</TableCell>
<TableCell className="text-right tabular-nums">{row.card.toLocaleString('sv-SE')} kr</TableCell>
<TableCell className="text-right tabular-nums">{row.cardPct}%</TableCell>
<TableCell className="text-right tabular-nums">{row.swish.toLocaleString('sv-SE')} kr</TableCell>
<TableCell className="text-right tabular-nums">{row.swishPct}%</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)}
{/* Daily sales table with pagination */}
<div>
<h3 className="text-sm font-semibold mb-3">Daglig forsaljning</h3>
{dailySales.length === 0 ? (
<p className="text-sm text-muted-foreground">Ingen data importerad annu.</p>
) : (
<>
<div className="rounded-xl border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Datum</TableHead>
<TableHead className="text-right">Totalt</TableHead>
<TableHead className="text-right">Kontant</TableHead>
<TableHead className="text-right">Kort</TableHead>
<TableHead className="text-right">Swish</TableHead>
<TableHead className="text-right">Moms</TableHead>
<TableHead className="text-right">Moms %</TableHead>
<TableHead className="w-20"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{paginatedSales.map(d => {
const rowVatPct = d.total > 0
? Math.round(d.vat / d.total * 10000) / 100
: 0
return (
<TableRow key={d.date}>
<TableCell className="font-medium">{d.date}</TableCell>
<TableCell className="text-right tabular-nums">{d.total.toLocaleString('sv-SE')}</TableCell>
<TableCell className="text-right tabular-nums">{d.cash.toLocaleString('sv-SE')}</TableCell>
<TableCell className="text-right tabular-nums">{d.card.toLocaleString('sv-SE')}</TableCell>
<TableCell className="text-right tabular-nums">{d.swish.toLocaleString('sv-SE')}</TableCell>
<TableCell className="text-right tabular-nums">{d.vat.toLocaleString('sv-SE')}</TableCell>
<TableCell className="text-right tabular-nums">
<span className={rowVatPct > 0 && (rowVatPct < 20 || rowVatPct > 30) ? 'text-yellow-600 dark:text-yellow-400' : ''}>
{rowVatPct}%
</span>
</TableCell>
<TableCell>
<div className="flex items-center justify-end gap-1">
<Button variant="ghost" size="sm" onClick={() => openEdit(d)}>
<Pencil className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
<Button variant="ghost" size="sm" onClick={() => setDeleteEntry(d)}>
<Trash2 className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
</div>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between mt-4">
<p className="text-sm text-muted-foreground">
Visar {page * PAGE_SIZE + 1}-{Math.min((page + 1) * PAGE_SIZE, dailySales.length)} av {dailySales.length} rader
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPage(p => Math.max(0, p - 1))}
disabled={page === 0}
>
<ChevronLeft className="h-4 w-4 mr-1" />
Foregaende
</Button>
<span className="text-sm text-muted-foreground">
Sida {page + 1} av {totalPages}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))}
disabled={page >= totalPages - 1}
>
Nasta
<ChevronRight className="h-4 w-4 ml-1" />
</Button>
</div>
</div>
)}
</>
)}
</div>
</TabsContent>
</Tabs>
{/* Edit dialog */}
<EditEntryDialog
open={!!editEntry}
onOpenChange={open => { if (!open) setEditEntry(null) }}
title="Redigera dagskassa"
description={editEntry ? `Redigera data for ${editEntry.date}` : ''}
onSave={handleEditSave}
isSaving={isSaving}
>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="edit-date">Datum</Label>
<Input id="edit-date" type="date" value={editEntry?.date ?? ''} disabled />
</div>
<div className="space-y-2">
<Label htmlFor="edit-total">Totalt (kr)</Label>
<Input
id="edit-total"
type="number"
step="0.01"
min="0"
value={editTotal}
onChange={e => setEditTotal(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-cash">Kontant (kr)</Label>
<Input
id="edit-cash"
type="number"
step="0.01"
min="0"
value={editCash}
onChange={e => setEditCash(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-card">Kort (kr)</Label>
<Input
id="edit-card"
type="number"
step="0.01"
min="0"
value={editCard}
onChange={e => setEditCard(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-swish">Swish (kr)</Label>
<Input
id="edit-swish"
type="number"
step="0.01"
min="0"
value={editSwish}
onChange={e => setEditSwish(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-vat">Moms (kr)</Label>
<Input
id="edit-vat"
type="number"
step="0.01"
min="0"
value={editVat}
onChange={e => setEditVat(e.target.value)}
/>
</div>
</div>
{showEditWarning && (
<div className="flex items-center gap-2 rounded-lg border border-yellow-300 bg-yellow-50 p-3 text-sm text-yellow-800 dark:border-yellow-700 dark:bg-yellow-950/30 dark:text-yellow-200">
<AlertTriangle className="h-4 w-4 shrink-0" />
<span>
Kontant + Kort + Swish ({editPaymentSum.toLocaleString('sv-SE')} kr) avviker {editDiffPct}% fran Totalt ({editTotalVal.toLocaleString('sv-SE')} kr).
</span>
</div>
)}
</EditEntryDialog>
{/* Delete confirmation dialog */}
<ConfirmDeleteDialog
open={!!deleteEntry}
onOpenChange={open => { if (!open) setDeleteEntry(null) }}
title="Ta bort dagskassa"
description={deleteEntry ? `Vill du ta bort data for ${deleteEntry.date}? Atgarden kan inte angras.` : ''}
onConfirm={handleDelete}
isDeleting={isDeleting}
/>
</div>
)
}
@@ -1,14 +1,844 @@
'use client'
import { HandCoins } from 'lucide-react'
import EmptyExtensionState from '@/components/extensions/shared/EmptyExtensionState'
import { useState, useMemo, useCallback } from 'react'
import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
import { useExtensionData } from '@/lib/extensions/use-extension-data'
import KPICard from '@/components/extensions/shared/KPICard'
import DateRangeFilter from '@/components/extensions/shared/DateRangeFilter'
import MonthlyTrendTable from '@/components/extensions/shared/MonthlyTrendTable'
import DataEntryForm from '@/components/extensions/shared/DataEntryForm'
import ExtensionLoadingSkeleton from '@/components/extensions/shared/ExtensionLoadingSkeleton'
import ConfirmDeleteDialog from '@/components/extensions/shared/ConfirmDeleteDialog'
import EditEntryDialog from '@/components/extensions/shared/EditEntryDialog'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Checkbox } from '@/components/ui/checkbox'
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select'
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from '@/components/ui/table'
import { Pencil, Plus, Trash2, Download, ChevronDown, ChevronUp } from 'lucide-react'
interface Employee {
id: string
name: string
active: boolean
}
interface TipEntry {
id: string
date: string
shift: string
employeeId: string
employeeName: string
amount: number
}
type SplitMethod = 'equal' | 'hours' | 'custom'
const SHIFTS = ['Lunch', 'Kväll', 'Heldag']
export default function TipTrackingWorkspace({}: WorkspaceComponentProps) {
const now = new Date()
const [dateRange, setDateRange] = useState({
start: new Date(now.getFullYear(), now.getMonth(), 1).toISOString().slice(0, 10),
end: new Date(now.getFullYear(), now.getMonth() + 1, 0).toISOString().slice(0, 10),
})
const { data, save, remove, refresh, isLoading } = useExtensionData('restaurant', 'tip-tracking')
// ---------------------------------------------------------------------------
// Derived data
// ---------------------------------------------------------------------------
const employees = useMemo(() =>
data.filter(d => d.key.startsWith('employee:'))
.map(d => ({
id: d.key.replace('employee:', ''),
...(d.value as { name: string; active: boolean }),
}))
, [data])
const entries = useMemo(() =>
data.filter(d => d.key.startsWith('entry:'))
.map(d => ({
id: d.key,
...(d.value as Omit<TipEntry, 'id'>),
}))
.filter(e => e.date >= dateRange.start && e.date <= dateRange.end)
.sort((a, b) => b.date.localeCompare(a.date))
, [data, dateRange])
const activeEmployees = employees.filter(e => e.active)
// Settings
const settings = useMemo(() => {
const rec = data.find(d => d.key === 'settings')
return (rec?.value ?? {}) as Record<string, unknown>
}, [data])
// ---------------------------------------------------------------------------
// Register tab – form state
// ---------------------------------------------------------------------------
const [entryDate, setEntryDate] = useState(now.toISOString().slice(0, 10))
const [shift, setShift] = useState(SHIFTS[0])
const [selectedEmployeeId, setEmployeeId] = useState('')
const employeeId = selectedEmployeeId || (activeEmployees.length > 0 ? activeEmployees[0].id : '')
const [amount, setAmount] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
// New employee form
const [newEmployeeName, setNewEmployeeName] = useState('')
// Edit entry dialog
const [editEntry, setEditEntry] = useState<TipEntry | null>(null)
const [editDate, setEditDate] = useState('')
const [editShift, setEditShift] = useState('')
const [editEmployeeId, setEditEmployeeId] = useState('')
const [editAmount, setEditAmount] = useState('')
const [isSavingEdit, setIsSavingEdit] = useState(false)
// Delete confirmation dialog
const [deleteKey, setDeleteKey] = useState<string | null>(null)
const [isDeleting, setIsDeleting] = useState(false)
// Edit employee dialog
const [editEmployee, setEditEmployee] = useState<Employee | null>(null)
const [editEmployeeName, setEditEmployeeName] = useState('')
const [isSavingEmployee, setIsSavingEmployee] = useState(false)
// Per-employee expanded view in overview
const [expandedEmployeeId, setExpandedEmployeeId] = useState<string | null>(null)
// Tip pool state
const [poolAmount, setPoolAmount] = useState('')
const [poolSelectedIds, setPoolSelectedIds] = useState<Set<string>>(new Set())
const [splitMethod, setSplitMethod] = useState<SplitMethod>('equal')
const [hoursMap, setHoursMap] = useState<Record<string, string>>({})
const [customPctMap, setCustomPctMap] = useState<Record<string, string>>({})
// ---------------------------------------------------------------------------
// Analytics
// ---------------------------------------------------------------------------
const totalTips = entries.reduce((s, e) => s + e.amount, 0)
const avgPerShift = entries.length > 0 ? Math.round(totalTips / entries.length) : 0
const monthlyTrend = useMemo(() => {
const map = new Map<string, number>()
for (const e of entries) {
const month = e.date.slice(0, 7)
map.set(month, (map.get(month) ?? 0) + e.amount)
}
return Array.from(map.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([month, value]) => ({ month, value }))
}, [entries])
const employeeTotals = useMemo(() => {
const map = new Map<string, { name: string; total: number; count: number }>()
for (const e of entries) {
const existing = map.get(e.employeeId) ?? { name: e.employeeName, total: 0, count: 0 }
existing.total += e.amount
existing.count++
map.set(e.employeeId, existing)
}
return Array.from(map.entries())
.map(([id, d]) => ({ id, ...d }))
.sort((a, b) => b.total - a.total)
}, [entries])
// Per-employee entries for expanded view
const expandedEmployeeEntries = useMemo(() => {
if (!expandedEmployeeId) return []
return entries
.filter(e => e.employeeId === expandedEmployeeId)
.sort((a, b) => b.date.localeCompare(a.date))
}, [entries, expandedEmployeeId])
// ---------------------------------------------------------------------------
// Tip pool calculations
// ---------------------------------------------------------------------------
const poolTotal = parseFloat(poolAmount) || 0
const poolSelectedEmployees = activeEmployees.filter(e => poolSelectedIds.has(e.id))
const poolDistribution = useMemo((): { id: string; name: string; share: number }[] => {
if (poolSelectedEmployees.length === 0 || poolTotal <= 0) return []
if (splitMethod === 'equal') {
const share = Math.round((poolTotal / poolSelectedEmployees.length) * 100) / 100
return poolSelectedEmployees.map(e => ({ id: e.id, name: e.name, share }))
}
if (splitMethod === 'hours') {
const totalHours = poolSelectedEmployees.reduce((sum, e) => {
return sum + (parseFloat(hoursMap[e.id] ?? '0') || 0)
}, 0)
if (totalHours <= 0) return poolSelectedEmployees.map(e => ({ id: e.id, name: e.name, share: 0 }))
return poolSelectedEmployees.map(e => {
const h = parseFloat(hoursMap[e.id] ?? '0') || 0
const share = Math.round((poolTotal * (h / totalHours)) * 100) / 100
return { id: e.id, name: e.name, share }
})
}
// custom
return poolSelectedEmployees.map(e => {
const pct = parseFloat(customPctMap[e.id] ?? '0') || 0
const share = Math.round((poolTotal * (pct / 100)) * 100) / 100
return { id: e.id, name: e.name, share }
})
}, [poolTotal, poolSelectedEmployees, splitMethod, hoursMap, customPctMap])
const customPctTotal = useMemo(() => {
return poolSelectedEmployees.reduce((sum, e) => {
return sum + (parseFloat(customPctMap[e.id] ?? '0') || 0)
}, 0)
}, [poolSelectedEmployees, customPctMap])
// ---------------------------------------------------------------------------
// Handlers
// ---------------------------------------------------------------------------
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
const val = parseFloat(amount)
if (isNaN(val) || val <= 0 || !employeeId) return
setIsSubmitting(true)
const emp = employees.find(em => em.id === employeeId)
const id = crypto.randomUUID()
await save(`entry:${id}`, {
date: entryDate,
shift,
employeeId,
employeeName: emp?.name ?? '',
amount: val,
})
setAmount('')
await refresh()
setIsSubmitting(false)
}
const openEditEntry = (entry: TipEntry) => {
setEditEntry(entry)
setEditDate(entry.date)
setEditShift(entry.shift)
setEditEmployeeId(entry.employeeId)
setEditAmount(String(entry.amount))
}
const handleSaveEdit = async () => {
if (!editEntry) return
const val = parseFloat(editAmount)
if (isNaN(val) || val <= 0 || !editEmployeeId) return
setIsSavingEdit(true)
const emp = employees.find(em => em.id === editEmployeeId)
await save(editEntry.id, {
date: editDate,
shift: editShift,
employeeId: editEmployeeId,
employeeName: emp?.name ?? '',
amount: val,
})
await refresh()
setIsSavingEdit(false)
}
const handleConfirmDelete = async () => {
if (!deleteKey) return
setIsDeleting(true)
await remove(deleteKey)
setIsDeleting(false)
}
const handleAddEmployee = async () => {
if (!newEmployeeName.trim()) return
const id = crypto.randomUUID()
await save(`employee:${id}`, { name: newEmployeeName.trim(), active: true })
setNewEmployeeName('')
await refresh()
}
const handleToggleEmployee = async (emp: Employee) => {
await save(`employee:${emp.id}`, { name: emp.name, active: !emp.active })
await refresh()
}
const openEditEmployee = (emp: Employee) => {
setEditEmployee(emp)
setEditEmployeeName(emp.name)
}
const handleSaveEmployee = async () => {
if (!editEmployee || !editEmployeeName.trim()) return
setIsSavingEmployee(true)
await save(`employee:${editEmployee.id}`, {
name: editEmployeeName.trim(),
active: editEmployee.active,
})
// Also update employeeName on existing entries for this employee
const empEntries = data
.filter(d => d.key.startsWith('entry:'))
.filter(d => (d.value as { employeeId?: string }).employeeId === editEmployee.id)
for (const rec of empEntries) {
const val = rec.value as Record<string, unknown>
await save(rec.key, { ...val, employeeName: editEmployeeName.trim() })
}
await refresh()
setIsSavingEmployee(false)
}
const handleTogglePooling = async () => {
const newVal = !settings.poolingEnabled
await save('settings', { ...settings, poolingEnabled: newVal })
await refresh()
}
const togglePoolEmployee = (empId: string) => {
setPoolSelectedIds(prev => {
const next = new Set(prev)
if (next.has(empId)) {
next.delete(empId)
} else {
next.add(empId)
}
return next
})
}
const toggleExpandedEmployee = (empId: string) => {
setExpandedEmployeeId(prev => prev === empId ? null : empId)
}
// ---------------------------------------------------------------------------
// CSV export
// ---------------------------------------------------------------------------
const handleExportCsv = useCallback(() => {
if (entries.length === 0) return
const header = 'Datum,Skift,Anstalld,Belopp (kr)'
const rows = entries.map(e =>
`${e.date},${e.shift},${e.employeeName.replace(/,/g, ' ')},${e.amount}`
)
const csv = [header, ...rows].join('\n')
const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `dricks_${dateRange.start}_${dateRange.end}.csv`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}, [entries, dateRange])
// ---------------------------------------------------------------------------
// Render
// ---------------------------------------------------------------------------
if (isLoading) return <ExtensionLoadingSkeleton />
export default function TipTrackingWorkspace() {
return (
<EmptyExtensionState
title="Dricksuppföljning"
description="Registrering av dricks per skift kommer snart. Du kommer kunna följa upp dricksfördelning och bokföra det korrekt."
icon={<HandCoins className="h-12 w-12 text-muted-foreground/40 mb-4" />}
/>
<div className="space-y-6">
{/* Edit entry dialog */}
<EditEntryDialog
open={editEntry !== null}
onOpenChange={open => { if (!open) setEditEntry(null) }}
title="Redigera dricksregistrering"
description="Andra uppgifterna for denna registrering."
onSave={handleSaveEdit}
isSaving={isSavingEdit}
>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Datum</Label>
<Input type="date" value={editDate} onChange={e => setEditDate(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Skift</Label>
<Select value={editShift} onValueChange={setEditShift}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{SHIFTS.map(s => <SelectItem key={s} value={s}>{s}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Anstalld</Label>
<Select value={editEmployeeId} onValueChange={setEditEmployeeId}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{activeEmployees.map(e => (
<SelectItem key={e.id} value={e.id}>{e.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Belopp (kr)</Label>
<Input type="number" step="1" min="0" value={editAmount} onChange={e => setEditAmount(e.target.value)} />
</div>
</div>
</EditEntryDialog>
{/* Delete confirmation dialog */}
<ConfirmDeleteDialog
open={deleteKey !== null}
onOpenChange={open => { if (!open) setDeleteKey(null) }}
title="Ta bort registrering"
description="Ar du saker pa att du vill ta bort denna dricksregistrering? Atgarden kan inte angras."
onConfirm={handleConfirmDelete}
isDeleting={isDeleting}
/>
{/* Edit employee dialog */}
<EditEntryDialog
open={editEmployee !== null}
onOpenChange={open => { if (!open) setEditEmployee(null) }}
title="Redigera anstalld"
description="Andra namn pa den anstallda."
onSave={handleSaveEmployee}
isSaving={isSavingEmployee}
>
<div className="space-y-2">
<Label>Namn</Label>
<Input value={editEmployeeName} onChange={e => setEditEmployeeName(e.target.value)} />
</div>
</EditEntryDialog>
<Tabs defaultValue="register">
<TabsList>
<TabsTrigger value="register">Registrera</TabsTrigger>
<TabsTrigger value="overview">Oversikt</TabsTrigger>
<TabsTrigger value="employees">Anstallda</TabsTrigger>
<TabsTrigger value="pool">Drickspool</TabsTrigger>
</TabsList>
{/* ------------------------------------------------------------------ */}
{/* Register tab */}
{/* ------------------------------------------------------------------ */}
<TabsContent value="register" className="space-y-6 mt-4">
{activeEmployees.length === 0 ? (
<div className="rounded-xl border p-6 text-center">
<p className="text-sm text-muted-foreground">
Lagg till anstallda under fliken &quot;Anstallda&quot; for att borja registrera dricks.
</p>
</div>
) : (
<DataEntryForm
title="Registrera dricks"
onSubmit={handleSubmit}
submitLabel="Registrera"
isSubmitting={isSubmitting}
>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="tip-date">Datum</Label>
<Input id="tip-date" type="date" value={entryDate} onChange={e => setEntryDate(e.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="tip-shift">Skift</Label>
<Select value={shift} onValueChange={setShift}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{SHIFTS.map(s => <SelectItem key={s} value={s}>{s}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="tip-employee">Anstalld</Label>
<Select value={employeeId} onValueChange={setEmployeeId}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{activeEmployees.map(e => (
<SelectItem key={e.id} value={e.id}>{e.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="tip-amount">Belopp (kr)</Label>
<Input id="tip-amount" type="number" step="1" min="0" placeholder="0" value={amount} onChange={e => setAmount(e.target.value)} />
</div>
</div>
</DataEntryForm>
)}
{/* Recent entries */}
<div>
<h3 className="text-sm font-semibold mb-3">Senaste registreringar</h3>
{entries.length === 0 ? (
<p className="text-sm text-muted-foreground">Inga registreringar i vald period.</p>
) : (
<div className="rounded-xl border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Datum</TableHead>
<TableHead>Skift</TableHead>
<TableHead>Anstalld</TableHead>
<TableHead className="text-right">Belopp</TableHead>
<TableHead className="w-20"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{entries.slice(0, 20).map(e => (
<TableRow key={e.id}>
<TableCell>{e.date}</TableCell>
<TableCell>{e.shift}</TableCell>
<TableCell>{e.employeeName}</TableCell>
<TableCell className="text-right tabular-nums">{e.amount.toLocaleString('sv-SE')} kr</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" onClick={() => openEditEntry(e)}>
<Pencil className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
<Button variant="ghost" size="sm" onClick={() => setDeleteKey(e.id)}>
<Trash2 className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
</TabsContent>
{/* ------------------------------------------------------------------ */}
{/* Overview tab */}
{/* ------------------------------------------------------------------ */}
<TabsContent value="overview" className="space-y-6 mt-4">
<div className="flex items-center justify-between gap-4 flex-wrap">
<DateRangeFilter onRangeChange={(start, end) => setDateRange({ start, end })} />
<Button variant="outline" size="sm" onClick={handleExportCsv} disabled={entries.length === 0}>
<Download className="h-4 w-4 mr-1" />
Exportera CSV
</Button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<KPICard label="Total dricks" value={totalTips.toLocaleString('sv-SE')} suffix="kr" />
<KPICard label="Snitt per skift" value={avgPerShift.toLocaleString('sv-SE')} suffix="kr" />
<KPICard label="Antal registreringar" value={entries.length} />
</div>
{monthlyTrend.length > 0 && (
<div>
<h3 className="text-sm font-semibold mb-3">Manadstrend</h3>
<MonthlyTrendTable rows={monthlyTrend} valueLabel="Total dricks" />
</div>
)}
{employeeTotals.length > 0 && (
<div>
<h3 className="text-sm font-semibold mb-3">Per anstalld</h3>
<div className="rounded-xl border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Anstalld</TableHead>
<TableHead className="text-right">Total</TableHead>
<TableHead className="text-right">Antal skift</TableHead>
<TableHead className="text-right">Snitt/skift</TableHead>
<TableHead className="w-10"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{employeeTotals.map(e => {
const isExpanded = expandedEmployeeId === e.id
return (
<TableRow key={e.id} className="group">
<TableCell>
<button
type="button"
className="flex items-center gap-1 font-medium text-left hover:underline"
onClick={() => toggleExpandedEmployee(e.id)}
>
{isExpanded
? <ChevronUp className="h-3.5 w-3.5 text-muted-foreground" />
: <ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
}
{e.name}
</button>
</TableCell>
<TableCell className="text-right tabular-nums">{e.total.toLocaleString('sv-SE')} kr</TableCell>
<TableCell className="text-right tabular-nums">{e.count}</TableCell>
<TableCell className="text-right tabular-nums">{Math.round(e.total / e.count).toLocaleString('sv-SE')} kr</TableCell>
<TableCell />
</TableRow>
)
})}
</TableBody>
</Table>
</div>
{/* Expanded employee detail */}
{expandedEmployeeId && expandedEmployeeEntries.length > 0 && (
<div className="mt-3 ml-4">
<h4 className="text-sm font-medium mb-2">
Drickshistorik for {employeeTotals.find(e => e.id === expandedEmployeeId)?.name}
</h4>
<div className="rounded-xl border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Datum</TableHead>
<TableHead>Skift</TableHead>
<TableHead className="text-right">Belopp</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{expandedEmployeeEntries.map(e => (
<TableRow key={e.id}>
<TableCell>{e.date}</TableCell>
<TableCell>{e.shift}</TableCell>
<TableCell className="text-right tabular-nums">{e.amount.toLocaleString('sv-SE')} kr</TableCell>
</TableRow>
))}
<TableRow className="font-medium border-t-2">
<TableCell colSpan={2}>Totalt / Snitt</TableCell>
<TableCell className="text-right tabular-nums">
{expandedEmployeeEntries.reduce((s, e) => s + e.amount, 0).toLocaleString('sv-SE')} kr
{' '}({Math.round(expandedEmployeeEntries.reduce((s, e) => s + e.amount, 0) / expandedEmployeeEntries.length).toLocaleString('sv-SE')} kr/skift)
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</div>
)}
</div>
)}
</TabsContent>
{/* ------------------------------------------------------------------ */}
{/* Employees tab */}
{/* ------------------------------------------------------------------ */}
<TabsContent value="employees" className="space-y-6 mt-4">
<div className="flex gap-2">
<Input
placeholder="Namn pa anstalld"
value={newEmployeeName}
onChange={e => setNewEmployeeName(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleAddEmployee()}
className="max-w-xs"
/>
<Button size="sm" onClick={handleAddEmployee} disabled={!newEmployeeName.trim()}>
<Plus className="h-4 w-4 mr-1" /> Lagg till
</Button>
</div>
{employees.length === 0 ? (
<p className="text-sm text-muted-foreground">Inga anstallda tillagda annu.</p>
) : (
<div className="rounded-xl border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Namn</TableHead>
<TableHead>Status</TableHead>
<TableHead className="w-32"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{employees.map(emp => (
<TableRow key={emp.id}>
<TableCell className="font-medium">{emp.name}</TableCell>
<TableCell>
<Badge variant={emp.active ? 'default' : 'secondary'}>
{emp.active ? 'Aktiv' : 'Inaktiv'}
</Badge>
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" onClick={() => openEditEmployee(emp)}>
<Pencil className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
<Button variant="ghost" size="sm" onClick={() => handleToggleEmployee(emp)}>
{emp.active ? 'Inaktivera' : 'Aktivera'}
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</TabsContent>
{/* ------------------------------------------------------------------ */}
{/* Tip pool tab */}
{/* ------------------------------------------------------------------ */}
<TabsContent value="pool" className="space-y-6 mt-4">
<div className="rounded-xl border p-4 space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold">Drickspool</h3>
<p className="text-sm text-muted-foreground">
Fordela dricks fran en gemensam pool till utvalda anstallda.
</p>
</div>
<Button
variant={settings.poolingEnabled ? 'default' : 'outline'}
size="sm"
onClick={handleTogglePooling}
>
{settings.poolingEnabled ? 'Aktiverad' : 'Inaktiverad'}
</Button>
</div>
{Boolean(settings.poolingEnabled) && (
<div className="space-y-6">
{/* Pool amount */}
<div className="space-y-2">
<Label htmlFor="pool-amount">Total poolbelopp (kr)</Label>
<Input
id="pool-amount"
type="number"
step="1"
min="0"
placeholder="0"
value={poolAmount}
onChange={e => setPoolAmount(e.target.value)}
className="max-w-xs"
/>
</div>
{/* Select employees */}
<div className="space-y-2">
<Label>Valj anstallda</Label>
{activeEmployees.length === 0 ? (
<p className="text-sm text-muted-foreground">Inga aktiva anstallda.</p>
) : (
<div className="space-y-2">
{activeEmployees.map(emp => (
<div key={emp.id} className="flex items-center gap-2">
<Checkbox
id={`pool-emp-${emp.id}`}
checked={poolSelectedIds.has(emp.id)}
onCheckedChange={() => togglePoolEmployee(emp.id)}
/>
<label htmlFor={`pool-emp-${emp.id}`} className="text-sm cursor-pointer">
{emp.name}
</label>
</div>
))}
</div>
)}
</div>
{/* Split method */}
{poolSelectedEmployees.length > 0 && (
<div className="space-y-2">
<Label>Fordelningsmetod</Label>
<Select value={splitMethod} onValueChange={v => setSplitMethod(v as SplitMethod)}>
<SelectTrigger className="max-w-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="equal">Lika</SelectItem>
<SelectItem value="hours">Per timmar</SelectItem>
<SelectItem value="custom">Anpassad %</SelectItem>
</SelectContent>
</Select>
</div>
)}
{/* Hours input (when split by hours) */}
{splitMethod === 'hours' && poolSelectedEmployees.length > 0 && (
<div className="space-y-3">
<Label>Timmar per anstalld</Label>
{poolSelectedEmployees.map(emp => (
<div key={emp.id} className="flex items-center gap-3">
<span className="text-sm w-32 truncate">{emp.name}</span>
<Input
type="number"
step="0.5"
min="0"
placeholder="0"
className="max-w-24"
value={hoursMap[emp.id] ?? ''}
onChange={e => setHoursMap(prev => ({ ...prev, [emp.id]: e.target.value }))}
/>
<span className="text-sm text-muted-foreground">timmar</span>
</div>
))}
</div>
)}
{/* Custom percentage input */}
{splitMethod === 'custom' && poolSelectedEmployees.length > 0 && (
<div className="space-y-3">
<Label>Procent per anstalld</Label>
{poolSelectedEmployees.map(emp => (
<div key={emp.id} className="flex items-center gap-3">
<span className="text-sm w-32 truncate">{emp.name}</span>
<Input
type="number"
step="1"
min="0"
max="100"
placeholder="0"
className="max-w-24"
value={customPctMap[emp.id] ?? ''}
onChange={e => setCustomPctMap(prev => ({ ...prev, [emp.id]: e.target.value }))}
/>
<span className="text-sm text-muted-foreground">%</span>
</div>
))}
<p className={`text-xs ${Math.abs(customPctTotal - 100) < 0.01 ? 'text-muted-foreground' : 'text-red-600'}`}>
Summa: {customPctTotal}% {Math.abs(customPctTotal - 100) >= 0.01 && '(maste vara 100%)'}
</p>
</div>
)}
{/* Distribution result */}
{poolDistribution.length > 0 && poolTotal > 0 && (
<div>
<h4 className="text-sm font-semibold mb-2">Fordelningsresultat</h4>
<div className="rounded-xl border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Anstalld</TableHead>
<TableHead className="text-right">Andel (kr)</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{poolDistribution.map(d => (
<TableRow key={d.id}>
<TableCell className="font-medium">{d.name}</TableCell>
<TableCell className="text-right tabular-nums">{d.share.toLocaleString('sv-SE')} kr</TableCell>
</TableRow>
))}
<TableRow className="font-medium border-t-2">
<TableCell>Totalt</TableCell>
<TableCell className="text-right tabular-nums">
{poolDistribution.reduce((s, d) => s + d.share, 0).toLocaleString('sv-SE')} kr
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</div>
)}
</div>
)}
</div>
</TabsContent>
</Tabs>
</div>
)
}