'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 MonthlyTrendTable from '@/components/extensions/shared/MonthlyTrendTable' 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 { 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 { Switch } from '@/components/ui/switch' import { Badge } from '@/components/ui/badge' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '@/components/ui/table' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogFooter, DialogDescription, } from '@/components/ui/dialog' import { Pencil, Plus, Trash2, Settings, ChevronLeft, ChevronRight, Archive, CheckCircle } from 'lucide-react' // --- Types --- type ProjectStatus = 'active' | 'completed' | 'archived' interface TimeEntry { id: string date: string projectId: string projectName: string hours: number billable: boolean description: string } interface Project { id: string name: string active: boolean client?: string hourlyRate?: number status?: ProjectStatus } // --- Helpers --- function getMonday(d: Date): Date { const copy = new Date(d) const day = copy.getDay() const diff = day === 0 ? -6 : 1 - day copy.setDate(copy.getDate() + diff) copy.setHours(0, 0, 0, 0) return copy } function addDays(d: Date, n: number): Date { const copy = new Date(d) copy.setDate(copy.getDate() + n) return copy } function formatDateStr(d: Date): string { return d.toISOString().slice(0, 10) } const DAY_LABELS = ['Man', 'Tis', 'Ons', 'Tor', 'Fre', 'Lor', 'Son'] function getProjectStatus(p: { active: boolean; status?: ProjectStatus }): ProjectStatus { return p.status ?? (p.active ? 'active' : 'completed') } function getEffectiveRate(project: Project | undefined, globalRate: number): number { if (project?.hourlyRate && project.hourlyRate > 0) return project.hourlyRate return globalRate } function statusLabel(status: ProjectStatus): string { switch (status) { case 'active': return 'Aktiv' case 'completed': return 'Avslutad' case 'archived': return 'Arkiverad' } } function statusBadgeVariant(status: ProjectStatus): 'default' | 'secondary' | 'outline' { switch (status) { case 'active': return 'default' case 'completed': return 'secondary' case 'archived': return 'outline' } } // --- Component --- export default function BillableHoursWorkspace({}: WorkspaceComponentProps) { const { data, save, remove, refresh, isLoading } = useExtensionData('tech', 'billable-hours') const settings = data.find(d => d.key === 'settings')?.value as { hourlyRate?: number } | undefined const hourlyRate = settings?.hourlyRate ?? 0 // --- Derived data --- const projects = useMemo(() => data.filter(d => d.key.startsWith('project:')) .map(d => ({ id: d.key.replace('project:', ''), ...(d.value as Omit), })) , [data]) const entries = useMemo(() => data.filter(d => d.key.startsWith('entry:')) .map(d => ({ id: d.key.replace('entry:', ''), ...(d.value as Omit) })) .sort((a, b) => b.date.localeCompare(a.date)) , [data]) // --- Form state --- const todayStr = useMemo(() => new Date().toISOString().slice(0, 10), []) const currentMonth = useMemo(() => new Date().toISOString().slice(0, 7), []) const [entryDate, setEntryDate] = useState(todayStr) const [selectedProjectId, setProjectId] = useState('') const activeProjects = projects.filter(p => getProjectStatus(p) === 'active') const projectId = selectedProjectId || (activeProjects.length > 0 ? activeProjects[0].id : '') const [hours, setHours] = useState('') const [billable, setBillable] = useState(true) const [description, setDescription] = useState('') const [isSubmitting, setIsSubmitting] = useState(false) // New project dialog const [newProjectName, setNewProjectName] = useState('') const [newProjectClient, setNewProjectClient] = useState('') const [newProjectRate, setNewProjectRate] = useState('') const [showNewProject, setShowNewProject] = useState(false) // Edit entry dialog const [editEntry, setEditEntry] = useState(null) const [editDate, setEditDate] = useState('') const [editProjectId, setEditProjectId] = useState('') const [editHours, setEditHours] = useState('') const [editBillable, setEditBillable] = useState(true) const [editDescription, setEditDescription] = useState('') const [isSavingEdit, setIsSavingEdit] = useState(false) // Delete confirm const [deleteEntryId, setDeleteEntryId] = useState(null) const [isDeleting, setIsDeleting] = useState(false) // Show archived toggle const [showArchived, setShowArchived] = useState(false) // Settings dialog const [showSettings, setShowSettings] = useState(false) const [settingsRate, setSettingsRate] = useState('') // Weekly view state const [weekStart, setWeekStart] = useState(() => getMonday(new Date())) const [weekCellProject, setWeekCellProject] = useState(null) const [weekCellDay, setWeekCellDay] = useState(null) const [weekCellHours, setWeekCellHours] = useState('') // --- KPI calculations --- const monthEntries = useMemo(() => entries.filter(e => e.date.startsWith(currentMonth)) , [entries, currentMonth]) const totalHours = monthEntries.reduce((s, e) => s + e.hours, 0) const billableHours = monthEntries.filter(e => e.billable).reduce((s, e) => s + e.hours, 0) const utilization = totalHours > 0 ? Math.round((billableHours / totalHours) * 100) : 0 const effectiveRate = totalHours > 0 ? Math.round((billableHours * hourlyRate) / totalHours) : 0 // Today's entries const todayEntries = entries.filter(e => e.date === todayStr) // Monthly trend (utilization %) const monthlyTrend = useMemo(() => { const map = new Map() for (const e of entries) { const month = e.date.slice(0, 7) const existing = map.get(month) ?? { total: 0, billable: 0 } existing.total += e.hours if (e.billable) existing.billable += e.hours map.set(month, existing) } return Array.from(map.entries()) .sort(([a], [b]) => a.localeCompare(b)) .map(([month, d]) => ({ month, value: d.total > 0 ? Math.round((d.billable / d.total) * 100) : 0, })) }, [entries]) // Period summary: monthly totals for billable/non-billable + revenue const periodSummary = useMemo(() => { const map = new Map() for (const e of entries) { const month = e.date.slice(0, 7) const existing = map.get(month) ?? { billable: 0, nonBillable: 0, revenue: 0 } if (e.billable) { existing.billable += e.hours const proj = projects.find(p => p.id === e.projectId) const rate = getEffectiveRate(proj, hourlyRate) existing.revenue += Math.round(e.hours * rate * 100) / 100 } else { existing.nonBillable += e.hours } map.set(month, existing) } return Array.from(map.entries()) .sort(([a], [b]) => b.localeCompare(a)) .map(([month, d]) => ({ month, ...d })) }, [entries, projects, hourlyRate]) // Per-project stats const projectStats = useMemo(() => { const cm = new Date().toISOString().slice(0, 7) const filtered = entries.filter(e => e.date.startsWith(cm)) const map = new Map() for (const e of filtered) { const existing = map.get(e.projectId) ?? { total: 0, billable: 0 } existing.total += e.hours if (e.billable) existing.billable += e.hours map.set(e.projectId, existing) } return projects.map(p => { const stats = map.get(p.id) ?? { total: 0, billable: 0 } return { ...p, ...stats, effectiveStatus: getProjectStatus(p), utilization: stats.total > 0 ? Math.round((stats.billable / stats.total) * 100) : 0, } }) }, [projects, entries]) // Weekly grid data const weekDays = useMemo(() => Array.from({ length: 7 }, (_, i) => formatDateStr(addDays(weekStart, i))) , [weekStart]) const weekLabel = useMemo(() => { const end = addDays(weekStart, 6) const startStr = weekStart.toLocaleDateString('sv-SE', { day: 'numeric', month: 'short' }) const endStr = end.toLocaleDateString('sv-SE', { day: 'numeric', month: 'short' }) return `${startStr} - ${endStr}` }, [weekStart]) const weekGrid = useMemo(() => { const weekDateSet = new Set(weekDays) const weekEntries = entries.filter(e => weekDateSet.has(e.date)) const grid = new Map>() for (const p of activeProjects) { const dayMap = new Map() for (let i = 0; i < 7; i++) dayMap.set(i, 0) grid.set(p.id, dayMap) } for (const e of weekEntries) { const dayIndex = weekDays.indexOf(e.date) if (dayIndex < 0) continue const existing = grid.get(e.projectId) if (existing) { existing.set(dayIndex, (existing.get(dayIndex) ?? 0) + e.hours) } } return grid }, [activeProjects, entries, weekDays]) const weekDayTotals = useMemo(() => { const totals = Array(7).fill(0) for (const dayMap of weekGrid.values()) { for (let i = 0; i < 7; i++) { totals[i] += dayMap.get(i) ?? 0 } } return totals as number[] }, [weekGrid]) const weekProjectTotals = useMemo(() => { const map = new Map() for (const [projId, dayMap] of weekGrid.entries()) { let total = 0 for (const h of dayMap.values()) total += h map.set(projId, total) } return map }, [weekGrid]) const weekGrandTotal = weekDayTotals.reduce((s, v) => s + v, 0) // --- Handlers --- const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() const h = parseFloat(hours) if (isNaN(h) || h <= 0 || !projectId) return setIsSubmitting(true) const proj = projects.find(p => p.id === projectId) const id = crypto.randomUUID() await save(`entry:${id}`, { date: entryDate, projectId, projectName: proj?.name ?? '', hours: h, billable, description, }) setHours('') setDescription('') setBillable(true) await refresh() setIsSubmitting(false) } const handleAddProject = async () => { if (!newProjectName.trim()) return const id = crypto.randomUUID() const rateVal = parseFloat(newProjectRate) await save(`project:${id}`, { name: newProjectName.trim(), active: true, status: 'active' as ProjectStatus, client: newProjectClient.trim() || undefined, hourlyRate: (!isNaN(rateVal) && rateVal > 0) ? rateVal : undefined, }) setNewProjectName('') setNewProjectClient('') setNewProjectRate('') setShowNewProject(false) await refresh() } const handleDeleteEntry = async () => { if (!deleteEntryId) return setIsDeleting(true) await remove(`entry:${deleteEntryId}`) await refresh() setIsDeleting(false) setDeleteEntryId(null) } const openEditEntry = useCallback((entry: TimeEntry) => { setEditEntry(entry) setEditDate(entry.date) setEditProjectId(entry.projectId) setEditHours(String(entry.hours)) setEditBillable(entry.billable) setEditDescription(entry.description) }, []) const handleSaveEdit = async () => { if (!editEntry) return const h = parseFloat(editHours) if (isNaN(h) || h <= 0 || !editProjectId) return setIsSavingEdit(true) const proj = projects.find(p => p.id === editProjectId) await save(`entry:${editEntry.id}`, { date: editDate, projectId: editProjectId, projectName: proj?.name ?? editEntry.projectName, hours: h, billable: editBillable, description: editDescription, }) await refresh() setIsSavingEdit(false) setEditEntry(null) } const handleProjectStatusChange = async (projectId: string, newStatus: ProjectStatus) => { const proj = projects.find(p => p.id === projectId) if (!proj) return await save(`project:${projectId}`, { name: proj.name, active: newStatus === 'active', status: newStatus, client: proj.client, hourlyRate: proj.hourlyRate, }) await refresh() } const handleSetup = async (values: Record) => { await save('settings', { hourlyRate: parseFloat(values.hourlyRate) || 0 }) } const handleSaveSettings = async () => { const rate = parseFloat(settingsRate) if (isNaN(rate) || rate <= 0) return await save('settings', { hourlyRate: rate }) await refresh() setShowSettings(false) } const handleWeekCellClick = (projId: string, dayIndex: number) => { setWeekCellProject(projId) setWeekCellDay(dayIndex) setWeekCellHours('') } const handleWeekCellSubmit = async () => { if (weekCellProject === null || weekCellDay === null) return const h = parseFloat(weekCellHours) if (isNaN(h) || h <= 0) { setWeekCellProject(null) setWeekCellDay(null) return } const proj = projects.find(p => p.id === weekCellProject) const id = crypto.randomUUID() await save(`entry:${id}`, { date: weekDays[weekCellDay], projectId: weekCellProject, projectName: proj?.name ?? '', hours: h, billable: true, description: '', }) await refresh() setWeekCellProject(null) setWeekCellDay(null) setWeekCellHours('') } const handleWeekCellKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter') { e.preventDefault() handleWeekCellSubmit() } else if (e.key === 'Escape') { setWeekCellProject(null) setWeekCellDay(null) } } // --- Render --- if (isLoading) return if (!hourlyRate) { return ( ) } const visibleProjectStats = showArchived ? projectStats : projectStats.filter(p => p.effectiveStatus !== 'archived') return (
{/* Settings gear button */}
{ setShowSettings(open) if (open) setSettingsRate(String(hourlyRate)) }}> Installningar Andra ditt globala timpris.
setSettingsRate(e.target.value)} placeholder="T.ex. 1000" />
Tidrapport Veckorapport Oversikt Projekt {/* --- Timesheet Tab --- */} {activeProjects.length === 0 ? (

Lagg till projekt under fliken "Projekt" for att borja rapportera tid.

) : (
setEntryDate(e.target.value)} />
setHours(e.target.value)} />
setDescription(e.target.value)} />
)} {/* Today's entries */}

Idag ({todayStr})

{todayEntries.length === 0 ? (

Ingen tid registrerad idag.

) : (
Projekt Timmar Typ Beskrivning {todayEntries.map(e => ( {e.projectName} {e.hours}h {e.billable ? 'Debiterbar' : 'Intern'} {e.description}
))}
)}
{/* --- Weekly Tab --- */} {activeProjects.length === 0 ? (

Lagg till projekt under fliken "Projekt" for att borja rapportera tid.

) : ( <>
{weekLabel}
Projekt {DAY_LABELS.map((label, i) => (
{label}
{weekDays[i].slice(5)}
))} Totalt
{activeProjects.map(p => { const dayMap = weekGrid.get(p.id) const projTotal = weekProjectTotals.get(p.id) ?? 0 return ( {p.name} {Array.from({ length: 7 }, (_, i) => { const cellHours = dayMap?.get(i) ?? 0 const isEditing = weekCellProject === p.id && weekCellDay === i return ( {isEditing ? ( setWeekCellHours(e.target.value)} onBlur={handleWeekCellSubmit} onKeyDown={handleWeekCellKeyDown} autoFocus /> ) : ( )} ) })} {projTotal > 0 ? `${projTotal}h` : '-'} ) })} {/* Totals row */} Totalt {weekDayTotals.map((total, i) => ( {total > 0 ? `${total}h` : '-'} ))} {weekGrandTotal > 0 ? `${weekGrandTotal}h` : '-'}
)}
{/* --- Overview Tab --- */}
{monthlyTrend.length > 0 && (

Belaggning per manad

)} {/* Period summary */} {periodSummary.length > 0 && (

Manatlig sammanstallning

Period Debiterbara Icke-debiterbara Totalt Intakt {periodSummary.map(row => ( {row.month} {row.billable}h {row.nonBillable}h {Math.round((row.billable + row.nonBillable) * 100) / 100}h {Math.round(row.revenue).toLocaleString('sv-SE')} kr ))}
)}
{/* --- Projects Tab --- */}
Nytt projekt Lagg till ett nytt projekt att rapportera tid pa.
setNewProjectName(e.target.value)} />
setNewProjectClient(e.target.value)} />
setNewProjectRate(e.target.value)} />

Om tomt anvands det globala timpriset ({hourlyRate} kr/h).

{visibleProjectStats.length === 0 ? (

Inga projekt att visa.

) : (
Projekt Kund Timpris Timmar (manad) Debiterbara Belaggning Status {visibleProjectStats.map(p => { const status = p.effectiveStatus return ( {p.name} {p.client || '-'} {p.hourlyRate ? `${p.hourlyRate} kr/h` : `${hourlyRate} kr/h`} {p.hourlyRate ? ( (projekt) ) : null} {p.total}h {p.billable}h {p.utilization}% {statusLabel(status)}
{status === 'active' && ( )} {status === 'completed' && ( )} {status !== 'archived' && ( )} {status === 'archived' && ( )}
) })}
)}
{/* Edit Entry Dialog */} { if (!open) setEditEntry(null) }} title="Redigera tidpost" description="Andra uppgifterna for den registrerade tiden." onSave={handleSaveEdit} isSaving={isSavingEdit} >
setEditDate(e.target.value)} />
setEditHours(e.target.value)} />
setEditDescription(e.target.value)} />
{/* Confirm Delete Dialog */} { if (!open) setDeleteEntryId(null) }} title="Ta bort tidpost" description="Ar du saker pa att du vill ta bort denna tidpost? Atgarden kan inte angras." onConfirm={handleDeleteEntry} isDeleting={isDeleting} />
) }