'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 DateRangeFilter from '@/components/extensions/shared/DateRangeFilter' import SetupPrompt from '@/components/extensions/shared/SetupPrompt' 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 { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Button } from '@/components/ui/button' 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, DialogDescription, DialogFooter, } from '@/components/ui/dialog' import { Pencil, Plus, Trash2, ArrowUp, ArrowDown, Minus, TrendingUp } from 'lucide-react' import { cn } from '@/lib/utils' interface Channel { name: string color: string } interface RevenueEntry { id: string month: string channel: string revenue: number orderCount: number } const DEFAULT_COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899'] const COLOR_PRESETS = [ '#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#06b6d4', '#84cc16', ] type SortMode = 'revenue' | 'growth' function formatCurrency(value: number): string { return Math.round(value * 100) / 100 === 0 ? '0' : (Math.round(value * 100) / 100).toLocaleString('sv-SE') } function formatAOV(revenue: number, orders: number): string { if (orders <= 0) return '-' return Math.round(revenue / orders).toLocaleString('sv-SE') } function GrowthIndicator({ current, previous }: { current: number; previous: number }) { if (previous === 0 && current === 0) { return ( 0% ) } if (previous === 0) { return ( Ny ) } const pctChange = Math.round(((current - previous) / previous) * 1000) / 10 if (pctChange === 0) { return ( 0% ) } const improving = pctChange > 0 return ( {improving ? : } {pctChange > 0 ? '+' : ''}{pctChange}% ) } export default function MultichannelRevenueWorkspace({}: WorkspaceComponentProps) { const now = new Date() const [dateRange, setDateRange] = useState({ start: new Date(now.getFullYear(), 0, 1).toISOString().slice(0, 10), end: new Date(now.getFullYear(), 11, 31).toISOString().slice(0, 10), }) const { data, save, remove, refresh, isLoading } = useExtensionData('ecommerce', 'multichannel-revenue') const channels = useMemo(() => { const s = data.find(d => d.key === 'settings')?.value as { channels?: Channel[] } | undefined return s?.channels ?? [] }, [data]) // All entries (unfiltered by date, needed for previous period comparison) const allEntries = useMemo(() => data.filter(d => d.key.startsWith('entry:')) .map(d => ({ id: d.key.replace('entry:', ''), ...(d.value as Omit), })) , [data]) // Entries filtered to current date range const entries = useMemo(() => allEntries .filter(e => { const eStart = e.month + '-01' const eEnd = e.month + '-31' return eEnd >= dateRange.start && eStart <= dateRange.end }) .sort((a, b) => b.month.localeCompare(a.month)) , [allEntries, dateRange]) // Previous year entries for the same period const prevYearEntries = useMemo(() => { const startDate = new Date(dateRange.start + 'T00:00:00') const endDate = new Date(dateRange.end + 'T00:00:00') const prevStart = new Date(startDate) prevStart.setFullYear(prevStart.getFullYear() - 1) const prevEnd = new Date(endDate) prevEnd.setFullYear(prevEnd.getFullYear() - 1) const prevStartStr = prevStart.toISOString().slice(0, 10) const prevEndStr = prevEnd.toISOString().slice(0, 10) return allEntries.filter(e => { const eStart = e.month + '-01' const eEnd = e.month + '-31' return eEnd >= prevStartStr && eStart <= prevEndStr }) }, [allEntries, dateRange]) // Form state const [entryMonth, setEntryMonth] = useState(now.toISOString().slice(0, 7)) const [selectedChannel, setEntryChannel] = useState('') const entryChannel = selectedChannel || (channels.length > 0 ? channels[0].name : '') const [entryRevenue, setEntryRevenue] = useState('') const [entryOrders, setEntryOrders] = useState('') const [isSubmitting, setIsSubmitting] = useState(false) // Channel management const [newChannelName, setNewChannelName] = useState('') const [sortMode, setSortMode] = useState('revenue') // Duplicate confirmation dialog state const [duplicateDialogOpen, setDuplicateDialogOpen] = useState(false) const [pendingEntry, setPendingEntry] = useState<{ month: string; channel: string; revenue: number; orderCount: number; existingId: string } | null>(null) // Edit entry dialog state const [editDialogOpen, setEditDialogOpen] = useState(false) const [editingEntry, setEditingEntry] = useState(null) const [editMonth, setEditMonth] = useState('') const [editChannel, setEditChannel] = useState('') const [editRevenue, setEditRevenue] = useState('') const [editOrders, setEditOrders] = useState('') const [isSavingEdit, setIsSavingEdit] = useState(false) // Delete entry dialog state const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) const [deletingEntryId, setDeletingEntryId] = useState(null) const [isDeleting, setIsDeleting] = useState(false) // Rename channel dialog state const [renameDialogOpen, setRenameDialogOpen] = useState(false) const [renamingChannel, setRenamingChannel] = useState(null) const [newName, setNewName] = useState('') const [isSavingRename, setIsSavingRename] = useState(false) // Color picker state const [colorPickerChannel, setColorPickerChannel] = useState(null) // ---- Computed values ---- const totalRevenue = entries.reduce((s, e) => s + e.revenue, 0) const totalOrders = entries.reduce((s, e) => s + e.orderCount, 0) const overallAOV = totalOrders > 0 ? Math.round(totalRevenue / totalOrders) : 0 const prevYearTotalRevenue = prevYearEntries.reduce((s, e) => s + e.revenue, 0) // Channel totals for current period const channelTotals = useMemo(() => { const map = new Map() for (const e of entries) { const existing = map.get(e.channel) ?? { revenue: 0, orders: 0 } existing.revenue += e.revenue existing.orders += e.orderCount map.set(e.channel, existing) } return Array.from(map.entries()) .map(([channel, d]) => ({ channel, ...d })) }, [entries]) // Channel totals for previous year period const prevYearChannelTotals = useMemo(() => { const map = new Map() for (const e of prevYearEntries) { const existing = map.get(e.channel) ?? { revenue: 0, orders: 0 } existing.revenue += e.revenue existing.orders += e.orderCount map.set(e.channel, existing) } return map }, [prevYearEntries]) // Growth rate per channel const channelGrowth = useMemo(() => { const growth = new Map() for (const ct of channelTotals) { const prev = prevYearChannelTotals.get(ct.channel) const prevRev = prev?.revenue ?? 0 if (prevRev > 0) { growth.set(ct.channel, ((ct.revenue - prevRev) / prevRev) * 100) } else if (ct.revenue > 0) { growth.set(ct.channel, Infinity) // New channel } else { growth.set(ct.channel, 0) } } return growth }, [channelTotals, prevYearChannelTotals]) // Sorted channel totals based on sort mode const sortedChannelTotals = useMemo(() => { const sorted = [...channelTotals] if (sortMode === 'growth') { sorted.sort((a, b) => { const growthA = channelGrowth.get(a.channel) ?? 0 const growthB = channelGrowth.get(b.channel) ?? 0 // Infinity (new channels) goes to the top if (growthA === Infinity && growthB !== Infinity) return -1 if (growthB === Infinity && growthA !== Infinity) return 1 return growthB - growthA }) } else { sorted.sort((a, b) => b.revenue - a.revenue) } return sorted }, [channelTotals, sortMode, channelGrowth]) const bestChannel = useMemo(() => { const sorted = [...channelTotals].sort((a, b) => b.revenue - a.revenue) return sorted[0]?.channel ?? '-' }, [channelTotals]) // Monthly comparison (months as rows, channels as columns) const monthlyComparison = useMemo(() => { const monthMap = new Map>() for (const e of entries) { if (!monthMap.has(e.month)) monthMap.set(e.month, new Map()) const channelMap = monthMap.get(e.month)! const existing = channelMap.get(e.channel) ?? { revenue: 0, orders: 0 } existing.revenue += e.revenue existing.orders += e.orderCount channelMap.set(e.channel, existing) } return Array.from(monthMap.entries()) .sort(([a], [b]) => a.localeCompare(b)) .map(([month, channelData]) => ({ month, channels: Object.fromEntries( Array.from(channelData.entries()).map(([ch, d]) => [ch, d]) ) as Record, total: Array.from(channelData.values()).reduce((s, v) => s + v.revenue, 0), totalOrders: Array.from(channelData.values()).reduce((s, v) => s + v.orders, 0), })) }, [entries]) // Channel bar chart (CSS-based) const maxChannelRevenue = Math.max(...sortedChannelTotals.map(c => c.revenue), 1) // ---- Handlers ---- const findDuplicateEntry = useCallback((month: string, channel: string) => { return allEntries.find(e => e.month === month && e.channel === channel) ?? null }, [allEntries]) const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() const rev = parseFloat(entryRevenue) const orders = parseInt(entryOrders) || 0 if (isNaN(rev) || rev <= 0 || !entryChannel) return // Check for duplicate const existing = findDuplicateEntry(entryMonth, entryChannel) if (existing) { setPendingEntry({ month: entryMonth, channel: entryChannel, revenue: rev, orderCount: orders, existingId: existing.id, }) setDuplicateDialogOpen(true) return } setIsSubmitting(true) const id = crypto.randomUUID() await save(`entry:${id}`, { month: entryMonth, channel: entryChannel, revenue: rev, orderCount: orders, }) setEntryRevenue('') setEntryOrders('') await refresh() setIsSubmitting(false) } const handleDuplicateUpdate = async () => { if (!pendingEntry) return setIsSubmitting(true) await save(`entry:${pendingEntry.existingId}`, { month: pendingEntry.month, channel: pendingEntry.channel, revenue: pendingEntry.revenue, orderCount: pendingEntry.orderCount, }) setEntryRevenue('') setEntryOrders('') setDuplicateDialogOpen(false) setPendingEntry(null) await refresh() setIsSubmitting(false) } const handleDuplicateCreateNew = async () => { if (!pendingEntry) return setIsSubmitting(true) const id = crypto.randomUUID() await save(`entry:${id}`, { month: pendingEntry.month, channel: pendingEntry.channel, revenue: pendingEntry.revenue, orderCount: pendingEntry.orderCount, }) setEntryRevenue('') setEntryOrders('') setDuplicateDialogOpen(false) setPendingEntry(null) await refresh() setIsSubmitting(false) } const handleAddChannel = async () => { if (!newChannelName.trim()) return const color = DEFAULT_COLORS[channels.length % DEFAULT_COLORS.length] const updated = [...channels, { name: newChannelName.trim(), color }] await save('settings', { channels: updated }) setNewChannelName('') } const handleRemoveChannel = async (name: string) => { const updated = channels.filter(c => c.name !== name) await save('settings', { channels: updated }) } const handleChangeChannelColor = async (channelName: string, color: string) => { const updated = channels.map(c => c.name === channelName ? { ...c, color } : c ) await save('settings', { channels: updated }) setColorPickerChannel(null) } const handleStartRename = (channelName: string) => { setRenamingChannel(channelName) setNewName(channelName) setRenameDialogOpen(true) } const handleRenameChannel = async () => { if (!renamingChannel || !newName.trim() || newName.trim() === renamingChannel) return setIsSavingRename(true) const trimmedName = newName.trim() // Update channel settings const updatedChannels = channels.map(c => c.name === renamingChannel ? { ...c, name: trimmedName } : c ) await save('settings', { channels: updatedChannels }) // Update all entries that reference the old channel name const entriesToUpdate = allEntries.filter(e => e.channel === renamingChannel) for (const entry of entriesToUpdate) { await save(`entry:${entry.id}`, { month: entry.month, channel: trimmedName, revenue: entry.revenue, orderCount: entry.orderCount, }) } setIsSavingRename(false) setRenameDialogOpen(false) setRenamingChannel(null) setNewName('') await refresh() } const handleStartEdit = (entry: RevenueEntry) => { setEditingEntry(entry) setEditMonth(entry.month) setEditChannel(entry.channel) setEditRevenue(String(entry.revenue)) setEditOrders(String(entry.orderCount)) setEditDialogOpen(true) } const handleSaveEdit = async () => { if (!editingEntry) return const rev = parseFloat(editRevenue) const orders = parseInt(editOrders) || 0 if (isNaN(rev) || rev <= 0 || !editChannel) return setIsSavingEdit(true) await save(`entry:${editingEntry.id}`, { month: editMonth, channel: editChannel, revenue: rev, orderCount: orders, }) setIsSavingEdit(false) setEditDialogOpen(false) setEditingEntry(null) await refresh() } const handleStartDelete = (entryId: string) => { setDeletingEntryId(entryId) setDeleteDialogOpen(true) } const handleConfirmDelete = async () => { if (!deletingEntryId) return setIsDeleting(true) await remove(`entry:${deletingEntryId}`) setIsDeleting(false) setDeleteDialogOpen(false) setDeletingEntryId(null) } const handleSetup = async (values: Record) => { const names = values.channels.split(',').map(n => n.trim()).filter(Boolean) const channelList = names.map((name, i) => ({ name, color: DEFAULT_COLORS[i % DEFAULT_COLORS.length], })) await save('settings', { channels: channelList }) } if (isLoading) return if (channels.length === 0) { return ( ) } return ( setDateRange({ start, end })} /> {/* KPI Cards */} 0 ? { value: Math.round(((totalRevenue - prevYearTotalRevenue) / prevYearTotalRevenue) * 1000) / 10, label: 'mot fg ar', } : undefined} /> 0 ? overallAOV.toLocaleString('sv-SE') : '-'} suffix={overallAOV > 0 ? 'kr' : undefined} /> {/* Channel management */} Kanaler {channels.map(ch => ( {/* Color swatch - clickable for color picker */} setColorPickerChannel( colorPickerChannel === ch.name ? null : ch.name )} title="Byt farg" /> {ch.name} handleStartRename(ch.name)} title="Byt namn" > handleRemoveChannel(ch.name)} title="Ta bort kanal" > {/* Color picker dropdown */} {colorPickerChannel === ch.name && ( {COLOR_PRESETS.map(color => ( handleChangeChannelColor(ch.name, color)} /> ))} )} ))} setNewChannelName(e.target.value)} className="max-w-xs" onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault() handleAddChannel() } }} /> Lagg till {/* Entry form */} Manad setEntryMonth(e.target.value)} /> Kanal {channels.map(c => {c.name})} Intakt (kr) setEntryRevenue(e.target.value)} /> Antal ordrar setEntryOrders(e.target.value)} /> {/* Duplicate confirmation dialog */} Post finns redan Det finns redan en post for {pendingEntry?.channel} i {pendingEntry?.month}. Vill du uppdatera den befintliga posten eller skapa en ny? setDuplicateDialogOpen(false)} disabled={isSubmitting}> Avbryt Skapa ny {isSubmitting ? 'Sparar...' : 'Uppdatera befintlig'} {/* Channel comparison bar chart */} {sortedChannelTotals.length > 0 && ( Kanaljamforelse Sortera: setSortMode('revenue')} > Intakt setSortMode('growth')} > Tillvaxt {sortedChannelTotals.map(ct => { const channelConfig = channels.find(c => c.name === ct.channel) const barWidth = Math.round((ct.revenue / maxChannelRevenue) * 100) const prevData = prevYearChannelTotals.get(ct.channel) const prevRev = prevData?.revenue ?? 0 const aov = ct.orders > 0 ? Math.round(ct.revenue / ct.orders) : 0 return ( {ct.channel} AOV: {aov > 0 ? aov.toLocaleString('sv-SE') + ' kr' : '-'} {formatCurrency(ct.revenue)} kr ) })} )} {/* Entries table with edit/delete */} {entries.length > 0 && ( Registrerade poster Manad Kanal Intakt Ordrar AOV Atgarder {entries.map(entry => { const channelConfig = channels.find(c => c.name === entry.channel) return ( handleStartEdit(entry)} > {entry.month} {entry.channel} {formatCurrency(entry.revenue)} kr {entry.orderCount} {formatAOV(entry.revenue, entry.orderCount)} {entry.orderCount > 0 ? 'kr' : ''} e.stopPropagation()}> handleStartEdit(entry)} title="Redigera" > handleStartDelete(entry.id)} title="Ta bort" > ) })} )} {/* Monthly comparison table */} {monthlyComparison.length > 0 && ( Manadsjamforelse Manad {channels.map(ch => ( {ch.name} ))} Total AOV {monthlyComparison.map(row => ( {row.month} {channels.map(ch => { const chData = row.channels[ch.name] return ( {chData ? formatCurrency(chData.revenue) : '0'} ) })} {formatCurrency(row.total)} {row.totalOrders > 0 ? Math.round(row.total / row.totalOrders).toLocaleString('sv-SE') + ' kr' : '-'} ))} )} {/* Period comparison: previous year */} {(prevYearEntries.length > 0 || channelTotals.length > 0) && ( Arsjamforelse per kanal Kanal Nuvarande period Foregaende ar Tillvaxt AOV (nu) AOV (fg ar) {sortedChannelTotals.map(ct => { const prevData = prevYearChannelTotals.get(ct.channel) const prevRev = prevData?.revenue ?? 0 const prevOrd = prevData?.orders ?? 0 return ( c.name === ct.channel)?.color ?? '#3b82f6' }} /> {ct.channel} {formatCurrency(ct.revenue)} kr {prevRev > 0 ? formatCurrency(prevRev) + ' kr' : '-'} {formatAOV(ct.revenue, ct.orders)} {ct.orders > 0 ? 'kr' : ''} {formatAOV(prevRev, prevOrd)} {prevOrd > 0 ? 'kr' : ''} ) })} {/* Totals row */} Totalt {formatCurrency(totalRevenue)} kr {prevYearTotalRevenue > 0 ? formatCurrency(prevYearTotalRevenue) + ' kr' : '-'} {overallAOV > 0 ? overallAOV.toLocaleString('sv-SE') + ' kr' : '-'} {(() => { const prevTotalOrders = prevYearEntries.reduce((s, e) => s + e.orderCount, 0) return prevTotalOrders > 0 ? Math.round(prevYearTotalRevenue / prevTotalOrders).toLocaleString('sv-SE') + ' kr' : '-' })()} )} {/* Edit entry dialog */} Manad setEditMonth(e.target.value)} /> Kanal {channels.map(c => ( {c.name} ))} Intakt (kr) setEditRevenue(e.target.value)} /> Antal ordrar setEditOrders(e.target.value)} /> {/* Delete confirmation dialog */} {/* Rename channel dialog */} Nytt namn setNewName(e.target.value)} placeholder="Kanalnamn" /> ) }