'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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '@/components/ui/table' import { cn } from '@/lib/utils' import { Pencil, Trash2, ArrowUp, ArrowDown, Minus, Settings } from 'lucide-react' const OOO_REASONS = ['Underhall', 'Renovering', 'Blockerat', 'Ovrigt'] as const type OooReason = typeof OOO_REASONS[number] function getOccupancyColor(pct: number): string { if (pct >= 80) return 'bg-green-500' if (pct >= 50) return 'bg-yellow-500' if (pct > 0) return 'bg-red-500' return 'bg-muted' } 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 ( 0 pp ) } // For occupancy: higher is better, so positive delta = green (improving) const improving = delta > 0 return ( {delta > 0 ? : } {delta > 0 ? '+' : ''}{delta} pp ) } interface DailyEntry { date: string roomsOccupied: number roomsOutOfOrder: number reason?: OooReason } export default function OccupancyWorkspace({}: 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', 'occupancy') const settings = data.find(d => d.key === 'settings')?.value as { totalRooms?: number } | undefined const totalRooms = settings?.totalRooms ?? 0 const allDailyEntries = useMemo(() => data.filter(d => d.key.startsWith('daily:')) .map(d => ({ date: d.key.replace('daily:', ''), ...(d.value as { roomsOccupied: number; roomsOutOfOrder: number; reason?: OooReason }), })) , [data]) const entries = useMemo(() => allDailyEntries .filter(e => e.date >= dateRange.start && e.date <= dateRange.end) .sort((a, b) => b.date.localeCompare(a.date)) , [allDailyEntries, dateRange]) const prevEntries = useMemo(() => allDailyEntries .filter(e => e.date >= prevPeriod.start && e.date <= prevPeriod.end) , [allDailyEntries, prevPeriod]) // Form state const [entryDate, setEntryDate] = useState(now.toISOString().slice(0, 10)) const [roomsOccupied, setRoomsOccupied] = useState('') const [roomsOutOfOrder, setRoomsOutOfOrder] = useState('') const [oooReason, setOooReason] = useState('Underhall') const [isSubmitting, setIsSubmitting] = useState(false) // Edit dialog state const [editDialogOpen, setEditDialogOpen] = useState(false) const [editDate, setEditDate] = useState('') const [editOccupied, setEditOccupied] = useState('') const [editOoo, setEditOoo] = useState('') const [editReason, setEditReason] = useState('Underhall') 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 [newTotalRooms, setNewTotalRooms] = useState('') const [isSavingSettings, setIsSavingSettings] = useState(false) // --- Validation --- const formOccupied = parseInt(roomsOccupied) || 0 const formOoo = parseInt(roomsOutOfOrder) || 0 const formExceedsTotal = totalRooms > 0 && (formOccupied + formOoo) > totalRooms const formIsValid = roomsOccupied !== '' && !isNaN(parseInt(roomsOccupied)) && !formExceedsTotal const editOccupiedNum = parseInt(editOccupied) || 0 const editOooNum = parseInt(editOoo) || 0 const editExceedsTotal = totalRooms > 0 && (editOccupiedNum + editOooNum) > totalRooms const editIsValid = editOccupied !== '' && !isNaN(parseInt(editOccupied)) && !editExceedsTotal // --- Current period KPIs --- const totalOccupied = entries.reduce((s, e) => s + e.roomsOccupied, 0) const totalOutOfOrder = entries.reduce((s, e) => s + e.roomsOutOfOrder, 0) const daysInRange = entries.length const totalAvailable = totalRooms * daysInRange const occupancyPct = totalAvailable > 0 ? Math.round((totalOccupied / totalAvailable) * 10000) / 100 : 0 const avgOccupied = daysInRange > 0 ? Math.round(totalOccupied / daysInRange) : 0 const avgOutOfOrder = daysInRange > 0 ? Math.round((totalOutOfOrder / daysInRange) * 10) / 10 : 0 const avgAvailable = daysInRange > 0 ? Math.round(((totalRooms * daysInRange - totalOccupied - totalOutOfOrder) / daysInRange) * 10) / 10 : totalRooms // --- Previous period KPIs --- const prevTotalOccupied = prevEntries.reduce((s, e) => s + e.roomsOccupied, 0) const prevDaysInRange = prevEntries.length const prevTotalAvailable = totalRooms * prevDaysInRange const prevOccupancyPct = prevTotalAvailable > 0 ? Math.round((prevTotalOccupied / prevTotalAvailable) * 10000) / 100 : 0 // Calendar heatmap for current month view const calendarData = useMemo(() => { const entryMap = new Map(entries.map(e => [e.date, e])) const start = new Date(dateRange.start) const end = new Date(dateRange.end) const days: { date: string; occupancyPct: number; dayOfWeek: number }[] = [] const current = new Date(start) while (current <= end) { const dateStr = current.toISOString().slice(0, 10) const entry = entryMap.get(dateStr) const pct = entry && totalRooms > 0 ? Math.round((entry.roomsOccupied / totalRooms) * 100) : 0 days.push({ date: dateStr, occupancyPct: pct, dayOfWeek: current.getDay() }) current.setDate(current.getDate() + 1) } return days }, [entries, dateRange, totalRooms]) // --- Handlers --- const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() const occupied = parseInt(roomsOccupied) const outOfOrder = parseInt(roomsOutOfOrder) || 0 if (isNaN(occupied)) return if (totalRooms > 0 && (occupied + outOfOrder) > totalRooms) return setIsSubmitting(true) await save(`daily:${entryDate}`, { roomsOccupied: occupied, roomsOutOfOrder: outOfOrder, reason: outOfOrder > 0 ? oooReason : undefined, }) setRoomsOccupied('') setRoomsOutOfOrder('') setOooReason('Underhall') await refresh() setIsSubmitting(false) } const openEditDialog = (entry: DailyEntry) => { setEditDate(entry.date) setEditOccupied(String(entry.roomsOccupied)) setEditOoo(String(entry.roomsOutOfOrder)) setEditReason(entry.reason ?? 'Underhall') setEditDialogOpen(true) } const handleSaveEdit = async () => { const occupied = parseInt(editOccupied) const outOfOrder = parseInt(editOoo) || 0 if (isNaN(occupied)) return if (totalRooms > 0 && (occupied + outOfOrder) > totalRooms) return setIsSavingEdit(true) await save(`daily:${editDate}`, { roomsOccupied: occupied, roomsOutOfOrder: outOfOrder, reason: outOfOrder > 0 ? editReason : undefined, }) await refresh() setIsSavingEdit(false) } const openDeleteDialog = (date: string) => { setDeleteDate(date) setDeleteDialogOpen(true) } const handleDelete = async () => { setIsDeleting(true) await remove(`daily:${deleteDate}`) setIsDeleting(false) } const handleSetup = async (values: Record) => { await save('settings', { totalRooms: parseInt(values.totalRooms) || 0 }) } const openSettingsDialog = () => { setNewTotalRooms(String(totalRooms)) setSettingsDialogOpen(true) } const handleSaveSettings = async () => { const rooms = parseInt(newTotalRooms) if (isNaN(rooms) || rooms <= 0) return setIsSavingSettings(true) await save('settings', { totalRooms: rooms }) await refresh() setIsSavingSettings(false) } if (isLoading) return if (!totalRooms) { return ( ) } return (
setDateRange({ start, end })} />
{/* KPI Cards */}
{/* Period comparison */}

Periodjamforelse

Belaggning (nuvarande)

{occupancyPct}%

Belaggning (foregaende)

{prevOccupancyPct}%

Foregaende period

{prevPeriod.start} — {prevPeriod.end}

Belagda rum (foregaende)

{prevDaysInRange > 0 ? Math.round(prevTotalOccupied / prevDaysInRange) : 0} snitt / dag

Dagar med data (foregaende)

{prevDaysInRange} dagar
{/* Entry form */}
setEntryDate(e.target.value)} />
setRoomsOccupied(e.target.value)} />
setRoomsOutOfOrder(e.target.value)} />
{formExceedsTotal && (

Belagda rum ({formOccupied}) + ur drift ({formOoo}) = {formOccupied + formOoo} overstiger totalt antal rum ({totalRooms}).

)}
{/* Calendar heatmap */} {calendarData.length > 0 && (

Belaggningskalender

{['Man', 'Tis', 'Ons', 'Tor', 'Fre', 'Lor', 'Son'].map(d => (
{d}
))}
{/* Offset for first day of month */} {calendarData.length > 0 && Array.from({ length: (calendarData[0].dayOfWeek + 6) % 7 }).map((_, i) => (
))} {calendarData.map(day => (
0 ? 'text-white' : 'text-muted-foreground' )} title={`${day.date}: ${day.occupancyPct}%`} > {parseInt(day.date.slice(-2))}
))}
80%+
50-79%
1-49%
Ingen data
)} {/* Daily data table */}

Daglig data

{entries.length === 0 ? (

Ingen data registrerad i vald period.

) : (
Datum Belagda Ur drift Orsak Lediga Belaggning {entries.map(e => { const pct = totalRooms > 0 ? Math.round((e.roomsOccupied / totalRooms) * 100) : 0 const available = totalRooms - e.roomsOccupied - e.roomsOutOfOrder return ( {e.date} {e.roomsOccupied} / {totalRooms} {e.roomsOutOfOrder} {e.roomsOutOfOrder > 0 ? (e.reason ?? '-') : '-'} {available} {pct}%
) })}
)}
{/* Edit entry dialog */}
setEditOccupied(e.target.value)} />
setEditOoo(e.target.value)} />
{editExceedsTotal && (

Belagda rum ({editOccupiedNum}) + ur drift ({editOooNum}) = {editOccupiedNum + editOooNum} overstiger totalt antal rum ({totalRooms}).

)}
{/* Confirm delete dialog */} {/* Settings dialog */}
setNewTotalRooms(e.target.value)} />
) }