'use client' import { useState, useMemo } from 'react' import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry' import { useExtensionData } from '@/lib/extensions/use-extension-data' import KPICard from '@/components/extensions/shared/KPICard' import DateRangeFilter from '@/components/extensions/shared/DateRangeFilter' import DataEntryForm from '@/components/extensions/shared/DataEntryForm' import SetupPrompt from '@/components/extensions/shared/SetupPrompt' import ExtensionLoadingSkeleton from '@/components/extensions/shared/ExtensionLoadingSkeleton' import ConfirmDeleteDialog from '@/components/extensions/shared/ConfirmDeleteDialog' import EditEntryDialog from '@/components/extensions/shared/EditEntryDialog' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Button } from '@/components/ui/button' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '@/components/ui/table' import { Pencil, Trash2, ArrowUp, ArrowDown, Minus, Settings } from 'lucide-react' import { validateMaxNumber, validatePositiveNumber } from '@/lib/extensions/validation' import { cn } from '@/lib/utils' function computePreviousPeriod(start: string, end: string): { start: string; end: string } { const startDate = new Date(start + 'T00:00:00') const endDate = new Date(end + 'T00:00:00') const durationMs = endDate.getTime() - startDate.getTime() const prevEnd = new Date(startDate.getTime() - 1) const prevStart = new Date(prevEnd.getTime() - durationMs) return { start: prevStart.toISOString().slice(0, 10), end: prevEnd.toISOString().slice(0, 10), } } function DeltaArrow({ current, previous, higherIsBetter = true }: { current: number previous: number higherIsBetter?: boolean }) { const delta = Math.round((current - previous) * 100) / 100 if (delta === 0 || (previous === 0 && current === 0)) { return ( 0 ) } const improving = higherIsBetter ? delta > 0 : delta < 0 return ( {delta > 0 ? : } {delta > 0 ? '+' : ''}{delta.toLocaleString('sv-SE')} ) } interface DailyEntry { date: string roomsSold: number roomRevenue: number } function computeKPIs(entries: DailyEntry[], totalRooms: number) { const totalRevenue = entries.reduce((s, e) => s + e.roomRevenue, 0) const totalRoomsSold = entries.reduce((s, e) => s + e.roomsSold, 0) const daysInRange = entries.length const totalAvailableRooms = totalRooms * daysInRange const revpar = totalAvailableRooms > 0 ? Math.round((totalRevenue / totalAvailableRooms) * 100) / 100 : 0 const adr = totalRoomsSold > 0 ? Math.round((totalRevenue / totalRoomsSold) * 100) / 100 : 0 const occupancyPct = totalAvailableRooms > 0 ? Math.round((totalRoomsSold / totalAvailableRooms) * 10000) / 100 : 0 return { totalRevenue, totalRoomsSold, revpar, adr, occupancyPct } } export default function RevparWorkspace({}: WorkspaceComponentProps) { const now = new Date() const [dateRange, setDateRange] = useState({ start: new Date(now.getFullYear(), now.getMonth(), 1).toISOString().slice(0, 10), end: new Date(now.getFullYear(), now.getMonth() + 1, 0).toISOString().slice(0, 10), }) const prevPeriod = useMemo( () => computePreviousPeriod(dateRange.start, dateRange.end), [dateRange.start, dateRange.end] ) const { data, save, remove, refresh, isLoading } = useExtensionData('hotel', 'revpar') const settings = data.find(d => d.key === 'settings')?.value as { totalRooms?: number } | undefined const totalRooms = settings?.totalRooms ?? 0 // All daily entries (unfiltered by date, for period comparison) const allEntries = useMemo(() => data.filter(d => d.key.startsWith('daily:')) .map(d => ({ date: d.key.replace('daily:', ''), ...(d.value as { roomsSold: number; roomRevenue: number }), })) , [data]) // Current period entries const entries = useMemo(() => allEntries .filter(e => e.date >= dateRange.start && e.date <= dateRange.end) .sort((a, b) => b.date.localeCompare(a.date)) , [allEntries, dateRange]) // Previous period entries const prevEntries = useMemo(() => allEntries .filter(e => e.date >= prevPeriod.start && e.date <= prevPeriod.end) , [allEntries, prevPeriod]) // Form state const [entryDate, setEntryDate] = useState(now.toISOString().slice(0, 10)) const [roomsSold, setRoomsSold] = useState('') const [roomRevenue, setRoomRevenue] = useState('') const [isSubmitting, setIsSubmitting] = useState(false) // Edit dialog state const [editDialogOpen, setEditDialogOpen] = useState(false) const [editDate, setEditDate] = useState('') const [editRoomsSold, setEditRoomsSold] = useState('') const [editRoomRevenue, setEditRoomRevenue] = useState('') const [isSavingEdit, setIsSavingEdit] = useState(false) // Delete dialog state const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) const [deleteDate, setDeleteDate] = useState('') const [isDeleting, setIsDeleting] = useState(false) // Settings dialog state const [settingsDialogOpen, setSettingsDialogOpen] = useState(false) const [settingsRoomCount, setSettingsRoomCount] = useState('') const [isSavingSettings, setIsSavingSettings] = useState(false) // Current period KPIs const current = computeKPIs(entries, totalRooms) // Previous period KPIs const prev = computeKPIs(prevEntries, totalRooms) // --- Input validation --- const roomsSoldNum = parseInt(roomsSold) const roomRevenueNum = parseFloat(roomRevenue) const roomsSoldError = roomsSold !== '' ? validateMaxNumber(roomsSold, totalRooms) ? `Kan inte overskrida ${totalRooms} rum` : null : null const roomRevenueError = roomRevenue !== '' ? validatePositiveNumber(roomRevenue) : null const formValid = !isNaN(roomsSoldNum) && roomsSoldNum >= 0 && roomsSoldNum <= totalRooms && !isNaN(roomRevenueNum) && roomRevenueNum > 0 // Edit dialog validation const editRoomsSoldNum = parseInt(editRoomsSold) const editRoomRevenueNum = parseFloat(editRoomRevenue) const editRoomsSoldError = editRoomsSold !== '' ? validateMaxNumber(editRoomsSold, totalRooms) ? `Kan inte overskrida ${totalRooms} rum` : null : null const editRoomRevenueError = editRoomRevenue !== '' ? validatePositiveNumber(editRoomRevenue) : null const editFormValid = !isNaN(editRoomsSoldNum) && editRoomsSoldNum >= 0 && editRoomsSoldNum <= totalRooms && !isNaN(editRoomRevenueNum) && editRoomRevenueNum > 0 // Settings validation const settingsRoomCountNum = parseInt(settingsRoomCount) const settingsValid = !isNaN(settingsRoomCountNum) && settingsRoomCountNum > 0 // Monthly trend with all three metrics const monthlyTrend = useMemo(() => { const map = new Map() for (const e of entries) { const month = e.date.slice(0, 7) const existing = map.get(month) ?? { revenue: 0, rooms: 0, days: 0 } existing.revenue += e.roomRevenue existing.rooms += e.roomsSold existing.days++ map.set(month, existing) } return Array.from(map.entries()) .sort(([a], [b]) => a.localeCompare(b)) .map(([month, d]) => { const available = totalRooms * d.days return { month, revpar: available > 0 ? Math.round((d.revenue / available) * 100) / 100 : 0, adr: d.rooms > 0 ? Math.round((d.revenue / d.rooms) * 100) / 100 : 0, occupancy: available > 0 ? Math.round((d.rooms / available) * 10000) / 100 : 0, } }) }, [entries, totalRooms]) // --- Handlers --- const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() if (!formValid) return setIsSubmitting(true) await save(`daily:${entryDate}`, { roomsSold: roomsSoldNum, roomRevenue: roomRevenueNum }) setRoomsSold('') setRoomRevenue('') await refresh() setIsSubmitting(false) } const openEditDialog = (entry: DailyEntry) => { setEditDate(entry.date) setEditRoomsSold(String(entry.roomsSold)) setEditRoomRevenue(String(entry.roomRevenue)) setEditDialogOpen(true) } const handleSaveEdit = async () => { if (!editFormValid) return setIsSavingEdit(true) await save(`daily:${editDate}`, { roomsSold: editRoomsSoldNum, roomRevenue: editRoomRevenueNum, }) await refresh() setIsSavingEdit(false) } const openDeleteDialog = (date: string) => { setDeleteDate(date) setDeleteDialogOpen(true) } const handleConfirmDelete = async () => { setIsDeleting(true) await remove(`daily:${deleteDate}`) setIsDeleting(false) } const openSettingsDialog = () => { setSettingsRoomCount(String(totalRooms)) setSettingsDialogOpen(true) } const handleSaveSettings = async () => { if (!settingsValid) return setIsSavingSettings(true) await save('settings', { totalRooms: settingsRoomCountNum }) await refresh() setIsSavingSettings(false) } const handleSetup = async (values: Record) => { await save('settings', { totalRooms: parseInt(values.totalRooms) || 0 }) } if (isLoading) return if (!totalRooms) { return ( ) } return (
setDateRange({ start, end })} />
{/* KPI Cards with delta indicators */}
{/* Period comparison */}

Periodjamforelse

RevPAR

{current.revpar.toLocaleString('sv-SE')} kr

Foregaende: {prev.revpar.toLocaleString('sv-SE')} kr

ADR

{current.adr.toLocaleString('sv-SE')} kr

Foregaende: {prev.adr.toLocaleString('sv-SE')} kr

Belaggning

{current.occupancyPct}%

Foregaende: {prev.occupancyPct}%

Foregaende period: {prevPeriod.start} — {prevPeriod.end}

{/* Data entry form with validation */}
setEntryDate(e.target.value)} />
setRoomsSold(e.target.value)} className={cn(roomsSoldError && 'border-red-500')} /> {roomsSoldError && (

{roomsSoldError}

)}
setRoomRevenue(e.target.value)} className={cn(roomRevenueError && 'border-red-500')} /> {roomRevenueError && (

{roomRevenueError}

)}
{/* Monthly trend with RevPAR, ADR, Occupancy */} {monthlyTrend.length > 0 && (

Manadstrend

Period RevPAR ADR Belaggning {monthlyTrend.map(row => ( {row.month} {row.revpar.toLocaleString('sv-SE')} kr {row.adr.toLocaleString('sv-SE')} kr {row.occupancy}% ))}
)} {/* Daily data table with edit and delete */}

Daglig data

{entries.length === 0 ? (

Ingen data registrerad i vald period.

) : (
Datum Rum salda Intakt ADR {entries.map(e => ( {e.date} {e.roomsSold} / {totalRooms} {e.roomRevenue.toLocaleString('sv-SE')} kr {e.roomsSold > 0 ? Math.round(e.roomRevenue / e.roomsSold).toLocaleString('sv-SE') : 0} kr
))}
)}
{/* Edit entry dialog */}
setEditRoomsSold(e.target.value)} className={cn(editRoomsSoldError && 'border-red-500')} /> {editRoomsSoldError && (

{editRoomsSoldError}

)}
setEditRoomRevenue(e.target.value)} className={cn(editRoomRevenueError && 'border-red-500')} /> {editRoomRevenueError && (

{editRoomRevenueError}

)}
{/* Confirm delete dialog */} {/* Settings dialog */}
setSettingsRoomCount(e.target.value)} /> {settingsRoomCount !== '' && !settingsValid && (

Ange ett giltigt antal rum (minst 1)

)}
) }