Files
accounted/components/extensions/tech/BillableHoursWorkspace.tsx
T
Emil 6a5b2b7792 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>
2026-02-21 15:21:49 +01:00

966 lines
38 KiB
TypeScript

'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<Project, 'id'>),
}))
, [data])
const entries = useMemo(() =>
data.filter(d => d.key.startsWith('entry:'))
.map(d => ({ id: d.key.replace('entry:', ''), ...(d.value as Omit<TimeEntry, 'id'>) }))
.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<TimeEntry | null>(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<string | null>(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<string | null>(null)
const [weekCellDay, setWeekCellDay] = useState<number | null>(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<string, { total: number; billable: number }>()
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<string, { billable: number; nonBillable: number; revenue: number }>()
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<string, { total: number; billable: number }>()
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<string, Map<number, number>>()
for (const p of activeProjects) {
const dayMap = new Map<number, number>()
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<string, number>()
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<string, string>) => {
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 <ExtensionLoadingSkeleton />
if (!hourlyRate) {
return (
<SetupPrompt
title="Konfigurera timpris"
description="Ange ditt timpris for att borja spara debiterbar tid."
fields={[{ key: 'hourlyRate', label: 'Timpris (kr/h)', type: 'number', placeholder: 'T.ex. 1000' }]}
onSave={handleSetup}
/>
)
}
const visibleProjectStats = showArchived
? projectStats
: projectStats.filter(p => p.effectiveStatus !== 'archived')
return (
<div className="space-y-6">
{/* Settings gear button */}
<div className="flex justify-end">
<Dialog open={showSettings} onOpenChange={(open) => {
setShowSettings(open)
if (open) setSettingsRate(String(hourlyRate))
}}>
<DialogTrigger asChild>
<Button variant="ghost" size="sm">
<Settings className="h-4 w-4 mr-1" />
Installningar
</Button>
</DialogTrigger>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Installningar</DialogTitle>
<DialogDescription>Andra ditt globala timpris.</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Timpris (kr/h)</Label>
<Input
type="number"
min="0"
value={settingsRate}
onChange={e => setSettingsRate(e.target.value)}
placeholder="T.ex. 1000"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowSettings(false)}>Avbryt</Button>
<Button onClick={handleSaveSettings} disabled={!settingsRate || parseFloat(settingsRate) <= 0}>
Spara
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
<Tabs defaultValue="timesheet">
<TabsList>
<TabsTrigger value="timesheet">Tidrapport</TabsTrigger>
<TabsTrigger value="weekly">Veckorapport</TabsTrigger>
<TabsTrigger value="overview">Oversikt</TabsTrigger>
<TabsTrigger value="projects">Projekt</TabsTrigger>
</TabsList>
{/* --- Timesheet Tab --- */}
<TabsContent value="timesheet" className="space-y-6 mt-4">
{activeProjects.length === 0 ? (
<div className="rounded-xl border p-6 text-center">
<p className="text-sm text-muted-foreground">
Lagg till projekt under fliken &quot;Projekt&quot; for att borja rapportera tid.
</p>
</div>
) : (
<DataEntryForm
title="Registrera tid"
onSubmit={handleSubmit}
submitLabel="Registrera"
isSubmitting={isSubmitting}
>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Datum</Label>
<Input type="date" value={entryDate} onChange={e => setEntryDate(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Projekt</Label>
<Select value={projectId} onValueChange={setProjectId}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{activeProjects.map(p => <SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Timmar</Label>
<Input type="number" step="0.25" min="0" placeholder="0" value={hours} onChange={e => setHours(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Beskrivning</Label>
<Input placeholder="Vad jobbade du med?" value={description} onChange={e => setDescription(e.target.value)} />
</div>
</div>
<div className="flex items-center gap-2">
<Switch checked={billable} onCheckedChange={setBillable} id="billable-switch" />
<Label htmlFor="billable-switch" className="text-sm">Debiterbar</Label>
</div>
</DataEntryForm>
)}
{/* Today's entries */}
<div>
<h3 className="text-sm font-semibold mb-3">Idag ({todayStr})</h3>
{todayEntries.length === 0 ? (
<p className="text-sm text-muted-foreground">Ingen tid registrerad idag.</p>
) : (
<div className="rounded-xl border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Projekt</TableHead>
<TableHead className="text-right">Timmar</TableHead>
<TableHead>Typ</TableHead>
<TableHead>Beskrivning</TableHead>
<TableHead className="w-20"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{todayEntries.map(e => (
<TableRow key={e.id}>
<TableCell className="font-medium">{e.projectName}</TableCell>
<TableCell className="text-right tabular-nums">{e.hours}h</TableCell>
<TableCell>{e.billable ? 'Debiterbar' : 'Intern'}</TableCell>
<TableCell className="text-muted-foreground">{e.description}</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={() => setDeleteEntryId(e.id)}>
<Trash2 className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
</TabsContent>
{/* --- Weekly Tab --- */}
<TabsContent value="weekly" className="space-y-6 mt-4">
{activeProjects.length === 0 ? (
<div className="rounded-xl border p-6 text-center">
<p className="text-sm text-muted-foreground">
Lagg till projekt under fliken &quot;Projekt&quot; for att borja rapportera tid.
</p>
</div>
) : (
<>
<div className="flex items-center justify-between">
<Button
variant="outline"
size="sm"
onClick={() => setWeekStart(prev => addDays(prev, -7))}
>
<ChevronLeft className="h-4 w-4 mr-1" />
Foregaende
</Button>
<span className="text-sm font-medium">{weekLabel}</span>
<Button
variant="outline"
size="sm"
onClick={() => setWeekStart(prev => addDays(prev, 7))}
>
Nasta
<ChevronRight className="h-4 w-4 ml-1" />
</Button>
</div>
<div className="rounded-xl border overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="min-w-[140px]">Projekt</TableHead>
{DAY_LABELS.map((label, i) => (
<TableHead key={i} className="text-center min-w-[80px]">
<div className="text-xs">{label}</div>
<div className="text-xs text-muted-foreground">{weekDays[i].slice(5)}</div>
</TableHead>
))}
<TableHead className="text-right min-w-[70px]">Totalt</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{activeProjects.map(p => {
const dayMap = weekGrid.get(p.id)
const projTotal = weekProjectTotals.get(p.id) ?? 0
return (
<TableRow key={p.id}>
<TableCell className="font-medium text-sm">{p.name}</TableCell>
{Array.from({ length: 7 }, (_, i) => {
const cellHours = dayMap?.get(i) ?? 0
const isEditing = weekCellProject === p.id && weekCellDay === i
return (
<TableCell key={i} className="text-center p-1">
{isEditing ? (
<Input
type="number"
step="0.25"
min="0"
className="h-8 w-16 mx-auto text-center text-sm"
value={weekCellHours}
onChange={e => setWeekCellHours(e.target.value)}
onBlur={handleWeekCellSubmit}
onKeyDown={handleWeekCellKeyDown}
autoFocus
/>
) : (
<button
type="button"
className="w-full h-8 rounded text-sm tabular-nums hover:bg-muted transition-colors cursor-pointer"
onClick={() => handleWeekCellClick(p.id, i)}
title="Klicka for att registrera timmar"
>
{cellHours > 0 ? `${cellHours}h` : '-'}
</button>
)}
</TableCell>
)
})}
<TableCell className="text-right tabular-nums font-medium">
{projTotal > 0 ? `${projTotal}h` : '-'}
</TableCell>
</TableRow>
)
})}
{/* Totals row */}
<TableRow className="border-t-2 font-semibold">
<TableCell>Totalt</TableCell>
{weekDayTotals.map((total, i) => (
<TableCell key={i} className="text-center tabular-nums">
{total > 0 ? `${total}h` : '-'}
</TableCell>
))}
<TableCell className="text-right tabular-nums">
{weekGrandTotal > 0 ? `${weekGrandTotal}h` : '-'}
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</>
)}
</TabsContent>
{/* --- Overview Tab --- */}
<TabsContent value="overview" className="space-y-6 mt-4">
<div className="grid grid-cols-1 sm:grid-cols-4 gap-4">
<KPICard label="Belaggningsgrad" value={utilization} suffix="%" />
<KPICard label="Debiterbara timmar" value={billableHours} suffix="h" />
<KPICard label="Totala timmar" value={totalHours} suffix="h" />
<KPICard label="Effektivt timpris" value={effectiveRate.toLocaleString('sv-SE')} suffix="kr/h" />
</div>
{monthlyTrend.length > 0 && (
<div>
<h3 className="text-sm font-semibold mb-3">Belaggning per manad</h3>
<MonthlyTrendTable rows={monthlyTrend} valueLabel="Belaggning" valueSuffix="%" />
</div>
)}
{/* Period summary */}
{periodSummary.length > 0 && (
<div>
<h3 className="text-sm font-semibold mb-3">Manatlig sammanstallning</h3>
<div className="rounded-xl border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Period</TableHead>
<TableHead className="text-right">Debiterbara</TableHead>
<TableHead className="text-right">Icke-debiterbara</TableHead>
<TableHead className="text-right">Totalt</TableHead>
<TableHead className="text-right">Intakt</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{periodSummary.map(row => (
<TableRow key={row.month}>
<TableCell className="font-medium">{row.month}</TableCell>
<TableCell className="text-right tabular-nums">{row.billable}h</TableCell>
<TableCell className="text-right tabular-nums">{row.nonBillable}h</TableCell>
<TableCell className="text-right tabular-nums">
{Math.round((row.billable + row.nonBillable) * 100) / 100}h
</TableCell>
<TableCell className="text-right tabular-nums">
{Math.round(row.revenue).toLocaleString('sv-SE')} kr
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)}
</TabsContent>
{/* --- Projects Tab --- */}
<TabsContent value="projects" className="space-y-6 mt-4">
<div className="flex items-center justify-between flex-wrap gap-2">
<Dialog open={showNewProject} onOpenChange={setShowNewProject}>
<DialogTrigger asChild>
<Button size="sm" variant="outline">
<Plus className="h-4 w-4 mr-1" /> Nytt projekt
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Nytt projekt</DialogTitle>
<DialogDescription>Lagg till ett nytt projekt att rapportera tid pa.</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Projektnamn</Label>
<Input
placeholder="T.ex. Kundprojekt A"
value={newProjectName}
onChange={e => setNewProjectName(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Kund (valfritt)</Label>
<Input
placeholder="T.ex. Foretag AB"
value={newProjectClient}
onChange={e => setNewProjectClient(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Timpris (kr/h, valfritt)</Label>
<Input
type="number"
min="0"
placeholder={`Standard: ${hourlyRate}`}
value={newProjectRate}
onChange={e => setNewProjectRate(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Om tomt anvands det globala timpriset ({hourlyRate} kr/h).
</p>
</div>
</div>
<DialogFooter>
<Button onClick={handleAddProject} disabled={!newProjectName.trim()}>Skapa</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<div className="flex items-center gap-2">
<Switch
checked={showArchived}
onCheckedChange={setShowArchived}
id="show-archived-switch"
/>
<Label htmlFor="show-archived-switch" className="text-sm">Visa arkiverade</Label>
</div>
</div>
{visibleProjectStats.length === 0 ? (
<p className="text-sm text-muted-foreground">Inga projekt att visa.</p>
) : (
<div className="rounded-xl border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Projekt</TableHead>
<TableHead>Kund</TableHead>
<TableHead className="text-right">Timpris</TableHead>
<TableHead className="text-right">Timmar (manad)</TableHead>
<TableHead className="text-right">Debiterbara</TableHead>
<TableHead className="text-right">Belaggning</TableHead>
<TableHead>Status</TableHead>
<TableHead className="w-28"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{visibleProjectStats.map(p => {
const status = p.effectiveStatus
return (
<TableRow key={p.id} className={status === 'archived' ? 'opacity-60' : undefined}>
<TableCell className="font-medium">{p.name}</TableCell>
<TableCell className="text-muted-foreground">{p.client || '-'}</TableCell>
<TableCell className="text-right tabular-nums">
{p.hourlyRate ? `${p.hourlyRate} kr/h` : `${hourlyRate} kr/h`}
{p.hourlyRate ? (
<span className="text-xs text-muted-foreground ml-1">(projekt)</span>
) : null}
</TableCell>
<TableCell className="text-right tabular-nums">{p.total}h</TableCell>
<TableCell className="text-right tabular-nums">{p.billable}h</TableCell>
<TableCell className="text-right tabular-nums">{p.utilization}%</TableCell>
<TableCell>
<Badge variant={statusBadgeVariant(status)}>
{statusLabel(status)}
</Badge>
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
{status === 'active' && (
<Button
variant="ghost"
size="sm"
title="Markera som avslutad"
onClick={() => handleProjectStatusChange(p.id, 'completed')}
>
<CheckCircle className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
)}
{status === 'completed' && (
<Button
variant="ghost"
size="sm"
title="Ateraktivera"
onClick={() => handleProjectStatusChange(p.id, 'active')}
>
<CheckCircle className="h-3.5 w-3.5 text-green-600" />
</Button>
)}
{status !== 'archived' && (
<Button
variant="ghost"
size="sm"
title="Arkivera"
onClick={() => handleProjectStatusChange(p.id, 'archived')}
>
<Archive className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
)}
{status === 'archived' && (
<Button
variant="ghost"
size="sm"
title="Ateraktivera"
onClick={() => handleProjectStatusChange(p.id, 'active')}
>
<Archive className="h-3.5 w-3.5 text-green-600" />
</Button>
)}
</div>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
)}
</TabsContent>
</Tabs>
{/* Edit Entry Dialog */}
<EditEntryDialog
open={editEntry !== null}
onOpenChange={(open) => { if (!open) setEditEntry(null) }}
title="Redigera tidpost"
description="Andra uppgifterna for den registrerade tiden."
onSave={handleSaveEdit}
isSaving={isSavingEdit}
>
<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>Projekt</Label>
<Select value={editProjectId} onValueChange={setEditProjectId}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{activeProjects.map(p => <SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Timmar</Label>
<Input
type="number"
step="0.25"
min="0"
value={editHours}
onChange={e => setEditHours(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Beskrivning</Label>
<Input
placeholder="Vad jobbade du med?"
value={editDescription}
onChange={e => setEditDescription(e.target.value)}
/>
</div>
<div className="flex items-center gap-2">
<Switch checked={editBillable} onCheckedChange={setEditBillable} id="edit-billable-switch" />
<Label htmlFor="edit-billable-switch" className="text-sm">Debiterbar</Label>
</div>
</EditEntryDialog>
{/* Confirm Delete Dialog */}
<ConfirmDeleteDialog
open={deleteEntryId !== null}
onOpenChange={(open) => { 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}
/>
</div>
)
}