'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 DataEntryForm from '@/components/extensions/shared/DataEntryForm' import ExtensionLoadingSkeleton from '@/components/extensions/shared/ExtensionLoadingSkeleton' import ConfirmDeleteDialog from '@/components/extensions/shared/ConfirmDeleteDialog' import EditEntryDialog from '@/components/extensions/shared/EditEntryDialog' import { validateSwedishPersonalNumber } from '@/lib/extensions/validation' 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '@/components/ui/table' import { Card, CardContent } from '@/components/ui/card' import { Pencil, Trash2, Plus, Download, Check } from 'lucide-react' const MAX_ROT_YEARLY = 50000 const ROT_RATE = 0.30 interface Job { id: string customerId: string customerName: string description: string total: number material: number labor: number rotDeduction: number date: string status: 'draft' | 'completed' } interface Customer { id: string name: string personalNumber: string } function buildYearOptions(): number[] { const current = new Date().getFullYear() const years: number[] = [] for (let y = current; y >= current - 5; y--) { years.push(y) } return years } export default function RotCalculatorWorkspace({}: WorkspaceComponentProps) { const { data, save, remove, refresh, isLoading } = useExtensionData('construction', 'rot-calculator') const customers = useMemo(() => data.filter(d => d.key.startsWith('customer:')) .map(d => ({ id: d.key.replace('customer:', ''), ...(d.value as { name: string; personalNumber: string }), })) , [data]) const allJobs = useMemo(() => data.filter(d => d.key.startsWith('job:')) .map(d => ({ id: d.key.replace('job:', ''), ...(d.value as Omit) })) .sort((a, b) => b.date.localeCompare(a.date)) , [data]) // Year filter const currentYear = new Date().getFullYear() const [selectedYear, setSelectedYear] = useState(String(currentYear)) const yearOptions = useMemo(() => buildYearOptions(), []) const jobs = useMemo(() => allJobs.filter(j => j.date.startsWith(selectedYear)) , [allJobs, selectedYear]) // Calculator form const [selectedCustomerId, setCustomerId] = useState('') const customerId = selectedCustomerId || (customers.length > 0 ? customers[0].id : '') const [description, setDescription] = useState('') const [total, setTotal] = useState('') const [material, setMaterial] = useState('') const [isSubmitting, setIsSubmitting] = useState(false) // New customer form const [newCustomerName, setNewCustomerName] = useState('') const [newCustomerPnr, setNewCustomerPnr] = useState('') const newCustomerPnrError = useMemo(() => { if (!newCustomerPnr.trim()) return null return validateSwedishPersonalNumber(newCustomerPnr.trim()) }, [newCustomerPnr]) const canAddCustomer = newCustomerName.trim().length > 0 && !newCustomerPnrError // Edit customer dialog const [editingCustomer, setEditingCustomer] = useState(null) const [editCustomerName, setEditCustomerName] = useState('') const [editCustomerPnr, setEditCustomerPnr] = useState('') const [isSavingCustomer, setIsSavingCustomer] = useState(false) const editCustomerPnrError = useMemo(() => { if (!editCustomerPnr.trim()) return null return validateSwedishPersonalNumber(editCustomerPnr.trim()) }, [editCustomerPnr]) // Edit job dialog const [editingJob, setEditingJob] = useState(null) const [editJobCustomerId, setEditJobCustomerId] = useState('') const [editJobDescription, setEditJobDescription] = useState('') const [editJobTotal, setEditJobTotal] = useState('') const [editJobMaterial, setEditJobMaterial] = useState('') const [isSavingJob, setIsSavingJob] = useState(false) // Delete job dialog const [deletingJobId, setDeletingJobId] = useState(null) const [isDeletingJob, setIsDeletingJob] = useState(false) // Per-customer used quota for selected year (only completed jobs count) const customerYearlyUsed = useMemo(() => { const map = new Map() for (const job of jobs) { if (job.status === 'completed') { map.set(job.customerId, (map.get(job.customerId) ?? 0) + job.rotDeduction) } } return map }, [jobs]) // Calculate ROT for current form input const totalNum = parseFloat(total) || 0 const materialNum = parseFloat(material) || 0 const labor = Math.max(totalNum - materialNum, 0) const usedQuota = customerYearlyUsed.get(customerId) ?? 0 const remainingQuota = Math.max(MAX_ROT_YEARLY - usedQuota, 0) const rotDeduction = Math.round(Math.min(labor * ROT_RATE, remainingQuota) * 100) / 100 const customerPays = Math.round((totalNum - rotDeduction) * 100) / 100 // Calculate ROT deduction respecting quota for a specific customer const calculateRotDeduction = useCallback((custId: string, laborAmount: number, excludeJobId?: string) => { let used = 0 for (const job of allJobs) { if ( job.customerId === custId && job.status === 'completed' && job.date.startsWith(selectedYear) && job.id !== excludeJobId ) { used += job.rotDeduction } } const remaining = Math.max(MAX_ROT_YEARLY - used, 0) return Math.round(Math.min(laborAmount * ROT_RATE, remaining) * 100) / 100 }, [allJobs, selectedYear]) const handleSubmitJob = async (e: React.FormEvent) => { e.preventDefault() if (!customerId || totalNum <= 0) return setIsSubmitting(true) const customer = customers.find(c => c.id === customerId) const id = crypto.randomUUID() await save(`job:${id}`, { customerId, customerName: customer?.name ?? '', description, total: totalNum, material: materialNum, labor, rotDeduction, date: new Date().toISOString().slice(0, 10), status: 'draft', }) setDescription('') setTotal('') setMaterial('') await refresh() setIsSubmitting(false) } const handleAddCustomer = async () => { if (!canAddCustomer) return const id = crypto.randomUUID() await save(`customer:${id}`, { name: newCustomerName.trim(), personalNumber: newCustomerPnr.trim() }) setNewCustomerName('') setNewCustomerPnr('') await refresh() } const openEditCustomer = (cust: Customer) => { setEditingCustomer(cust) setEditCustomerName(cust.name) setEditCustomerPnr(cust.personalNumber) } const handleSaveCustomer = async () => { if (!editingCustomer || !editCustomerName.trim() || editCustomerPnrError) return setIsSavingCustomer(true) await save(`customer:${editingCustomer.id}`, { name: editCustomerName.trim(), personalNumber: editCustomerPnr.trim(), }) // Update customerName on all jobs belonging to this customer const customerJobs = allJobs.filter(j => j.customerId === editingCustomer.id) for (const job of customerJobs) { await save(`job:${job.id}`, { customerId: job.customerId, customerName: editCustomerName.trim(), description: job.description, total: job.total, material: job.material, labor: job.labor, rotDeduction: job.rotDeduction, date: job.date, status: job.status, }) } await refresh() setIsSavingCustomer(false) } const openEditJob = (job: Job) => { setEditingJob(job) setEditJobCustomerId(job.customerId) setEditJobDescription(job.description) setEditJobTotal(String(job.total)) setEditJobMaterial(String(job.material)) } const handleSaveJob = async () => { if (!editingJob) return const editTotalNum = parseFloat(editJobTotal) || 0 const editMaterialNum = parseFloat(editJobMaterial) || 0 if (editTotalNum <= 0) return setIsSavingJob(true) const editLabor = Math.max(editTotalNum - editMaterialNum, 0) const newRot = calculateRotDeduction(editJobCustomerId, editLabor, editingJob.id) const customer = customers.find(c => c.id === editJobCustomerId) await save(`job:${editingJob.id}`, { customerId: editJobCustomerId, customerName: customer?.name ?? editingJob.customerName, description: editJobDescription, total: editTotalNum, material: editMaterialNum, labor: editLabor, rotDeduction: newRot, date: editingJob.date, status: editingJob.status, }) await refresh() setIsSavingJob(false) } const handleDeleteJob = async () => { if (!deletingJobId) return setIsDeletingJob(true) await remove(`job:${deletingJobId}`) await refresh() setIsDeletingJob(false) setDeletingJobId(null) } const handleMarkCompleted = async (job: Job) => { const rot = calculateRotDeduction(job.customerId, job.labor, job.id) await save(`job:${job.id}`, { customerId: job.customerId, customerName: job.customerName, description: job.description, total: job.total, material: job.material, labor: job.labor, rotDeduction: rot, date: job.date, status: 'completed', }) await refresh() } const handleExportCsv = () => { const completedJobs = jobs.filter(j => j.status === 'completed') const header = 'Personnummer;Kundnamn;Arbetskostnad;ROTAvdrag;Datum' const rows = completedJobs.map(job => { const cust = customers.find(c => c.id === job.customerId) const pnr = cust?.personalNumber ?? '' return `${pnr};${job.customerName};${job.labor};${job.rotDeduction};${job.date}` }) const csv = [header, ...rows].join('\n') const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' }) const url = URL.createObjectURL(blob) const link = document.createElement('a') link.href = url link.download = `rot-avdrag-${selectedYear}.csv` link.click() URL.revokeObjectURL(url) } if (isLoading) return const completedJobCount = jobs.filter(j => j.status === 'completed').length const totalRot = jobs.filter(j => j.status === 'completed').reduce((s, j) => s + j.rotDeduction, 0) const totalRevenue = jobs.reduce((s, j) => s + j.total, 0) return (
Kalkylator Kunder Jobb
{customers.length === 0 ? (

Lagg till kunder under fliken "Kunder" for att borja berakna ROT-avdrag.

) : ( <>
setDescription(e.target.value)} />
setTotal(e.target.value)} />
setMaterial(e.target.value)} />
{totalNum > 0 && (
Arbetskostnad: {labor.toLocaleString('sv-SE')} kr ROT-avdrag (30%): -{rotDeduction.toLocaleString('sv-SE')} kr Kunden betalar: {customerPays.toLocaleString('sv-SE')} kr Kvarvarande kvot: {Math.max(remainingQuota - rotDeduction, 0).toLocaleString('sv-SE')} kr
)}
)}
setNewCustomerName(e.target.value)} className="max-w-xs" />
setNewCustomerPnr(e.target.value)} className="max-w-xs" /> {newCustomerPnrError && (

{newCustomerPnrError}

)}
{customers.length === 0 ? (

Inga kunder tillagda annu.

) : (
{customers.map(cust => { const used = customerYearlyUsed.get(cust.id) ?? 0 const pct = Math.min(Math.round((used / MAX_ROT_YEARLY) * 100), 100) return (

{cust.name}

{cust.personalNumber && (

{cust.personalNumber}

)}
{used.toLocaleString('sv-SE')} / {MAX_ROT_YEARLY.toLocaleString('sv-SE')} kr
) })}
)}
{completedJobCount > 0 && ( )}
{jobs.length === 0 ? (

Inga jobb registrerade for {selectedYear}.

) : (
Datum Kund Beskrivning Totalt ROT-avdrag Status {jobs.map(job => ( {job.date} {job.customerName} {job.description} {job.total.toLocaleString('sv-SE')} kr {job.rotDeduction.toLocaleString('sv-SE')} kr {job.status === 'completed' ? 'Klar' : 'Utkast'}
{job.status === 'draft' && ( )}
))}
)}
{/* Edit Customer Dialog */} { if (!open) setEditingCustomer(null) }} title="Redigera kund" description="Uppdatera kunduppgifter." onSave={handleSaveCustomer} isSaving={isSavingCustomer} >
setEditCustomerName(e.target.value)} />
setEditCustomerPnr(e.target.value)} /> {editCustomerPnrError && (

{editCustomerPnrError}

)}
{/* Edit Job Dialog */} { if (!open) setEditingJob(null) }} title="Redigera jobb" description="Uppdatera jobbdetaljer. ROT-avdrag beraknas om automatiskt." onSave={handleSaveJob} isSaving={isSavingJob} >
setEditJobDescription(e.target.value)} />
setEditJobTotal(e.target.value)} />
setEditJobMaterial(e.target.value)} />
{(() => { const editTotalNum = parseFloat(editJobTotal) || 0 const editMaterialNum = parseFloat(editJobMaterial) || 0 const editLabor = Math.max(editTotalNum - editMaterialNum, 0) const editRot = editingJob ? calculateRotDeduction(editJobCustomerId, editLabor, editingJob.id) : 0 return editTotalNum > 0 ? (
Arbetskostnad: {editLabor.toLocaleString('sv-SE')} kr
ROT-avdrag (30%): -{editRot.toLocaleString('sv-SE')} kr
) : null })()}
{/* Delete Job Confirmation */} { if (!open) setDeletingJobId(null) }} title="Ta bort jobb" description="Ar du saker pa att du vill ta bort detta jobb? Kundens anvanda kvot minskar om jobbet var slutfort." onConfirm={handleDeleteJob} isDeleting={isDeletingJob} />
) }