'use client' import { useState, useMemo, useCallback } from 'react' import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry' import { useExtensionData } from '@/lib/extensions/use-extension-data' import KPICard from '@/components/extensions/shared/KPICard' import DateRangeFilter from '@/components/extensions/shared/DateRangeFilter' import MonthlyTrendTable from '@/components/extensions/shared/MonthlyTrendTable' import DataEntryForm from '@/components/extensions/shared/DataEntryForm' import ExtensionLoadingSkeleton from '@/components/extensions/shared/ExtensionLoadingSkeleton' import ConfirmDeleteDialog from '@/components/extensions/shared/ConfirmDeleteDialog' import EditEntryDialog from '@/components/extensions/shared/EditEntryDialog' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { Checkbox } from '@/components/ui/checkbox' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '@/components/ui/table' import { Pencil, Plus, Trash2, Download, ChevronDown, ChevronUp } from 'lucide-react' interface Employee { id: string name: string active: boolean } interface TipEntry { id: string date: string shift: string employeeId: string employeeName: string amount: number } type SplitMethod = 'equal' | 'hours' | 'custom' const SHIFTS = ['Lunch', 'Kväll', 'Heldag'] export default function TipTrackingWorkspace({}: WorkspaceComponentProps) { const now = new Date() const [dateRange, setDateRange] = useState({ start: new Date(now.getFullYear(), now.getMonth(), 1).toISOString().slice(0, 10), end: new Date(now.getFullYear(), now.getMonth() + 1, 0).toISOString().slice(0, 10), }) const { data, save, remove, refresh, isLoading } = useExtensionData('restaurant', 'tip-tracking') // --------------------------------------------------------------------------- // Derived data // --------------------------------------------------------------------------- const employees = useMemo(() => data.filter(d => d.key.startsWith('employee:')) .map(d => ({ id: d.key.replace('employee:', ''), ...(d.value as { name: string; active: boolean }), })) , [data]) const entries = useMemo(() => data.filter(d => d.key.startsWith('entry:')) .map(d => ({ id: d.key, ...(d.value as Omit), })) .filter(e => e.date >= dateRange.start && e.date <= dateRange.end) .sort((a, b) => b.date.localeCompare(a.date)) , [data, dateRange]) const activeEmployees = employees.filter(e => e.active) // Settings const settings = useMemo(() => { const rec = data.find(d => d.key === 'settings') return (rec?.value ?? {}) as Record }, [data]) // --------------------------------------------------------------------------- // Register tab – form state // --------------------------------------------------------------------------- const [entryDate, setEntryDate] = useState(now.toISOString().slice(0, 10)) const [shift, setShift] = useState(SHIFTS[0]) const [selectedEmployeeId, setEmployeeId] = useState('') const employeeId = selectedEmployeeId || (activeEmployees.length > 0 ? activeEmployees[0].id : '') const [amount, setAmount] = useState('') const [isSubmitting, setIsSubmitting] = useState(false) // New employee form const [newEmployeeName, setNewEmployeeName] = useState('') // Edit entry dialog const [editEntry, setEditEntry] = useState(null) const [editDate, setEditDate] = useState('') const [editShift, setEditShift] = useState('') const [editEmployeeId, setEditEmployeeId] = useState('') const [editAmount, setEditAmount] = useState('') const [isSavingEdit, setIsSavingEdit] = useState(false) // Delete confirmation dialog const [deleteKey, setDeleteKey] = useState(null) const [isDeleting, setIsDeleting] = useState(false) // Edit employee dialog const [editEmployee, setEditEmployee] = useState(null) const [editEmployeeName, setEditEmployeeName] = useState('') const [isSavingEmployee, setIsSavingEmployee] = useState(false) // Per-employee expanded view in overview const [expandedEmployeeId, setExpandedEmployeeId] = useState(null) // Tip pool state const [poolAmount, setPoolAmount] = useState('') const [poolSelectedIds, setPoolSelectedIds] = useState>(new Set()) const [splitMethod, setSplitMethod] = useState('equal') const [hoursMap, setHoursMap] = useState>({}) const [customPctMap, setCustomPctMap] = useState>({}) // --------------------------------------------------------------------------- // Analytics // --------------------------------------------------------------------------- const totalTips = entries.reduce((s, e) => s + e.amount, 0) const avgPerShift = entries.length > 0 ? Math.round(totalTips / entries.length) : 0 const monthlyTrend = useMemo(() => { const map = new Map() for (const e of entries) { const month = e.date.slice(0, 7) map.set(month, (map.get(month) ?? 0) + e.amount) } return Array.from(map.entries()) .sort(([a], [b]) => a.localeCompare(b)) .map(([month, value]) => ({ month, value })) }, [entries]) const employeeTotals = useMemo(() => { const map = new Map() for (const e of entries) { const existing = map.get(e.employeeId) ?? { name: e.employeeName, total: 0, count: 0 } existing.total += e.amount existing.count++ map.set(e.employeeId, existing) } return Array.from(map.entries()) .map(([id, d]) => ({ id, ...d })) .sort((a, b) => b.total - a.total) }, [entries]) // Per-employee entries for expanded view const expandedEmployeeEntries = useMemo(() => { if (!expandedEmployeeId) return [] return entries .filter(e => e.employeeId === expandedEmployeeId) .sort((a, b) => b.date.localeCompare(a.date)) }, [entries, expandedEmployeeId]) // --------------------------------------------------------------------------- // Tip pool calculations // --------------------------------------------------------------------------- const poolTotal = parseFloat(poolAmount) || 0 const poolSelectedEmployees = activeEmployees.filter(e => poolSelectedIds.has(e.id)) const poolDistribution = useMemo((): { id: string; name: string; share: number }[] => { if (poolSelectedEmployees.length === 0 || poolTotal <= 0) return [] if (splitMethod === 'equal') { const share = Math.round((poolTotal / poolSelectedEmployees.length) * 100) / 100 return poolSelectedEmployees.map(e => ({ id: e.id, name: e.name, share })) } if (splitMethod === 'hours') { const totalHours = poolSelectedEmployees.reduce((sum, e) => { return sum + (parseFloat(hoursMap[e.id] ?? '0') || 0) }, 0) if (totalHours <= 0) return poolSelectedEmployees.map(e => ({ id: e.id, name: e.name, share: 0 })) return poolSelectedEmployees.map(e => { const h = parseFloat(hoursMap[e.id] ?? '0') || 0 const share = Math.round((poolTotal * (h / totalHours)) * 100) / 100 return { id: e.id, name: e.name, share } }) } // custom return poolSelectedEmployees.map(e => { const pct = parseFloat(customPctMap[e.id] ?? '0') || 0 const share = Math.round((poolTotal * (pct / 100)) * 100) / 100 return { id: e.id, name: e.name, share } }) }, [poolTotal, poolSelectedEmployees, splitMethod, hoursMap, customPctMap]) const customPctTotal = useMemo(() => { return poolSelectedEmployees.reduce((sum, e) => { return sum + (parseFloat(customPctMap[e.id] ?? '0') || 0) }, 0) }, [poolSelectedEmployees, customPctMap]) // --------------------------------------------------------------------------- // Handlers // --------------------------------------------------------------------------- const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() const val = parseFloat(amount) if (isNaN(val) || val <= 0 || !employeeId) return setIsSubmitting(true) const emp = employees.find(em => em.id === employeeId) const id = crypto.randomUUID() await save(`entry:${id}`, { date: entryDate, shift, employeeId, employeeName: emp?.name ?? '', amount: val, }) setAmount('') await refresh() setIsSubmitting(false) } const openEditEntry = (entry: TipEntry) => { setEditEntry(entry) setEditDate(entry.date) setEditShift(entry.shift) setEditEmployeeId(entry.employeeId) setEditAmount(String(entry.amount)) } const handleSaveEdit = async () => { if (!editEntry) return const val = parseFloat(editAmount) if (isNaN(val) || val <= 0 || !editEmployeeId) return setIsSavingEdit(true) const emp = employees.find(em => em.id === editEmployeeId) await save(editEntry.id, { date: editDate, shift: editShift, employeeId: editEmployeeId, employeeName: emp?.name ?? '', amount: val, }) await refresh() setIsSavingEdit(false) } const handleConfirmDelete = async () => { if (!deleteKey) return setIsDeleting(true) await remove(deleteKey) setIsDeleting(false) } const handleAddEmployee = async () => { if (!newEmployeeName.trim()) return const id = crypto.randomUUID() await save(`employee:${id}`, { name: newEmployeeName.trim(), active: true }) setNewEmployeeName('') await refresh() } const handleToggleEmployee = async (emp: Employee) => { await save(`employee:${emp.id}`, { name: emp.name, active: !emp.active }) await refresh() } const openEditEmployee = (emp: Employee) => { setEditEmployee(emp) setEditEmployeeName(emp.name) } const handleSaveEmployee = async () => { if (!editEmployee || !editEmployeeName.trim()) return setIsSavingEmployee(true) await save(`employee:${editEmployee.id}`, { name: editEmployeeName.trim(), active: editEmployee.active, }) // Also update employeeName on existing entries for this employee const empEntries = data .filter(d => d.key.startsWith('entry:')) .filter(d => (d.value as { employeeId?: string }).employeeId === editEmployee.id) for (const rec of empEntries) { const val = rec.value as Record await save(rec.key, { ...val, employeeName: editEmployeeName.trim() }) } await refresh() setIsSavingEmployee(false) } const handleTogglePooling = async () => { const newVal = !settings.poolingEnabled await save('settings', { ...settings, poolingEnabled: newVal }) await refresh() } const togglePoolEmployee = (empId: string) => { setPoolSelectedIds(prev => { const next = new Set(prev) if (next.has(empId)) { next.delete(empId) } else { next.add(empId) } return next }) } const toggleExpandedEmployee = (empId: string) => { setExpandedEmployeeId(prev => prev === empId ? null : empId) } // --------------------------------------------------------------------------- // CSV export // --------------------------------------------------------------------------- const handleExportCsv = useCallback(() => { if (entries.length === 0) return const header = 'Datum,Skift,Anstalld,Belopp (kr)' const rows = entries.map(e => `${e.date},${e.shift},${e.employeeName.replace(/,/g, ' ')},${e.amount}` ) const csv = [header, ...rows].join('\n') const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8' }) const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url a.download = `dricks_${dateRange.start}_${dateRange.end}.csv` document.body.appendChild(a) a.click() document.body.removeChild(a) URL.revokeObjectURL(url) }, [entries, dateRange]) // --------------------------------------------------------------------------- // Render // --------------------------------------------------------------------------- if (isLoading) return return (
{/* Edit entry dialog */} { if (!open) setEditEntry(null) }} title="Redigera dricksregistrering" description="Andra uppgifterna for denna registrering." onSave={handleSaveEdit} isSaving={isSavingEdit} >
setEditDate(e.target.value)} />
setEditAmount(e.target.value)} />
{/* Delete confirmation dialog */} { if (!open) setDeleteKey(null) }} title="Ta bort registrering" description="Ar du saker pa att du vill ta bort denna dricksregistrering? Atgarden kan inte angras." onConfirm={handleConfirmDelete} isDeleting={isDeleting} /> {/* Edit employee dialog */} { if (!open) setEditEmployee(null) }} title="Redigera anstalld" description="Andra namn pa den anstallda." onSave={handleSaveEmployee} isSaving={isSavingEmployee} >
setEditEmployeeName(e.target.value)} />
Registrera Oversikt Anstallda Drickspool {/* ------------------------------------------------------------------ */} {/* Register tab */} {/* ------------------------------------------------------------------ */} {activeEmployees.length === 0 ? (

Lagg till anstallda under fliken "Anstallda" for att borja registrera dricks.

) : (
setEntryDate(e.target.value)} />
setAmount(e.target.value)} />
)} {/* Recent entries */}

Senaste registreringar

{entries.length === 0 ? (

Inga registreringar i vald period.

) : (
Datum Skift Anstalld Belopp {entries.slice(0, 20).map(e => ( {e.date} {e.shift} {e.employeeName} {e.amount.toLocaleString('sv-SE')} kr
))}
)}
{/* ------------------------------------------------------------------ */} {/* Overview tab */} {/* ------------------------------------------------------------------ */}
setDateRange({ start, end })} />
{monthlyTrend.length > 0 && (

Manadstrend

)} {employeeTotals.length > 0 && (

Per anstalld

Anstalld Total Antal skift Snitt/skift {employeeTotals.map(e => { const isExpanded = expandedEmployeeId === e.id return ( {e.total.toLocaleString('sv-SE')} kr {e.count} {Math.round(e.total / e.count).toLocaleString('sv-SE')} kr ) })}
{/* Expanded employee detail */} {expandedEmployeeId && expandedEmployeeEntries.length > 0 && (

Drickshistorik for {employeeTotals.find(e => e.id === expandedEmployeeId)?.name}

Datum Skift Belopp {expandedEmployeeEntries.map(e => ( {e.date} {e.shift} {e.amount.toLocaleString('sv-SE')} kr ))} Totalt / Snitt {expandedEmployeeEntries.reduce((s, e) => s + e.amount, 0).toLocaleString('sv-SE')} kr {' '}({Math.round(expandedEmployeeEntries.reduce((s, e) => s + e.amount, 0) / expandedEmployeeEntries.length).toLocaleString('sv-SE')} kr/skift)
)}
)}
{/* ------------------------------------------------------------------ */} {/* Employees tab */} {/* ------------------------------------------------------------------ */}
setNewEmployeeName(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAddEmployee()} className="max-w-xs" />
{employees.length === 0 ? (

Inga anstallda tillagda annu.

) : (
Namn Status {employees.map(emp => ( {emp.name} {emp.active ? 'Aktiv' : 'Inaktiv'}
))}
)}
{/* ------------------------------------------------------------------ */} {/* Tip pool tab */} {/* ------------------------------------------------------------------ */}

Drickspool

Fordela dricks fran en gemensam pool till utvalda anstallda.

{Boolean(settings.poolingEnabled) && (
{/* Pool amount */}
setPoolAmount(e.target.value)} className="max-w-xs" />
{/* Select employees */}
{activeEmployees.length === 0 ? (

Inga aktiva anstallda.

) : (
{activeEmployees.map(emp => (
togglePoolEmployee(emp.id)} />
))}
)}
{/* Split method */} {poolSelectedEmployees.length > 0 && (
)} {/* Hours input (when split by hours) */} {splitMethod === 'hours' && poolSelectedEmployees.length > 0 && (
{poolSelectedEmployees.map(emp => (
{emp.name} setHoursMap(prev => ({ ...prev, [emp.id]: e.target.value }))} /> timmar
))}
)} {/* Custom percentage input */} {splitMethod === 'custom' && poolSelectedEmployees.length > 0 && (
{poolSelectedEmployees.map(emp => (
{emp.name} setCustomPctMap(prev => ({ ...prev, [emp.id]: e.target.value }))} /> %
))}

Summa: {customPctTotal}% {Math.abs(customPctTotal - 100) >= 0.01 && '(maste vara 100%)'}

)} {/* Distribution result */} {poolDistribution.length > 0 && poolTotal > 0 && (

Fordelningsresultat

Anstalld Andel (kr) {poolDistribution.map(d => ( {d.name} {d.share.toLocaleString('sv-SE')} kr ))} Totalt {poolDistribution.reduce((s, d) => s + d.share, 0).toLocaleString('sv-SE')} kr
)}
)}
) }