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:
@@ -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 "Anstallda" 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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user