'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 DateRangeFilter from '@/components/extensions/shared/DateRangeFilter' 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '@/components/ui/table' import { Pencil, Plus, ChevronDown, ChevronUp, Trash2, AlertTriangle, CheckCircle } from 'lucide-react' import { cn } from '@/lib/utils' interface Project { id: string name: string budget: number status: 'active' | 'completed' startDate: string } interface CostEntry { id: string projectId: string description: string amount: number date: string category: string } interface RevenueEntry { id: string projectId: string description: string amount: number date: string } const COST_CATEGORIES = ['Material', 'Arbetskraft', 'Underentreprenor', 'Maskiner', 'Ovrigt'] function getBudgetStatus(totalCost: number, budget: number): 'ok' | 'warning' | 'danger' { if (budget <= 0) return 'ok' const ratio = totalCost / budget if (ratio >= 1) return 'danger' if (ratio >= 0.8) return 'warning' return 'ok' } function getProgressColor(status: 'ok' | 'warning' | 'danger'): string { switch (status) { case 'danger': return '[&>div]:bg-red-500' case 'warning': return '[&>div]:bg-amber-500' default: return '' } } export default function ProjectCostWorkspace({}: WorkspaceComponentProps) { const { data, save, remove, refresh, isLoading } = useExtensionData('construction', 'project-cost') // --- Date range filter --- const now = new Date() const [dateRange, setDateRange] = useState<{ start: string; end: string } | null>(null) // --- Parse 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 allCosts = 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]) const allRevenues = useMemo(() => data.filter(d => d.key.startsWith('revenue:')) .map(d => ({ id: d.key.replace('revenue:', ''), ...(d.value as Omit) })) .sort((a, b) => b.date.localeCompare(a.date)) , [data]) // Filtered costs/revenues based on date range const costs = useMemo(() => { if (!dateRange) return allCosts return allCosts.filter(c => c.date >= dateRange.start && c.date <= dateRange.end) }, [allCosts, dateRange]) const revenues = useMemo(() => { if (!dateRange) return allRevenues return allRevenues.filter(r => r.date >= dateRange.start && r.date <= dateRange.end) }, [allRevenues, dateRange]) // --- UI state --- const [expandedProject, setExpandedProject] = useState(null) const [showNewProject, setShowNewProject] = useState(false) const [newProjectName, setNewProjectName] = useState('') const [newProjectBudget, setNewProjectBudget] = useState('') // Cost/Revenue entry forms const [costDesc, setCostDesc] = useState('') const [costAmount, setCostAmount] = useState('') const [costCategory, setCostCategory] = useState(COST_CATEGORIES[0]) const [revDesc, setRevDesc] = useState('') const [revAmount, setRevAmount] = useState('') const [isSubmitting, setIsSubmitting] = useState(false) // Delete confirmation state const [deleteTarget, setDeleteTarget] = useState<{ type: 'cost' | 'revenue' | 'project' id: string label: string } | null>(null) const [isDeleting, setIsDeleting] = useState(false) // Edit cost/revenue entry state const [editEntry, setEditEntry] = useState<{ type: 'cost' | 'revenue' id: string projectId: string description: string amount: string date: string category?: string } | null>(null) const [isSavingEdit, setIsSavingEdit] = useState(false) // Edit project state const [editProject, setEditProject] = useState<{ id: string name: string budget: string } | null>(null) const [isSavingProject, setIsSavingProject] = useState(false) // Complete project confirmation state const [completeProjectId, setCompleteProjectId] = useState(null) // --- Computed stats --- const projectStats = useMemo(() => { return projects.map(p => { const projectCosts = costs.filter(c => c.projectId === p.id) const projectRevenues = revenues.filter(r => r.projectId === p.id) const totalCost = projectCosts.reduce((s, c) => s + c.amount, 0) const totalRevenue = projectRevenues.reduce((s, r) => s + r.amount, 0) const margin = totalRevenue > 0 ? Math.round(((totalRevenue - totalCost) / totalRevenue) * 100) : 0 const budgetUsed = p.budget > 0 ? Math.round((totalCost / p.budget) * 100) : 0 const budgetStatus = getBudgetStatus(totalCost, p.budget) // Cost category breakdown const categoryTotals = COST_CATEGORIES.map(cat => { const catTotal = projectCosts .filter(c => c.category === cat) .reduce((s, c) => s + c.amount, 0) return { category: cat, total: catTotal, pct: totalCost > 0 ? Math.round((catTotal / totalCost) * 100) : 0, } }).filter(ct => ct.total > 0) return { ...p, totalCost, totalRevenue, margin, budgetUsed, budgetStatus, costs: projectCosts, revenues: projectRevenues, categoryTotals, } }) }, [projects, costs, revenues]) const activeProjects = projectStats.filter(p => p.status === 'active') const completedProjects = projectStats.filter(p => p.status === 'completed') const totalRevenue = projectStats.reduce((s, p) => s + p.totalRevenue, 0) const totalCosts = projectStats.reduce((s, p) => s + p.totalCost, 0) const avgMargin = totalRevenue > 0 ? Math.round(((totalRevenue - totalCosts) / totalRevenue) * 100) : 0 // --- Handlers --- const handleAddProject = async () => { if (!newProjectName.trim()) return const id = crypto.randomUUID() await save(`project:${id}`, { name: newProjectName.trim(), budget: Math.round((parseFloat(newProjectBudget) || 0) * 100) / 100, status: 'active', startDate: new Date().toISOString().slice(0, 10), }) setNewProjectName('') setNewProjectBudget('') setShowNewProject(false) await refresh() } const handleAddCost = async (projectId: string) => { const amt = parseFloat(costAmount) if (isNaN(amt) || amt <= 0) return setIsSubmitting(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('') await refresh() setIsSubmitting(false) } const handleAddRevenue = async (projectId: string) => { const amt = parseFloat(revAmount) if (isNaN(amt) || amt <= 0) return setIsSubmitting(true) const id = crypto.randomUUID() await save(`revenue:${id}`, { projectId, description: revDesc, amount: Math.round(amt * 100) / 100, date: new Date().toISOString().slice(0, 10), }) setRevDesc('') setRevAmount('') await refresh() setIsSubmitting(false) } const handleDelete = async () => { if (!deleteTarget) return setIsDeleting(true) if (deleteTarget.type === 'project') { // Delete all costs and revenues for the project, then the project itself const projectCosts = allCosts.filter(c => c.projectId === deleteTarget.id) const projectRevenues = allRevenues.filter(r => r.projectId === deleteTarget.id) for (const c of projectCosts) { await remove(`cost:${c.id}`) } for (const r of projectRevenues) { await remove(`revenue:${r.id}`) } await remove(`project:${deleteTarget.id}`) } else if (deleteTarget.type === 'cost') { await remove(`cost:${deleteTarget.id}`) } else { await remove(`revenue:${deleteTarget.id}`) } await refresh() setIsDeleting(false) setDeleteTarget(null) } const handleSaveEditEntry = async () => { if (!editEntry) return const amt = parseFloat(editEntry.amount) if (isNaN(amt) || amt <= 0) return setIsSavingEdit(true) if (editEntry.type === 'cost') { await save(`cost:${editEntry.id}`, { projectId: editEntry.projectId, description: editEntry.description, amount: Math.round(amt * 100) / 100, date: editEntry.date, category: editEntry.category || COST_CATEGORIES[0], }) } else { await save(`revenue:${editEntry.id}`, { projectId: editEntry.projectId, description: editEntry.description, amount: Math.round(amt * 100) / 100, date: editEntry.date, }) } await refresh() setIsSavingEdit(false) setEditEntry(null) } const handleSaveEditProject = async () => { if (!editProject) return const project = projects.find(p => p.id === editProject.id) if (!project) return setIsSavingProject(true) await save(`project:${editProject.id}`, { name: editProject.name.trim(), budget: Math.round((parseFloat(editProject.budget) || 0) * 100) / 100, status: project.status, startDate: project.startDate, }) await refresh() setIsSavingProject(false) setEditProject(null) } const handleCompleteProject = async () => { if (!completeProjectId) return const project = projects.find(p => p.id === completeProjectId) if (!project) return await save(`project:${completeProjectId}`, { name: project.name, budget: project.budget, status: 'completed' as const, startDate: project.startDate, }) await refresh() setCompleteProjectId(null) } if (isLoading) return // --- Render helper for budget alert banner --- const renderBudgetAlert = (p: (typeof projectStats)[number]) => { if (p.budget <= 0) return null if (p.budgetStatus === 'danger') { return (
Kostnaden overskrider budgeten ({p.budgetUsed}% anvant)
) } if (p.budgetStatus === 'warning') { return (
Budgetvarning: {p.budgetUsed}% av budgeten anvand
) } return null } // --- Render helper for category breakdown --- const renderCategoryBreakdown = (categoryTotals: { category: string; total: number; pct: number }[]) => { if (categoryTotals.length === 0) return null return (

Kostnadsfordelning per kategori

Kategori Belopp Andel {categoryTotals.map(ct => ( {ct.category} {ct.total.toLocaleString('sv-SE')} kr {ct.pct}% ))}
) } // --- Render project card for the Projects tab --- const renderProjectCard = (p: (typeof projectStats)[number]) => { const isExpanded = expandedProject === p.id return (
setExpandedProject(isExpanded ? null : p.id)} > {p.name} {p.status === 'active' ? 'Aktiv' : 'Avslutad'}
setExpandedProject(isExpanded ? null : p.id)} > {isExpanded ? : }
{isExpanded && ( {/* Budget alert banner */} {renderBudgetAlert(p)} {/* Stats row */}

Kostnad

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

Intakt

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

Marginal

{p.margin}%

{/* Budget progress */} {p.budget > 0 && (
Budget anvand {p.budgetUsed}% av {p.budget.toLocaleString('sv-SE')} kr
)} {/* Category breakdown */} {renderCategoryBreakdown(p.categoryTotals)} {/* Cost entries */}

Kostnader

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

Intakter

setRevDesc(e.target.value)} className="max-w-xs" /> setRevAmount(e.target.value)} className="w-28" />
{p.revenues.length > 0 && (
Datum Beskrivning Belopp {p.revenues.map(r => ( {r.date} {r.description} {r.amount.toLocaleString('sv-SE')} kr
))}
)}
{/* Complete project button (active only) */} {p.status === 'active' && (
)}
)}
) } return (
Oversikt Projekt setDateRange({ start, end })} />
{/* Active projects */} {activeProjects.length > 0 && (

Aktiva projekt

{activeProjects.map(p => ( {/* Budget alert */} {renderBudgetAlert(p)}

{p.name}

Aktiv
Marginal: {p.margin}%
Kostnad: {p.totalCost.toLocaleString('sv-SE')} kr Intakt: {p.totalRevenue.toLocaleString('sv-SE')} kr {p.budget > 0 && Budget: {p.budget.toLocaleString('sv-SE')} kr}
{p.budget > 0 && ( )}
))}
)} {/* Completed projects */} {completedProjects.length > 0 && (

Avslutade projekt

{completedProjects.map(p => (

{p.name}

Avslutad

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

Kostnad: {p.totalCost.toLocaleString('sv-SE')} kr Intakt: {p.totalRevenue.toLocaleString('sv-SE')} kr Resultat: {(Math.round((p.totalRevenue - p.totalCost) * 100) / 100).toLocaleString('sv-SE')} kr
))}
)}
{!showNewProject ? ( ) : (
setNewProjectName(e.target.value)} className="max-w-xs" /> setNewProjectBudget(e.target.value)} className="max-w-xs" />
)} {activeProjects.length > 0 && (

Aktiva projekt

{activeProjects.map(p => renderProjectCard(p))}
)} {completedProjects.length > 0 && (

Avslutade projekt

{completedProjects.map(p => renderProjectCard(p))}
)}
{/* Delete confirmation dialog */} { if (!open) setDeleteTarget(null) }} title={ deleteTarget?.type === 'project' ? 'Ta bort projekt' : deleteTarget?.type === 'cost' ? 'Ta bort kostnad' : 'Ta bort intakt' } description={ deleteTarget?.type === 'project' ? `Vill du ta bort projektet "${deleteTarget?.label}"? Alla kostnader och intakter kopplade till projektet tas ocksa bort. Atgarden kan inte angras.` : `Vill du ta bort "${deleteTarget?.label}"? Atgarden kan inte angras.` } onConfirm={handleDelete} isDeleting={isDeleting} /> {/* Edit cost/revenue entry dialog */} { if (!open) setEditEntry(null) }} title={editEntry?.type === 'cost' ? 'Redigera kostnad' : 'Redigera intakt'} description="Andra uppgifterna och klicka Spara." onSave={handleSaveEditEntry} isSaving={isSavingEdit} > {editEntry && (
setEditEntry({ ...editEntry, description: e.target.value })} />
setEditEntry({ ...editEntry, amount: e.target.value })} />
setEditEntry({ ...editEntry, date: e.target.value })} />
{editEntry.type === 'cost' && (
)}
)}
{/* Edit project dialog */} { if (!open) setEditProject(null) }} title="Redigera projekt" description="Andra projektnamn och budget." onSave={handleSaveEditProject} isSaving={isSavingProject} > {editProject && (
setEditProject({ ...editProject, name: e.target.value })} />
setEditProject({ ...editProject, budget: e.target.value })} />
)}
{/* Complete project confirmation dialog */} { if (!open) setCompleteProjectId(null) }} title="Avsluta projekt" description={`Vill du markera projektet som avslutat? Projektet flyttas till "Avslutade" och kan inte ateraktiveras.`} onConfirm={handleCompleteProject} />
) }