'use client' 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' const DEFAULT_CATEGORIES = ['Ol', 'Vin', 'Sprit'] const DEFAULT_PRICING: Record = { Ol: 80, Vin: 120, Sprit: 200, } interface LiterEntry { id: string date: string category: string liters: number } interface Pricing { [category: string]: number } function DeltaIndicator({ current, previous }: { current: number; previous: number }) { if (previous === 0) return const delta = Math.round(((current - previous) / previous) * 10000) / 100 if (delta > 0) { return ( +{delta}% ) } if (delta < 0) { return ( {delta}% ) } return ( 0% ) } export default function EarningsPerLiterWorkspace({}: WorkspaceComponentProps) { const now = new Date() const [dateRange, setDateRange] = useState({ start: new Date(now.getFullYear(), now.getMonth(), 1).toISOString().slice(0, 10), end: new Date(now.getFullYear(), now.getMonth() + 1, 0).toISOString().slice(0, 10), }) const { data, save, remove, refresh, isLoading: dataLoading } = useExtensionData('restaurant', 'earnings-per-liter') // Settings: categories const settings = data.find(d => d.key === 'settings')?.value as { categories?: string[] } | undefined const categories = settings?.categories ?? DEFAULT_CATEGORIES // Pricing per category (kr per liter) const pricingData = data.find(d => d.key === 'pricing')?.value as Pricing | undefined const pricing: Pricing = useMemo(() => { const base: Pricing = {} for (const cat of categories) { base[cat] = pricingData?.[cat] ?? DEFAULT_PRICING[cat] ?? 100 } return base }, [categories, pricingData]) // Entries filtered by date range const entries: LiterEntry[] = useMemo(() => data.filter(d => d.key.startsWith('entry:')) .map(d => ({ id: d.key, ...(d.value as { date: string; category: string; liters: number }), })) .filter(e => e.date >= dateRange.start && e.date <= dateRange.end) .sort((a, b) => b.date.localeCompare(a.date)) , [data, dateRange]) // Previous period entries for comparison const prevPeriodEntries: LiterEntry[] = useMemo(() => { const startDate = new Date(dateRange.start) const endDate = new Date(dateRange.end) const durationMs = endDate.getTime() - startDate.getTime() const prevStart = new Date(startDate.getTime() - durationMs - 86400000) const prevEnd = new Date(startDate.getTime() - 86400000) const prevStartStr = prevStart.toISOString().slice(0, 10) const prevEndStr = prevEnd.toISOString().slice(0, 10) return data.filter(d => d.key.startsWith('entry:')) .map(d => ({ id: d.key, ...(d.value as { date: string; category: string; liters: number }), })) .filter(e => e.date >= prevStartStr && e.date <= prevEndStr) }, [data, dateRange]) // Form state - single entry const [entryDate, setEntryDate] = useState(now.toISOString().slice(0, 10)) const [category, setCategory] = useState(categories[0]) const [liters, setLiters] = useState('') const [isSubmitting, setIsSubmitting] = useState(false) // Batch entry mode const [batchMode, setBatchMode] = useState(false) const [batchDate, setBatchDate] = useState(now.toISOString().slice(0, 10)) const [batchLiters, setBatchLiters] = useState>({}) const [isBatchSubmitting, setIsBatchSubmitting] = useState(false) // Edit state const [editingEntry, setEditingEntry] = useState(null) const [editDate, setEditDate] = useState('') const [editCategory, setEditCategory] = useState('') const [editLiters, setEditLiters] = useState('') const [isSavingEdit, setIsSavingEdit] = useState(false) // Delete confirmation state const [deletingEntry, setDeletingEntry] = useState(null) const [isDeleting, setIsDeleting] = useState(false) // Category management state const [newCategoryName, setNewCategoryName] = useState('') const [editingPricing, setEditingPricing] = useState(false) const [pricingInputs, setPricingInputs] = useState>({}) // Alcohol revenue from bookkeeping (accounts 3000-3999) const { totalCredit: alcoholRevenue, isLoading: revenueLoading } = useAccountTotals({ from: '3000', to: '3999', dateFrom: dateRange.start, dateTo: dateRange.end, }) // Calculations const totalLiters = entries.reduce((s, e) => s + e.liters, 0) const earningsPerLiter = totalLiters > 0 ? Math.round((alcoholRevenue / totalLiters) * 100) / 100 : 0 // Estimated revenue based on pricing const estimatedRevenue = useMemo(() => { let total = 0 for (const e of entries) { const price = pricing[e.category] ?? 100 total += e.liters * price } return Math.round(total * 100) / 100 }, [entries, pricing]) // Previous period calculations const prevTotalLiters = prevPeriodEntries.reduce((s, e) => s + e.liters, 0) const prevEstimatedRevenue = useMemo(() => { let total = 0 for (const e of prevPeriodEntries) { const price = pricing[e.category] ?? 100 total += e.liters * price } return Math.round(total * 100) / 100 }, [prevPeriodEntries, pricing]) const prevEarningsPerLiter = prevTotalLiters > 0 ? Math.round((prevEstimatedRevenue / prevTotalLiters) * 100) / 100 : 0 // Category breakdown with per-category revenue = liters x avg price const categoryBreakdown = useMemo(() => { const map = new Map() for (const e of entries) { map.set(e.category, (map.get(e.category) ?? 0) + e.liters) } return categories.map(cat => { const catLiters = map.get(cat) ?? 0 const avgPrice = pricing[cat] ?? 100 const estimatedRev = Math.round(catLiters * avgPrice * 100) / 100 const eplCategory = catLiters > 0 ? Math.round((estimatedRev / catLiters) * 100) / 100 : 0 return { category: cat, liters: catLiters, avgPrice, estimatedRevenue: estimatedRev, earningsPerLiter: eplCategory, } }) }, [entries, categories, pricing]) // Monthly trend const monthlyTrend = useMemo(() => { const map = new Map() for (const e of entries) { const month = e.date.slice(0, 7) map.set(month, (map.get(month) ?? 0) + e.liters) } return Array.from(map.entries()) .sort(([a], [b]) => a.localeCompare(b)) .map(([month, liters]) => ({ month, value: liters })) }, [entries]) // --- Handlers --- const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() const val = parseFloat(liters) if (isNaN(val) || val <= 0) return setIsSubmitting(true) const id = crypto.randomUUID() await save(`entry:${id}`, { date: entryDate, category, liters: val }) setLiters('') await refresh() setIsSubmitting(false) } const handleBatchSubmit = async (e: React.FormEvent) => { e.preventDefault() setIsBatchSubmitting(true) for (const cat of categories) { const val = parseFloat(batchLiters[cat] ?? '') if (!isNaN(val) && val > 0) { const id = crypto.randomUUID() await save(`entry:${id}`, { date: batchDate, category: cat, liters: val }) } } setBatchLiters({}) await refresh() setIsBatchSubmitting(false) } const openEdit = (entry: LiterEntry) => { setEditingEntry(entry) setEditDate(entry.date) setEditCategory(entry.category) setEditLiters(String(entry.liters)) } const handleSaveEdit = async () => { if (!editingEntry) return const val = parseFloat(editLiters) if (isNaN(val) || val <= 0) return setIsSavingEdit(true) await save(editingEntry.id, { date: editDate, category: editCategory, liters: val }) await refresh() setIsSavingEdit(false) setEditingEntry(null) } const handleConfirmDelete = async () => { if (!deletingEntry) return setIsDeleting(true) await remove(deletingEntry.id) setIsDeleting(false) setDeletingEntry(null) } // Category management const handleAddCategory = async () => { const name = newCategoryName.trim() if (!name || categories.includes(name)) return const updated = [...categories, name] await save('settings', { ...settings, categories: updated }) setNewCategoryName('') await refresh() } const handleRemoveCategory = async (cat: string) => { const updated = categories.filter(c => c !== cat) if (updated.length === 0) return await save('settings', { ...settings, categories: updated }) // Update pricing to remove the category if (pricingData) { const updatedPricing = { ...pricingData } delete updatedPricing[cat] await save('pricing', updatedPricing) } await refresh() } const handleRenameCategory = async (oldName: string, newName: string) => { if (!newName.trim() || oldName === newName.trim()) return const trimmed = newName.trim() const updated = categories.map(c => c === oldName ? trimmed : c) await save('settings', { ...settings, categories: updated }) // Update pricing key if (pricingData) { const updatedPricing = { ...pricingData } if (updatedPricing[oldName] !== undefined) { updatedPricing[trimmed] = updatedPricing[oldName] delete updatedPricing[oldName] } await save('pricing', updatedPricing) } await refresh() } const handleSavePricing = async () => { const updated: Pricing = {} for (const cat of categories) { const val = parseFloat(pricingInputs[cat] ?? '') updated[cat] = !isNaN(val) && val > 0 ? Math.round(val * 100) / 100 : (pricing[cat] ?? 100) } await save('pricing', updated) setEditingPricing(false) await refresh() } const startEditPricing = () => { const inputs: Record = {} for (const cat of categories) { inputs[cat] = String(pricing[cat] ?? 100) } setPricingInputs(inputs) setEditingPricing(true) } if (dataLoading || revenueLoading) return return (
Registrera Oversikt Installningar {/* ---- REGISTER TAB ---- */} setDateRange({ start, end })} /> {/* KPI row */}
0 ? { value: Math.round(((earningsPerLiter - prevEarningsPerLiter) / prevEarningsPerLiter) * 10000) / 100, label: 'mot foreg. period', } : undefined} /> 0 ? { value: Math.round(((totalLiters - prevTotalLiters) / prevTotalLiters) * 10000) / 100, label: 'mot foreg. period', } : undefined} />
{/* Revenue comparison */} {estimatedRevenue > 0 && alcoholRevenue > 0 && (

Uppskattad vs bokford intakt

Differens baserat pa snittkr/liter per kategori

{(Math.round((estimatedRevenue - alcoholRevenue) * 100) / 100).toLocaleString('sv-SE')} kr

)} {/* Single entry form / batch mode toggle */}
{batchMode ? (
setBatchDate(e.target.value)} className="max-w-xs" />
{categories.map(cat => (
setBatchLiters(prev => ({ ...prev, [cat]: e.target.value }))} />
))}
) : (
setEntryDate(e.target.value)} />
setLiters(e.target.value)} />
)} {/* Entry history */}

Senaste registreringar

{entries.length === 0 ? (

Inga registreringar i vald period.

) : (
Datum Kategori Liter Uppsk. intakt {entries.slice(0, 20).map(e => { const entryRevenue = Math.round(e.liters * (pricing[e.category] ?? 100) * 100) / 100 return ( {e.date} {e.category} {e.liters.toLocaleString('sv-SE')} l {entryRevenue.toLocaleString('sv-SE')} kr
) })}
)}
{/* ---- OVERVIEW TAB ---- */} setDateRange({ start, end })} /> {/* Period comparison KPIs */}

Intakt/liter (nuvarande)

{earningsPerLiter.toLocaleString('sv-SE')} kr/l

{prevEarningsPerLiter > 0 && (
Foreg: {prevEarningsPerLiter.toLocaleString('sv-SE')} kr/l
)}

Liter (nuvarande)

{(Math.round(totalLiters * 100) / 100).toLocaleString('sv-SE')} l

{prevTotalLiters > 0 && (
Foreg: {(Math.round(prevTotalLiters * 100) / 100).toLocaleString('sv-SE')} l
)}

Uppsk. intakt (nuvarande)

{estimatedRevenue.toLocaleString('sv-SE')} kr

{prevEstimatedRevenue > 0 && (
Foreg: {prevEstimatedRevenue.toLocaleString('sv-SE')} kr
)}
{/* Category breakdown */} {categoryBreakdown.some(c => c.liters > 0) && (

Per kategori

Kategori Liter Snittpris/l Uppsk. intakt Kr/liter {categoryBreakdown.map(c => ( {c.category} {c.liters.toLocaleString('sv-SE')} l {c.avgPrice.toLocaleString('sv-SE')} kr {c.estimatedRevenue.toLocaleString('sv-SE')} kr {c.earningsPerLiter.toLocaleString('sv-SE')} kr/l ))} Totalt {(Math.round(totalLiters * 100) / 100).toLocaleString('sv-SE')} l {estimatedRevenue.toLocaleString('sv-SE')} kr {earningsPerLiter.toLocaleString('sv-SE')} kr/l
)} {/* Monthly trend */} {monthlyTrend.length > 0 && (

Manadstrend

)}
{/* ---- SETTINGS TAB ---- */} {/* Category management */}

Kategorier

Lagg till, ta bort eller byt namn pa dryckkategorier.

setNewCategoryName(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAddCategory()} className="max-w-xs" />
{categories.length > 0 && (
Namn {categories.map(cat => ( 1} onRename={(newName) => handleRenameCategory(cat, newName)} onRemove={() => handleRemoveCategory(cat)} /> ))}
)}
{/* Pricing settings */}

Snittpris per liter

Anvands for att berakna uppskattad intakt per kategori.

{!editingPricing && ( )}
{editingPricing ? (
{categories.map(cat => (
setPricingInputs(prev => ({ ...prev, [cat]: e.target.value }))} />
))}
) : (
Kategori Pris (kr/l) {categories.map(cat => ( {cat} {(pricing[cat] ?? 100).toLocaleString('sv-SE')} kr ))}
)}
{/* Edit entry dialog */} { if (!open) setEditingEntry(null) }} title="Redigera registrering" description="Andra datum, kategori eller antal liter." onSave={handleSaveEdit} isSaving={isSavingEdit} >
setEditDate(e.target.value)} />
setEditLiters(e.target.value)} />
{/* Delete confirmation dialog */} { if (!open) setDeletingEntry(null) }} title="Ta bort registrering" description={deletingEntry ? `Vill du ta bort ${deletingEntry.liters} l ${deletingEntry.category} fran ${deletingEntry.date}?` : ''} onConfirm={handleConfirmDelete} isDeleting={isDeleting} />
) } // Inline sub-component for category row with rename support function CategoryRow({ name, canRemove, onRename, onRemove, }: { name: string canRemove: boolean onRename: (newName: string) => Promise onRemove: () => Promise }) { const [isRenaming, setIsRenaming] = useState(false) const [newName, setNewName] = useState(name) const handleRename = async () => { await onRename(newName) setIsRenaming(false) } return ( {isRenaming ? (
setNewName(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleRename()} className="h-8 max-w-[200px]" autoFocus />
) : ( {name} )}
{!isRenaming && ( )} {canRemove && !isRenaming && ( )}
) }