'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 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 { Progress } from '@/components/ui/progress' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' 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 { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogFooter, DialogDescription, } from '@/components/ui/dialog' import { Pencil, Plus, ChevronDown, ChevronUp, Trash2, CheckCircle } from 'lucide-react' import { cn } from '@/lib/utils' // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- interface Project { id: string name: string budget: number status: 'active' | 'completed' startDate: string } interface BillingEntry { id: string projectId: string description: string amount: number date: string invoiced: boolean } const COST_CATEGORIES = ['Lon', 'Material', 'Licenser', 'Ovrigt'] as const type CostCategory = typeof COST_CATEGORIES[number] interface CostEntry { id: string projectId: string description: string amount: number date: string category: CostCategory } // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- export default function ProjectBillingWorkspace({}: WorkspaceComponentProps) { const { data, save, remove, refresh, isLoading } = useExtensionData('tech', 'project-billing') // --------------------------------------------------------------------------- // Derived data // --------------------------------------------------------------------------- const projects = useMemo(() => data.filter(d => d.key.startsWith('project:')) .map(d => ({ id: d.key.replace('project:', ''), ...(d.value as Omit) })) .sort((a, b) => b.startDate.localeCompare(a.startDate)) , [data]) const billings = useMemo(() => data.filter(d => d.key.startsWith('billing:')) .map(d => ({ id: d.key.replace('billing:', ''), ...(d.value as Omit) })) .sort((a, b) => b.date.localeCompare(a.date)) , [data]) const costs = useMemo(() => data.filter(d => d.key.startsWith('cost:')) .map(d => ({ id: d.key.replace('cost:', ''), ...(d.value as Omit) })) .sort((a, b) => b.date.localeCompare(a.date)) , [data]) // --------------------------------------------------------------------------- // UI state // --------------------------------------------------------------------------- const [expandedProject, setExpandedProject] = useState(null) // New project dialog const [newProjectName, setNewProjectName] = useState('') const [newProjectBudget, setNewProjectBudget] = useState('') const [showNewProject, setShowNewProject] = useState(false) // Billing entry form (inline in expanded project) const [billingDesc, setBillingDesc] = useState('') const [billingAmount, setBillingAmount] = useState('') const [isSubmitting, setIsSubmitting] = useState(false) // Cost entry form (inline in expanded project) const [costDesc, setCostDesc] = useState('') const [costAmount, setCostAmount] = useState('') const [costCategory, setCostCategory] = useState('Ovrigt') const [isSubmittingCost, setIsSubmittingCost] = useState(false) // Edit project dialog const [editProjectOpen, setEditProjectOpen] = useState(false) const [editProjectId, setEditProjectId] = useState(null) const [editProjectName, setEditProjectName] = useState('') const [editProjectBudget, setEditProjectBudget] = useState('') const [isSavingProject, setIsSavingProject] = useState(false) // Edit billing dialog const [editBillingOpen, setEditBillingOpen] = useState(false) const [editBillingId, setEditBillingId] = useState(null) const [editBillingDesc, setEditBillingDesc] = useState('') const [editBillingAmount, setEditBillingAmount] = useState('') const [isSavingBilling, setIsSavingBilling] = useState(false) // Edit cost dialog const [editCostOpen, setEditCostOpen] = useState(false) const [editCostId, setEditCostId] = useState(null) const [editCostDesc, setEditCostDesc] = useState('') const [editCostAmount, setEditCostAmount] = useState('') const [editCostCategory, setEditCostCategory] = useState('Ovrigt') const [isSavingCost, setIsSavingCost] = useState(false) // Delete dialogs const [deleteBillingOpen, setDeleteBillingOpen] = useState(false) const [deleteBillingId, setDeleteBillingId] = useState(null) const [isDeletingBilling, setIsDeletingBilling] = useState(false) const [deleteCostOpen, setDeleteCostOpen] = useState(false) const [deleteCostId, setDeleteCostId] = useState(null) const [isDeletingCost, setIsDeletingCost] = useState(false) const [deleteProjectOpen, setDeleteProjectOpen] = useState(false) const [deleteProjectId, setDeleteProjectId] = useState(null) const [isDeletingProject, setIsDeletingProject] = useState(false) // Complete project dialog const [completeProjectOpen, setCompleteProjectOpen] = useState(false) const [completeProjectId, setCompleteProjectId] = useState(null) // --------------------------------------------------------------------------- // Project stats with correct margin calculation // --------------------------------------------------------------------------- const projectStats = useMemo(() => { return projects.map(p => { const projectBillings = billings.filter(b => b.projectId === p.id) const projectCosts = costs.filter(c => c.projectId === p.id) const totalBilled = projectBillings.reduce((s, b) => s + b.amount, 0) const totalCosts = projectCosts.reduce((s, c) => s + c.amount, 0) const uninvoiced = projectBillings.filter(b => !b.invoiced).reduce((s, b) => s + b.amount, 0) const budgetRemaining = Math.max(Math.round((p.budget - totalBilled) * 100) / 100, 0) const budgetUsed = p.budget > 0 ? Math.min(Math.round((totalBilled / p.budget) * 100), 100) : 0 // Correct margin: (revenue - costs) / revenue * 100 const revenue = totalBilled const margin = revenue > 0 ? Math.round(((revenue - totalCosts) / revenue) * 100) : 0 return { ...p, totalBilled, totalCosts, uninvoiced, budgetRemaining, budgetUsed, margin, billings: projectBillings, costs: projectCosts, } }) }, [projects, billings, costs]) const activeProjects = useMemo(() => projectStats.filter(p => p.status === 'active'), [projectStats]) const completedProjects = useMemo(() => projectStats.filter(p => p.status === 'completed'), [projectStats]) // --------------------------------------------------------------------------- // KPIs // --------------------------------------------------------------------------- const totalBilled = billings.reduce((s, b) => s + b.amount, 0) const totalCostsAll = costs.reduce((s, c) => s + c.amount, 0) const totalUninvoiced = billings.filter(b => !b.invoiced).reduce((s, b) => s + b.amount, 0) const avgMargin = activeProjects.length > 0 ? Math.round(activeProjects.reduce((s, p) => s + p.margin, 0) / activeProjects.length) : 0 const activeCount = activeProjects.length // --------------------------------------------------------------------------- // Handlers: Projects // --------------------------------------------------------------------------- const handleAddProject = async () => { if (!newProjectName.trim()) return const id = crypto.randomUUID() await save(`project:${id}`, { name: newProjectName.trim(), budget: parseFloat(newProjectBudget) || 0, status: 'active', startDate: new Date().toISOString().slice(0, 10), }) setNewProjectName('') setNewProjectBudget('') setShowNewProject(false) await refresh() } const openEditProject = (p: Project) => { setEditProjectId(p.id) setEditProjectName(p.name) setEditProjectBudget(String(p.budget)) setEditProjectOpen(true) } const handleSaveProject = async () => { if (!editProjectId || !editProjectName.trim()) return setIsSavingProject(true) const existing = projects.find(p => p.id === editProjectId) if (existing) { await save(`project:${editProjectId}`, { name: editProjectName.trim(), budget: parseFloat(editProjectBudget) || 0, status: existing.status, startDate: existing.startDate, }) await refresh() } setIsSavingProject(false) } const handleCompleteProject = async () => { if (!completeProjectId) return const existing = projects.find(p => p.id === completeProjectId) if (existing) { await save(`project:${completeProjectId}`, { name: existing.name, budget: existing.budget, status: 'completed', startDate: existing.startDate, }) await refresh() } setCompleteProjectOpen(false) setCompleteProjectId(null) } const handleDeleteProject = async () => { if (!deleteProjectId) return setIsDeletingProject(true) // Remove associated billings and costs const projBillings = billings.filter(b => b.projectId === deleteProjectId) const projCosts = costs.filter(c => c.projectId === deleteProjectId) for (const b of projBillings) { await remove(`billing:${b.id}`) } for (const c of projCosts) { await remove(`cost:${c.id}`) } await remove(`project:${deleteProjectId}`) await refresh() setIsDeletingProject(false) setDeleteProjectOpen(false) setDeleteProjectId(null) if (expandedProject === deleteProjectId) { setExpandedProject(null) } } // --------------------------------------------------------------------------- // Handlers: Billing entries // --------------------------------------------------------------------------- const handleAddBilling = async (projectId: string) => { const amt = parseFloat(billingAmount) if (isNaN(amt) || amt <= 0) return setIsSubmitting(true) const id = crypto.randomUUID() await save(`billing:${id}`, { projectId, description: billingDesc, amount: Math.round(amt * 100) / 100, date: new Date().toISOString().slice(0, 10), invoiced: false, }) setBillingDesc('') setBillingAmount('') await refresh() setIsSubmitting(false) } const handleToggleInvoiced = async (b: BillingEntry) => { await save(`billing:${b.id}`, { projectId: b.projectId, description: b.description, amount: b.amount, date: b.date, invoiced: !b.invoiced, }) await refresh() } const openEditBilling = (b: BillingEntry) => { setEditBillingId(b.id) setEditBillingDesc(b.description) setEditBillingAmount(String(b.amount)) setEditBillingOpen(true) } const handleSaveBilling = async () => { if (!editBillingId) return setIsSavingBilling(true) const existing = billings.find(b => b.id === editBillingId) if (existing) { const amt = parseFloat(editBillingAmount) if (!isNaN(amt) && amt > 0) { await save(`billing:${editBillingId}`, { projectId: existing.projectId, description: editBillingDesc, amount: Math.round(amt * 100) / 100, date: existing.date, invoiced: existing.invoiced, }) await refresh() } } setIsSavingBilling(false) } const handleDeleteBilling = async () => { if (!deleteBillingId) return setIsDeletingBilling(true) await remove(`billing:${deleteBillingId}`) await refresh() setIsDeletingBilling(false) setDeleteBillingOpen(false) setDeleteBillingId(null) } // --------------------------------------------------------------------------- // Handlers: Cost entries // --------------------------------------------------------------------------- const handleAddCost = async (projectId: string) => { const amt = parseFloat(costAmount) if (isNaN(amt) || amt <= 0) return setIsSubmittingCost(true) const id = crypto.randomUUID() await save(`cost:${id}`, { projectId, description: costDesc, amount: Math.round(amt * 100) / 100, date: new Date().toISOString().slice(0, 10), category: costCategory, }) setCostDesc('') setCostAmount('') setCostCategory('Ovrigt') await refresh() setIsSubmittingCost(false) } const openEditCost = (c: CostEntry) => { setEditCostId(c.id) setEditCostDesc(c.description) setEditCostAmount(String(c.amount)) setEditCostCategory(c.category) setEditCostOpen(true) } const handleSaveCost = async () => { if (!editCostId) return setIsSavingCost(true) const existing = costs.find(c => c.id === editCostId) if (existing) { const amt = parseFloat(editCostAmount) if (!isNaN(amt) && amt > 0) { await save(`cost:${editCostId}`, { projectId: existing.projectId, description: editCostDesc, amount: Math.round(amt * 100) / 100, date: existing.date, category: editCostCategory, }) await refresh() } } setIsSavingCost(false) } const handleDeleteCost = async () => { if (!deleteCostId) return setIsDeletingCost(true) await remove(`cost:${deleteCostId}`) await refresh() setIsDeletingCost(false) setDeleteCostOpen(false) setDeleteCostId(null) } // --------------------------------------------------------------------------- // Render helpers // --------------------------------------------------------------------------- if (isLoading) return const formatKr = (v: number) => Math.round(v * 100) / 100 /** Render a single project card (used for both active and completed) */ const renderProjectCard = (p: typeof projectStats[number]) => { const isExpanded = expandedProject === p.id const isActive = p.status === 'active' return ( setExpandedProject(isExpanded ? null : p.id)} >
{p.name} {isActive ? 'Aktiv' : 'Avslutad'} {isActive && ( <> )}
{isActive && ( )} {isExpanded ? : }
{/* Budget visualization */}
Budget

{p.budget.toLocaleString('sv-SE')} kr

Fakturerat

{formatKr(p.totalBilled).toLocaleString('sv-SE')} kr

Kostnad

{formatKr(p.totalCosts).toLocaleString('sv-SE')} kr

Marginal

= 0 ? 'text-green-600' : 'text-red-600' )}> {p.margin}%

{p.budget > 0 && (
Budgetanvandning {p.budgetUsed}% ({formatKr(p.budgetRemaining).toLocaleString('sv-SE')} kr kvar)
)}
{isExpanded && ( {/* ---- Billing section ---- */}

Fakturering

{isActive && (
setBillingDesc(e.target.value)} className="max-w-xs" /> setBillingAmount(e.target.value)} className="w-32" />
)} {p.billings.length > 0 && (
Fakt. Datum Beskrivning Belopp {p.billings.map(b => ( handleToggleInvoiced(b)} aria-label="Markera som fakturerad" /> {b.date} {b.description} {formatKr(b.amount).toLocaleString('sv-SE')} kr
))}
)} {p.billings.length === 0 && (

Inga faktureringsrader annu.

)}
{/* ---- Cost section ---- */}

Kostnader

{isActive && (
setCostDesc(e.target.value)} className="max-w-xs" /> setCostAmount(e.target.value)} className="w-32" />
)} {p.costs.length > 0 && (
Datum Beskrivning Kategori Belopp {p.costs.map(c => ( {c.date} {c.description} {c.category} {formatKr(c.amount).toLocaleString('sv-SE')} kr
))}
)} {p.costs.length === 0 && (

Inga kostnader registrerade annu.

)}
)}
) } return (
Projekt Fakturering {/* ==== Projects tab ==== */}
Nytt projekt Skapa ett nytt projekt att fakturera mot.
setNewProjectName(e.target.value)} />
setNewProjectBudget(e.target.value)} />
{/* Active projects */} {activeProjects.length > 0 && (
{activeProjects.map(renderProjectCard)}
)} {activeProjects.length === 0 && (

Inga aktiva projekt. Skapa ett nytt projekt ovan.

)} {/* Completed projects */} {completedProjects.length > 0 && (

Avslutade projekt

{completedProjects.map(renderProjectCard)}
)}
{/* ==== Billing tab ==== */}

All fakturering

{billings.length === 0 ? (

Ingen fakturering registrerad annu.

) : (
Fakt. Datum Projekt Beskrivning Belopp {billings.map(b => { const proj = projects.find(p => p.id === b.projectId) return ( handleToggleInvoiced(b)} aria-label="Markera som fakturerad" /> {b.date} {proj?.name ?? 'Okant'} {b.description} {formatKr(b.amount).toLocaleString('sv-SE')} kr ) })}
)}
{/* ==== Dialogs ==== */} {/* Edit project dialog */}
setEditProjectName(e.target.value)} />
setEditProjectBudget(e.target.value)} />
{/* Edit billing dialog */}
setEditBillingDesc(e.target.value)} />
setEditBillingAmount(e.target.value)} />
{/* Edit cost dialog */}
setEditCostDesc(e.target.value)} />
setEditCostAmount(e.target.value)} />
{/* Complete project confirmation */} Avsluta projekt Ar du saker pa att du vill markera projektet som avslutat? Du kan inte langre lagga till rader. {/* Delete billing confirmation */} {/* Delete cost confirmation */} {/* Delete project confirmation */}
) }