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>
This commit is contained in:
@@ -1,14 +1,989 @@
|
||||
'use client'
|
||||
|
||||
import { BarChart3 } from 'lucide-react'
|
||||
import EmptyExtensionState from '@/components/extensions/shared/EmptyExtensionState'
|
||||
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'
|
||||
|
||||
export default function MultichannelRevenueWorkspace() {
|
||||
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 (
|
||||
<span className="inline-flex items-center gap-0.5 text-xs text-muted-foreground">
|
||||
<Minus className="h-3 w-3" />
|
||||
<span>0%</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (previous === 0) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-0.5 text-xs text-green-600">
|
||||
<ArrowUp className="h-3 w-3" />
|
||||
<span>Ny</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
const pctChange = Math.round(((current - previous) / previous) * 1000) / 10
|
||||
if (pctChange === 0) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-0.5 text-xs text-muted-foreground">
|
||||
<Minus className="h-3 w-3" />
|
||||
<span>0%</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
const improving = pctChange > 0
|
||||
return (
|
||||
<EmptyExtensionState
|
||||
title="Flerkanalintäkter"
|
||||
description="Uppföljning av intäkter per försäljningskanal kommer snart. Du kommer kunna jämföra prestanda mellan webshop, marknadsplatser och fysisk butik."
|
||||
icon={<BarChart3 className="h-12 w-12 text-muted-foreground/40 mb-4" />}
|
||||
/>
|
||||
<span className={cn(
|
||||
'inline-flex items-center gap-0.5 text-xs',
|
||||
improving ? 'text-green-600' : 'text-red-600'
|
||||
)}>
|
||||
{improving
|
||||
? <ArrowUp className="h-3 w-3" />
|
||||
: <ArrowDown className="h-3 w-3" />
|
||||
}
|
||||
<span>{pctChange > 0 ? '+' : ''}{pctChange}%</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
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<RevenueEntry, 'id'>),
|
||||
}))
|
||||
, [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<SortMode>('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<RevenueEntry | null>(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<string | null>(null)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
|
||||
// Rename channel dialog state
|
||||
const [renameDialogOpen, setRenameDialogOpen] = useState(false)
|
||||
const [renamingChannel, setRenamingChannel] = useState<string | null>(null)
|
||||
const [newName, setNewName] = useState('')
|
||||
const [isSavingRename, setIsSavingRename] = useState(false)
|
||||
|
||||
// Color picker state
|
||||
const [colorPickerChannel, setColorPickerChannel] = useState<string | null>(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<string, { revenue: number; orders: number }>()
|
||||
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<string, { revenue: number; orders: number }>()
|
||||
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<string, number>()
|
||||
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<string, Map<string, { revenue: number; orders: number }>>()
|
||||
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<string, { revenue: number; orders: number }>,
|
||||
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<string, string>) => {
|
||||
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 <ExtensionLoadingSkeleton />
|
||||
|
||||
if (channels.length === 0) {
|
||||
return (
|
||||
<SetupPrompt
|
||||
title="Konfigurera kanaler"
|
||||
description="Ange dina forsaljningskanaler (kommaseparerade, t.ex. Webshop, Amazon, Fysisk butik)."
|
||||
fields={[{ key: 'channels', label: 'Kanaler', type: 'text', placeholder: 'Webshop, Amazon, Fysisk butik' }]}
|
||||
onSave={handleSetup}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<DateRangeFilter onRangeChange={(start, end) => setDateRange({ start, end })} />
|
||||
|
||||
{/* KPI Cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-4 gap-4">
|
||||
<KPICard
|
||||
label="Total intakt"
|
||||
value={formatCurrency(totalRevenue)}
|
||||
suffix="kr"
|
||||
trend={prevYearTotalRevenue > 0 ? {
|
||||
value: Math.round(((totalRevenue - prevYearTotalRevenue) / prevYearTotalRevenue) * 1000) / 10,
|
||||
label: 'mot fg ar',
|
||||
} : undefined}
|
||||
/>
|
||||
<KPICard label="Basta kanal" value={bestChannel} />
|
||||
<KPICard
|
||||
label="Genomsnittligt ordervarde"
|
||||
value={overallAOV > 0 ? overallAOV.toLocaleString('sv-SE') : '-'}
|
||||
suffix={overallAOV > 0 ? 'kr' : undefined}
|
||||
/>
|
||||
<KPICard label="Antal kanaler" value={channels.length} />
|
||||
</div>
|
||||
|
||||
{/* Channel management */}
|
||||
<div className="rounded-xl border p-4">
|
||||
<h3 className="text-sm font-semibold mb-3">Kanaler</h3>
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
{channels.map(ch => (
|
||||
<div key={ch.name} className="relative flex items-center gap-1.5 rounded-md border px-2 py-1 text-sm">
|
||||
{/* Color swatch - clickable for color picker */}
|
||||
<button
|
||||
type="button"
|
||||
className="w-3 h-3 rounded-full border border-black/10 cursor-pointer hover:ring-2 hover:ring-offset-1 hover:ring-primary/30"
|
||||
style={{ backgroundColor: ch.color }}
|
||||
onClick={() => setColorPickerChannel(
|
||||
colorPickerChannel === ch.name ? null : ch.name
|
||||
)}
|
||||
title="Byt farg"
|
||||
/>
|
||||
<span>{ch.name}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-5 w-5 p-0"
|
||||
onClick={() => handleStartRename(ch.name)}
|
||||
title="Byt namn"
|
||||
>
|
||||
<Pencil className="h-3 w-3 text-muted-foreground" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-5 w-5 p-0"
|
||||
onClick={() => handleRemoveChannel(ch.name)}
|
||||
title="Ta bort kanal"
|
||||
>
|
||||
<Trash2 className="h-3 w-3 text-muted-foreground" />
|
||||
</Button>
|
||||
|
||||
{/* Color picker dropdown */}
|
||||
{colorPickerChannel === ch.name && (
|
||||
<div className="absolute top-full left-0 mt-1 z-10 rounded-md border bg-popover p-2 shadow-md">
|
||||
<div className="grid grid-cols-4 gap-1.5">
|
||||
{COLOR_PRESETS.map(color => (
|
||||
<button
|
||||
key={color}
|
||||
type="button"
|
||||
className={cn(
|
||||
'w-6 h-6 rounded-full border-2 cursor-pointer hover:scale-110 transition-transform',
|
||||
ch.color === color ? 'border-foreground' : 'border-transparent'
|
||||
)}
|
||||
style={{ backgroundColor: color }}
|
||||
onClick={() => handleChangeChannelColor(ch.name, color)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Ny kanal"
|
||||
value={newChannelName}
|
||||
onChange={e => setNewChannelName(e.target.value)}
|
||||
className="max-w-xs"
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
handleAddChannel()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button size="sm" variant="outline" onClick={handleAddChannel} disabled={!newChannelName.trim()}>
|
||||
<Plus className="h-4 w-4 mr-1" /> Lagg till
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Entry form */}
|
||||
<DataEntryForm
|
||||
title="Registrera manadsdata"
|
||||
onSubmit={handleSubmit}
|
||||
submitLabel="Registrera"
|
||||
isSubmitting={isSubmitting}
|
||||
>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Manad</Label>
|
||||
<Input type="month" value={entryMonth} onChange={e => setEntryMonth(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Kanal</Label>
|
||||
<Select value={entryChannel} onValueChange={setEntryChannel}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{channels.map(c => <SelectItem key={c.name} value={c.name}>{c.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Intakt (kr)</Label>
|
||||
<Input type="number" min="0" placeholder="0" value={entryRevenue} onChange={e => setEntryRevenue(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Antal ordrar</Label>
|
||||
<Input type="number" min="0" placeholder="0" value={entryOrders} onChange={e => setEntryOrders(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
</DataEntryForm>
|
||||
|
||||
{/* Duplicate confirmation dialog */}
|
||||
<Dialog open={duplicateDialogOpen} onOpenChange={setDuplicateDialogOpen}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Post finns redan</DialogTitle>
|
||||
<DialogDescription>
|
||||
Det finns redan en post for {pendingEntry?.channel} i {pendingEntry?.month}.
|
||||
Vill du uppdatera den befintliga posten eller skapa en ny?
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="flex-col gap-2 sm:flex-row">
|
||||
<Button variant="outline" onClick={() => setDuplicateDialogOpen(false)} disabled={isSubmitting}>
|
||||
Avbryt
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={handleDuplicateCreateNew} disabled={isSubmitting}>
|
||||
Skapa ny
|
||||
</Button>
|
||||
<Button onClick={handleDuplicateUpdate} disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Sparar...' : 'Uppdatera befintlig'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Channel comparison bar chart */}
|
||||
{sortedChannelTotals.length > 0 && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold">Kanaljamforelse</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">Sortera:</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={sortMode === 'revenue' ? 'default' : 'outline'}
|
||||
className="h-7 text-xs px-2"
|
||||
onClick={() => setSortMode('revenue')}
|
||||
>
|
||||
Intakt
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={sortMode === 'growth' ? 'default' : 'outline'}
|
||||
className="h-7 text-xs px-2"
|
||||
onClick={() => setSortMode('growth')}
|
||||
>
|
||||
<TrendingUp className="h-3 w-3 mr-1" />
|
||||
Tillvaxt
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border p-4 space-y-3">
|
||||
{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 (
|
||||
<div key={ct.channel} className="space-y-1">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="font-medium">{ct.channel}</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
AOV: {aov > 0 ? aov.toLocaleString('sv-SE') + ' kr' : '-'}
|
||||
</span>
|
||||
<GrowthIndicator current={ct.revenue} previous={prevRev} />
|
||||
<span className="tabular-nums">{formatCurrency(ct.revenue)} kr</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-6 w-full rounded bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded transition-all"
|
||||
style={{
|
||||
width: `${barWidth}%`,
|
||||
backgroundColor: channelConfig?.color ?? '#3b82f6',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Entries table with edit/delete */}
|
||||
{entries.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-3">Registrerade poster</h3>
|
||||
<div className="rounded-xl border overflow-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Manad</TableHead>
|
||||
<TableHead>Kanal</TableHead>
|
||||
<TableHead className="text-right">Intakt</TableHead>
|
||||
<TableHead className="text-right">Ordrar</TableHead>
|
||||
<TableHead className="text-right">AOV</TableHead>
|
||||
<TableHead className="text-right w-[80px]">Atgarder</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{entries.map(entry => {
|
||||
const channelConfig = channels.find(c => c.name === entry.channel)
|
||||
return (
|
||||
<TableRow
|
||||
key={entry.id}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => handleStartEdit(entry)}
|
||||
>
|
||||
<TableCell className="font-medium">{entry.month}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div
|
||||
className="w-2.5 h-2.5 rounded-full"
|
||||
style={{ backgroundColor: channelConfig?.color ?? '#3b82f6' }}
|
||||
/>
|
||||
{entry.channel}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{formatCurrency(entry.revenue)} kr
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{entry.orderCount}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{formatAOV(entry.revenue, entry.orderCount)} {entry.orderCount > 0 ? 'kr' : ''}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-1" onClick={e => e.stopPropagation()}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={() => handleStartEdit(entry)}
|
||||
title="Redigera"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={() => handleStartDelete(entry.id)}
|
||||
title="Ta bort"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Monthly comparison table */}
|
||||
{monthlyComparison.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-3">Manadsjamforelse</h3>
|
||||
<div className="rounded-xl border overflow-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Manad</TableHead>
|
||||
{channels.map(ch => (
|
||||
<TableHead key={ch.name} className="text-right">{ch.name}</TableHead>
|
||||
))}
|
||||
<TableHead className="text-right font-semibold">Total</TableHead>
|
||||
<TableHead className="text-right">AOV</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{monthlyComparison.map(row => (
|
||||
<TableRow key={row.month}>
|
||||
<TableCell className="font-medium">{row.month}</TableCell>
|
||||
{channels.map(ch => {
|
||||
const chData = row.channels[ch.name]
|
||||
return (
|
||||
<TableCell key={ch.name} className="text-right tabular-nums">
|
||||
{chData ? formatCurrency(chData.revenue) : '0'}
|
||||
</TableCell>
|
||||
)
|
||||
})}
|
||||
<TableCell className="text-right tabular-nums font-semibold">
|
||||
{formatCurrency(row.total)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{row.totalOrders > 0
|
||||
? Math.round(row.total / row.totalOrders).toLocaleString('sv-SE') + ' kr'
|
||||
: '-'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Period comparison: previous year */}
|
||||
{(prevYearEntries.length > 0 || channelTotals.length > 0) && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-3">Arsjamforelse per kanal</h3>
|
||||
<div className="rounded-xl border overflow-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Kanal</TableHead>
|
||||
<TableHead className="text-right">Nuvarande period</TableHead>
|
||||
<TableHead className="text-right">Foregaende ar</TableHead>
|
||||
<TableHead className="text-right">Tillvaxt</TableHead>
|
||||
<TableHead className="text-right">AOV (nu)</TableHead>
|
||||
<TableHead className="text-right">AOV (fg ar)</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sortedChannelTotals.map(ct => {
|
||||
const prevData = prevYearChannelTotals.get(ct.channel)
|
||||
const prevRev = prevData?.revenue ?? 0
|
||||
const prevOrd = prevData?.orders ?? 0
|
||||
return (
|
||||
<TableRow key={ct.channel}>
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div
|
||||
className="w-2.5 h-2.5 rounded-full"
|
||||
style={{ backgroundColor: channels.find(c => c.name === ct.channel)?.color ?? '#3b82f6' }}
|
||||
/>
|
||||
{ct.channel}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{formatCurrency(ct.revenue)} kr
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{prevRev > 0 ? formatCurrency(prevRev) + ' kr' : '-'}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<GrowthIndicator current={ct.revenue} previous={prevRev} />
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{formatAOV(ct.revenue, ct.orders)} {ct.orders > 0 ? 'kr' : ''}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{formatAOV(prevRev, prevOrd)} {prevOrd > 0 ? 'kr' : ''}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
{/* Totals row */}
|
||||
<TableRow className="border-t-2 font-semibold">
|
||||
<TableCell>Totalt</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{formatCurrency(totalRevenue)} kr
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{prevYearTotalRevenue > 0 ? formatCurrency(prevYearTotalRevenue) + ' kr' : '-'}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<GrowthIndicator current={totalRevenue} previous={prevYearTotalRevenue} />
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{overallAOV > 0 ? overallAOV.toLocaleString('sv-SE') + ' kr' : '-'}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{(() => {
|
||||
const prevTotalOrders = prevYearEntries.reduce((s, e) => s + e.orderCount, 0)
|
||||
return prevTotalOrders > 0
|
||||
? Math.round(prevYearTotalRevenue / prevTotalOrders).toLocaleString('sv-SE') + ' kr'
|
||||
: '-'
|
||||
})()}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit entry dialog */}
|
||||
<EditEntryDialog
|
||||
open={editDialogOpen}
|
||||
onOpenChange={setEditDialogOpen}
|
||||
title="Redigera post"
|
||||
description={editingEntry ? `${editingEntry.channel} - ${editingEntry.month}` : undefined}
|
||||
onSave={handleSaveEdit}
|
||||
isSaving={isSavingEdit}
|
||||
>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-month">Manad</Label>
|
||||
<Input
|
||||
id="edit-month"
|
||||
type="month"
|
||||
value={editMonth}
|
||||
onChange={e => setEditMonth(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-channel">Kanal</Label>
|
||||
<Select value={editChannel} onValueChange={setEditChannel}>
|
||||
<SelectTrigger id="edit-channel"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{channels.map(c => (
|
||||
<SelectItem key={c.name} value={c.name}>{c.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-revenue">Intakt (kr)</Label>
|
||||
<Input
|
||||
id="edit-revenue"
|
||||
type="number"
|
||||
min="0"
|
||||
value={editRevenue}
|
||||
onChange={e => setEditRevenue(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-orders">Antal ordrar</Label>
|
||||
<Input
|
||||
id="edit-orders"
|
||||
type="number"
|
||||
min="0"
|
||||
value={editOrders}
|
||||
onChange={e => setEditOrders(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</EditEntryDialog>
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
<ConfirmDeleteDialog
|
||||
open={deleteDialogOpen}
|
||||
onOpenChange={setDeleteDialogOpen}
|
||||
title="Ta bort post"
|
||||
description="Ar du saker pa att du vill ta bort denna intaktspost? Atgarden kan inte angras."
|
||||
onConfirm={handleConfirmDelete}
|
||||
isDeleting={isDeleting}
|
||||
/>
|
||||
|
||||
{/* Rename channel dialog */}
|
||||
<EditEntryDialog
|
||||
open={renameDialogOpen}
|
||||
onOpenChange={setRenameDialogOpen}
|
||||
title="Byt namn pa kanal"
|
||||
description={`Nuvarande namn: ${renamingChannel ?? ''}. Alla registrerade poster uppdateras automatiskt.`}
|
||||
onSave={handleRenameChannel}
|
||||
isSaving={isSavingRename}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="rename-channel">Nytt namn</Label>
|
||||
<Input
|
||||
id="rename-channel"
|
||||
value={newName}
|
||||
onChange={e => setNewName(e.target.value)}
|
||||
placeholder="Kanalnamn"
|
||||
/>
|
||||
</div>
|
||||
</EditEntryDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,14 +1,875 @@
|
||||
'use client'
|
||||
|
||||
import { ShoppingBag } from 'lucide-react'
|
||||
import EmptyExtensionState from '@/components/extensions/shared/EmptyExtensionState'
|
||||
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 CsvImportWizard from '@/components/extensions/shared/CsvImportWizard'
|
||||
import MonthlyTrendTable from '@/components/extensions/shared/MonthlyTrendTable'
|
||||
import ExtensionLoadingSkeleton from '@/components/extensions/shared/ExtensionLoadingSkeleton'
|
||||
import ConfirmDeleteDialog from '@/components/extensions/shared/ConfirmDeleteDialog'
|
||||
import EditEntryDialog from '@/components/extensions/shared/EditEntryDialog'
|
||||
import DataEntryForm from '@/components/extensions/shared/DataEntryForm'
|
||||
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 {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Pencil, Trash2, ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
|
||||
interface ShopifyOrder {
|
||||
id: string
|
||||
name: string
|
||||
createdAt: string
|
||||
total: number
|
||||
subtotal: number
|
||||
shipping: number
|
||||
taxes: number
|
||||
paymentMethod: string
|
||||
fulfillmentStatus: string
|
||||
}
|
||||
|
||||
interface ImportRecord {
|
||||
id: string
|
||||
date: string
|
||||
rowCount: number
|
||||
}
|
||||
|
||||
const TARGET_FIELDS = [
|
||||
{ key: 'name', label: 'Order', required: true },
|
||||
{ key: 'createdAt', label: 'Datum', required: true },
|
||||
{ key: 'total', label: 'Total', required: true },
|
||||
{ key: 'subtotal', label: 'Subtotal' },
|
||||
{ key: 'shipping', label: 'Frakt' },
|
||||
{ key: 'taxes', label: 'Moms' },
|
||||
{ key: 'paymentMethod', label: 'Betalmetod' },
|
||||
{ key: 'fulfillmentStatus', label: 'Leveransstatus' },
|
||||
]
|
||||
|
||||
const DEFAULT_MAPPINGS: Record<string, string> = {
|
||||
name: 'Name',
|
||||
createdAt: 'Created at',
|
||||
total: 'Total',
|
||||
subtotal: 'Subtotal',
|
||||
shipping: 'Shipping',
|
||||
taxes: 'Taxes',
|
||||
paymentMethod: 'Payment Method',
|
||||
fulfillmentStatus: 'Fulfillment Status',
|
||||
}
|
||||
|
||||
const PAGES_SIZE = 20
|
||||
|
||||
export default function ShopifyImportWorkspace({}: WorkspaceComponentProps) {
|
||||
const { data, save, remove, refresh, isLoading } = useExtensionData('ecommerce', 'shopify-import')
|
||||
|
||||
const orders = useMemo(() =>
|
||||
data.filter(d => d.key.startsWith('order:'))
|
||||
.map(d => ({ id: d.key.replace('order:', ''), ...(d.value as Omit<ShopifyOrder, 'id'>) }))
|
||||
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
|
||||
, [data])
|
||||
|
||||
const imports = useMemo(() =>
|
||||
data.filter(d => d.key.startsWith('import:'))
|
||||
.map(d => ({ id: d.key, ...(d.value as Omit<ImportRecord, 'id'>) }))
|
||||
.sort((a, b) => b.date.localeCompare(a.date))
|
||||
, [data])
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Filter state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [dateFrom, setDateFrom] = useState('')
|
||||
const [dateTo, setDateTo] = useState('')
|
||||
const [paymentFilter, setPaymentFilter] = useState('__all__')
|
||||
const [fulfillmentFilter, setFulfillmentFilter] = useState('__all__')
|
||||
|
||||
// Pagination state
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
|
||||
// Edit order dialog state
|
||||
const [editOrder, setEditOrder] = useState<ShopifyOrder | null>(null)
|
||||
const [editName, setEditName] = useState('')
|
||||
const [editDate, setEditDate] = useState('')
|
||||
const [editTotal, setEditTotal] = useState('')
|
||||
const [editSubtotal, setEditSubtotal] = useState('')
|
||||
const [editShipping, setEditShipping] = useState('')
|
||||
const [editTaxes, setEditTaxes] = useState('')
|
||||
const [editPaymentMethod, setEditPaymentMethod] = useState('')
|
||||
const [editFulfillmentStatus, setEditFulfillmentStatus] = useState('')
|
||||
const [isSavingEdit, setIsSavingEdit] = useState(false)
|
||||
|
||||
// Delete order dialog state
|
||||
const [deleteOrderId, setDeleteOrderId] = useState<string | null>(null)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
|
||||
// Manual entry form state
|
||||
const [manualName, setManualName] = useState('')
|
||||
const [manualDate, setManualDate] = useState(new Date().toISOString().slice(0, 10))
|
||||
const [manualTotal, setManualTotal] = useState('')
|
||||
const [manualSubtotal, setManualSubtotal] = useState('')
|
||||
const [manualShipping, setManualShipping] = useState('')
|
||||
const [manualTaxes, setManualTaxes] = useState('')
|
||||
const [manualPaymentMethod, setManualPaymentMethod] = useState('')
|
||||
const [manualFulfillmentStatus, setManualFulfillmentStatus] = useState('')
|
||||
const [isSubmittingManual, setIsSubmittingManual] = useState(false)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Distinct values for filter dropdowns
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const paymentMethods = useMemo(() => {
|
||||
const set = new Set<string>()
|
||||
for (const o of orders) {
|
||||
if (o.paymentMethod) set.add(o.paymentMethod)
|
||||
}
|
||||
return Array.from(set).sort()
|
||||
}, [orders])
|
||||
|
||||
const fulfillmentStatuses = useMemo(() => {
|
||||
const set = new Set<string>()
|
||||
for (const o of orders) {
|
||||
if (o.fulfillmentStatus) set.add(o.fulfillmentStatus)
|
||||
}
|
||||
return Array.from(set).sort()
|
||||
}, [orders])
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Active filter count
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const activeFilterCount = useMemo(() => {
|
||||
let count = 0
|
||||
if (searchQuery.trim()) count++
|
||||
if (dateFrom) count++
|
||||
if (dateTo) count++
|
||||
if (paymentFilter !== '__all__') count++
|
||||
if (fulfillmentFilter !== '__all__') count++
|
||||
return count
|
||||
}, [searchQuery, dateFrom, dateTo, paymentFilter, fulfillmentFilter])
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CSV import handler
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const handleImport = async (rows: Record<string, string>[]) => {
|
||||
const importId = crypto.randomUUID()
|
||||
let count = 0
|
||||
|
||||
for (const row of rows) {
|
||||
const parseNum = (v?: string) => {
|
||||
if (!v) return 0
|
||||
return Math.round(parseFloat(v.replace(/\s/g, '').replace(',', '.')) * 100) / 100 || 0
|
||||
}
|
||||
|
||||
const orderId = crypto.randomUUID()
|
||||
await save(`order:${orderId}`, {
|
||||
name: row.name ?? '',
|
||||
createdAt: row.createdAt ?? new Date().toISOString().slice(0, 10),
|
||||
total: parseNum(row.total),
|
||||
subtotal: parseNum(row.subtotal),
|
||||
shipping: parseNum(row.shipping),
|
||||
taxes: parseNum(row.taxes),
|
||||
paymentMethod: row.paymentMethod ?? '',
|
||||
fulfillmentStatus: row.fulfillmentStatus ?? '',
|
||||
})
|
||||
count++
|
||||
}
|
||||
|
||||
await save(`import:${importId}`, {
|
||||
date: new Date().toISOString().slice(0, 10),
|
||||
rowCount: count,
|
||||
})
|
||||
|
||||
await refresh()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Manual order entry handler
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const handleManualSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!manualName.trim()) return
|
||||
const parseNum = (v: string) => Math.round(parseFloat(v.replace(/\s/g, '').replace(',', '.')) * 100) / 100 || 0
|
||||
|
||||
setIsSubmittingManual(true)
|
||||
const orderId = crypto.randomUUID()
|
||||
await save(`order:${orderId}`, {
|
||||
name: manualName.trim(),
|
||||
createdAt: manualDate || new Date().toISOString().slice(0, 10),
|
||||
total: parseNum(manualTotal),
|
||||
subtotal: parseNum(manualSubtotal),
|
||||
shipping: parseNum(manualShipping),
|
||||
taxes: parseNum(manualTaxes),
|
||||
paymentMethod: manualPaymentMethod,
|
||||
fulfillmentStatus: manualFulfillmentStatus,
|
||||
})
|
||||
|
||||
setManualName('')
|
||||
setManualDate(new Date().toISOString().slice(0, 10))
|
||||
setManualTotal('')
|
||||
setManualSubtotal('')
|
||||
setManualShipping('')
|
||||
setManualTaxes('')
|
||||
setManualPaymentMethod('')
|
||||
setManualFulfillmentStatus('')
|
||||
await refresh()
|
||||
setIsSubmittingManual(false)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Edit order handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const openEditOrder = (order: ShopifyOrder) => {
|
||||
setEditOrder(order)
|
||||
setEditName(order.name)
|
||||
setEditDate(order.createdAt)
|
||||
setEditTotal(String(order.total))
|
||||
setEditSubtotal(String(order.subtotal))
|
||||
setEditShipping(String(order.shipping))
|
||||
setEditTaxes(String(order.taxes))
|
||||
setEditPaymentMethod(order.paymentMethod)
|
||||
setEditFulfillmentStatus(order.fulfillmentStatus)
|
||||
}
|
||||
|
||||
const handleSaveEdit = async () => {
|
||||
if (!editOrder) return
|
||||
const parseNum = (v: string) => Math.round(parseFloat(v.replace(/\s/g, '').replace(',', '.')) * 100) / 100 || 0
|
||||
|
||||
setIsSavingEdit(true)
|
||||
await save(`order:${editOrder.id}`, {
|
||||
name: editName,
|
||||
createdAt: editDate || new Date().toISOString().slice(0, 10),
|
||||
total: parseNum(editTotal),
|
||||
subtotal: parseNum(editSubtotal),
|
||||
shipping: parseNum(editShipping),
|
||||
taxes: parseNum(editTaxes),
|
||||
paymentMethod: editPaymentMethod,
|
||||
fulfillmentStatus: editFulfillmentStatus,
|
||||
})
|
||||
await refresh()
|
||||
setIsSavingEdit(false)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Delete order handler
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
if (!deleteOrderId) return
|
||||
setIsDeleting(true)
|
||||
await remove(`order:${deleteOrderId}`)
|
||||
await refresh()
|
||||
setIsDeleting(false)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stats
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const totalRevenue = orders.reduce((s, o) => s + o.total, 0)
|
||||
const aov = orders.length > 0 ? Math.round(totalRevenue / orders.length) : 0
|
||||
const totalTaxes = orders.reduce((s, o) => s + o.taxes, 0)
|
||||
const totalSubtotal = orders.reduce((s, o) => s + o.subtotal, 0)
|
||||
const avgVatRate = totalSubtotal > 0
|
||||
? Math.round((totalTaxes / totalSubtotal) * 10000) / 100
|
||||
: 0
|
||||
|
||||
// Monthly trend
|
||||
const monthlyTrend = useMemo(() => {
|
||||
const map = new Map<string, { revenue: number; count: number }>()
|
||||
for (const o of orders) {
|
||||
const month = o.createdAt.slice(0, 7)
|
||||
const existing = map.get(month) ?? { revenue: 0, count: 0 }
|
||||
existing.revenue += o.total
|
||||
existing.count++
|
||||
map.set(month, existing)
|
||||
}
|
||||
return Array.from(map.entries())
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([month, data]) => ({ month, value: data.revenue }))
|
||||
}, [orders])
|
||||
|
||||
// Monthly VAT breakdown
|
||||
const monthlyVat = useMemo(() => {
|
||||
const map = new Map<string, { taxes: number; subtotal: number }>()
|
||||
for (const o of orders) {
|
||||
const month = o.createdAt.slice(0, 7)
|
||||
const existing = map.get(month) ?? { taxes: 0, subtotal: 0 }
|
||||
existing.taxes += o.taxes
|
||||
existing.subtotal += o.subtotal
|
||||
map.set(month, existing)
|
||||
}
|
||||
return Array.from(map.entries())
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([month, d]) => ({
|
||||
month,
|
||||
taxes: Math.round(d.taxes * 100) / 100,
|
||||
subtotal: Math.round(d.subtotal * 100) / 100,
|
||||
rate: d.subtotal > 0 ? Math.round((d.taxes / d.subtotal) * 10000) / 100 : 0,
|
||||
}))
|
||||
}, [orders])
|
||||
|
||||
// Payment method breakdown
|
||||
const paymentBreakdown = useMemo(() => {
|
||||
const map = new Map<string, { count: number; total: number }>()
|
||||
for (const o of orders) {
|
||||
const method = o.paymentMethod || 'Okant'
|
||||
const existing = map.get(method) ?? { count: 0, total: 0 }
|
||||
existing.count++
|
||||
existing.total += o.total
|
||||
map.set(method, existing)
|
||||
}
|
||||
return Array.from(map.entries())
|
||||
.map(([method, data]) => ({ method, ...data }))
|
||||
.sort((a, b) => b.total - a.total)
|
||||
}, [orders])
|
||||
|
||||
// Fulfillment breakdown
|
||||
const fulfillmentBreakdown = useMemo(() => {
|
||||
const map = new Map<string, number>()
|
||||
for (const o of orders) {
|
||||
const status = o.fulfillmentStatus || 'Okant'
|
||||
map.set(status, (map.get(status) ?? 0) + 1)
|
||||
}
|
||||
return Array.from(map.entries())
|
||||
.map(([status, count]) => ({ status, count }))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
}, [orders])
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Filtered & paginated orders
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const filteredOrders = useMemo(() => {
|
||||
let result = orders
|
||||
|
||||
if (searchQuery.trim()) {
|
||||
const q = searchQuery.toLowerCase()
|
||||
result = result.filter(o =>
|
||||
o.name.toLowerCase().includes(q) ||
|
||||
o.paymentMethod.toLowerCase().includes(q) ||
|
||||
o.fulfillmentStatus.toLowerCase().includes(q)
|
||||
)
|
||||
}
|
||||
|
||||
if (dateFrom) {
|
||||
result = result.filter(o => o.createdAt >= dateFrom)
|
||||
}
|
||||
|
||||
if (dateTo) {
|
||||
result = result.filter(o => o.createdAt <= dateTo)
|
||||
}
|
||||
|
||||
if (paymentFilter !== '__all__') {
|
||||
result = result.filter(o => o.paymentMethod === paymentFilter)
|
||||
}
|
||||
|
||||
if (fulfillmentFilter !== '__all__') {
|
||||
result = result.filter(o => o.fulfillmentStatus === fulfillmentFilter)
|
||||
}
|
||||
|
||||
return result
|
||||
}, [orders, searchQuery, dateFrom, dateTo, paymentFilter, fulfillmentFilter])
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(filteredOrders.length / PAGES_SIZE))
|
||||
const safePage = Math.min(currentPage, totalPages)
|
||||
const paginatedOrders = filteredOrders.slice(
|
||||
(safePage - 1) * PAGES_SIZE,
|
||||
safePage * PAGES_SIZE
|
||||
)
|
||||
|
||||
// Reset page when filters change
|
||||
const resetPage = () => setCurrentPage(1)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Clear filters
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const clearFilters = () => {
|
||||
setSearchQuery('')
|
||||
setDateFrom('')
|
||||
setDateTo('')
|
||||
setPaymentFilter('__all__')
|
||||
setFulfillmentFilter('__all__')
|
||||
setCurrentPage(1)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
if (isLoading) return <ExtensionLoadingSkeleton />
|
||||
|
||||
export default function ShopifyImportWorkspace() {
|
||||
return (
|
||||
<EmptyExtensionState
|
||||
title="Shopify-import"
|
||||
description="Import av ordrar och transaktioner från Shopify kommer snart. Du kommer kunna synkronisera din Shopify-butik automatiskt."
|
||||
icon={<ShoppingBag className="h-12 w-12 text-muted-foreground/40 mb-4" />}
|
||||
/>
|
||||
<div className="space-y-6">
|
||||
{/* Edit order dialog */}
|
||||
<EditEntryDialog
|
||||
open={editOrder !== null}
|
||||
onOpenChange={open => { if (!open) setEditOrder(null) }}
|
||||
title="Redigera order"
|
||||
description="Andra uppgifterna for denna order."
|
||||
onSave={handleSaveEdit}
|
||||
isSaving={isSavingEdit}
|
||||
>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Order</Label>
|
||||
<Input value={editName} onChange={e => setEditName(e.target.value)} />
|
||||
</div>
|
||||
<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>Total</Label>
|
||||
<Input type="number" step="0.01" min="0" value={editTotal} onChange={e => setEditTotal(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Subtotal</Label>
|
||||
<Input type="number" step="0.01" min="0" value={editSubtotal} onChange={e => setEditSubtotal(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Frakt</Label>
|
||||
<Input type="number" step="0.01" min="0" value={editShipping} onChange={e => setEditShipping(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Moms</Label>
|
||||
<Input type="number" step="0.01" min="0" value={editTaxes} onChange={e => setEditTaxes(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Betalmetod</Label>
|
||||
<Input value={editPaymentMethod} onChange={e => setEditPaymentMethod(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Leveransstatus</Label>
|
||||
<Input value={editFulfillmentStatus} onChange={e => setEditFulfillmentStatus(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
</EditEntryDialog>
|
||||
|
||||
{/* Delete order dialog */}
|
||||
<ConfirmDeleteDialog
|
||||
open={deleteOrderId !== null}
|
||||
onOpenChange={open => { if (!open) setDeleteOrderId(null) }}
|
||||
title="Ta bort order"
|
||||
description="Ar du saker pa att du vill ta bort denna order? Atgarden kan inte angras."
|
||||
onConfirm={handleConfirmDelete}
|
||||
isDeleting={isDeleting}
|
||||
/>
|
||||
|
||||
<Tabs defaultValue="import">
|
||||
<TabsList>
|
||||
<TabsTrigger value="import">Import</TabsTrigger>
|
||||
<TabsTrigger value="orders">Ordrar</TabsTrigger>
|
||||
<TabsTrigger value="stats">Statistik</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
{/* Import tab */}
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
<TabsContent value="import" className="space-y-6 mt-4">
|
||||
<CsvImportWizard
|
||||
targetFields={TARGET_FIELDS}
|
||||
defaultMappings={DEFAULT_MAPPINGS}
|
||||
onImport={handleImport}
|
||||
/>
|
||||
|
||||
{/* Manual order entry */}
|
||||
<DataEntryForm
|
||||
title="Lagg till order manuellt"
|
||||
onSubmit={handleManualSubmit}
|
||||
submitLabel="Lagg till"
|
||||
isSubmitting={isSubmittingManual}
|
||||
>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="manual-name">Order *</Label>
|
||||
<Input
|
||||
id="manual-name"
|
||||
value={manualName}
|
||||
onChange={e => setManualName(e.target.value)}
|
||||
placeholder="t.ex. #1001"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="manual-date">Datum *</Label>
|
||||
<Input
|
||||
id="manual-date"
|
||||
type="date"
|
||||
value={manualDate}
|
||||
onChange={e => setManualDate(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="manual-total">Total</Label>
|
||||
<Input
|
||||
id="manual-total"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
placeholder="0"
|
||||
value={manualTotal}
|
||||
onChange={e => setManualTotal(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="manual-subtotal">Subtotal</Label>
|
||||
<Input
|
||||
id="manual-subtotal"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
placeholder="0"
|
||||
value={manualSubtotal}
|
||||
onChange={e => setManualSubtotal(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="manual-shipping">Frakt</Label>
|
||||
<Input
|
||||
id="manual-shipping"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
placeholder="0"
|
||||
value={manualShipping}
|
||||
onChange={e => setManualShipping(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="manual-taxes">Moms</Label>
|
||||
<Input
|
||||
id="manual-taxes"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
placeholder="0"
|
||||
value={manualTaxes}
|
||||
onChange={e => setManualTaxes(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="manual-payment">Betalmetod</Label>
|
||||
<Input
|
||||
id="manual-payment"
|
||||
value={manualPaymentMethod}
|
||||
onChange={e => setManualPaymentMethod(e.target.value)}
|
||||
placeholder="t.ex. Stripe"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="manual-fulfillment">Leveransstatus</Label>
|
||||
<Input
|
||||
id="manual-fulfillment"
|
||||
value={manualFulfillmentStatus}
|
||||
onChange={e => setManualFulfillmentStatus(e.target.value)}
|
||||
placeholder="t.ex. fulfilled"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</DataEntryForm>
|
||||
|
||||
{imports.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-3">Importhistorik</h3>
|
||||
<div className="rounded-xl border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Datum</TableHead>
|
||||
<TableHead className="text-right">Ordrar</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{imports.map(imp => (
|
||||
<TableRow key={imp.id}>
|
||||
<TableCell>{imp.date}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{imp.rowCount}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
{/* Orders tab */}
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
<TabsContent value="orders" className="space-y-6 mt-4">
|
||||
{/* Filters */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Sok</Label>
|
||||
<Input
|
||||
placeholder="Sok ordrar..."
|
||||
value={searchQuery}
|
||||
onChange={e => { setSearchQuery(e.target.value); resetPage() }}
|
||||
className="w-48"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Fran datum</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={dateFrom}
|
||||
onChange={e => { setDateFrom(e.target.value); resetPage() }}
|
||||
className="w-40"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Till datum</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={dateTo}
|
||||
onChange={e => { setDateTo(e.target.value); resetPage() }}
|
||||
className="w-40"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Betalmetod</Label>
|
||||
<Select value={paymentFilter} onValueChange={v => { setPaymentFilter(v); resetPage() }}>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">Alla</SelectItem>
|
||||
{paymentMethods.map(m => (
|
||||
<SelectItem key={m} value={m}>{m}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Leveransstatus</Label>
|
||||
<Select value={fulfillmentFilter} onValueChange={v => { setFulfillmentFilter(v); resetPage() }}>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">Alla</SelectItem>
|
||||
{fulfillmentStatuses.map(s => (
|
||||
<SelectItem key={s} value={s}>{s}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeFilterCount > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary">{activeFilterCount} aktiva filter</Badge>
|
||||
<Button variant="ghost" size="sm" className="text-xs" onClick={clearFilters}>
|
||||
Rensa filter
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{filteredOrders.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Inga ordrar hittades.</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="rounded-xl border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Order</TableHead>
|
||||
<TableHead>Datum</TableHead>
|
||||
<TableHead className="text-right">Total</TableHead>
|
||||
<TableHead className="text-right">Frakt</TableHead>
|
||||
<TableHead className="text-right">Moms</TableHead>
|
||||
<TableHead>Betalning</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="w-20"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{paginatedOrders.map(o => (
|
||||
<TableRow key={o.id}>
|
||||
<TableCell className="font-medium">{o.name}</TableCell>
|
||||
<TableCell>{o.createdAt}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{o.total.toLocaleString('sv-SE')} kr</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{o.shipping.toLocaleString('sv-SE')} kr</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{o.taxes.toLocaleString('sv-SE')} kr</TableCell>
|
||||
<TableCell>{o.paymentMethod}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={o.fulfillmentStatus === 'fulfilled' ? 'default' : 'secondary'}>
|
||||
{o.fulfillmentStatus || 'Okant'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="sm" onClick={() => openEditOrder(o)}>
|
||||
<Pencil className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setDeleteOrderId(o.id)}>
|
||||
<Trash2 className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{filteredOrders.length} ordrar totalt
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={safePage <= 1}
|
||||
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||
Foregaende
|
||||
</Button>
|
||||
<span className="text-sm tabular-nums">
|
||||
Sida {safePage} av {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={safePage >= totalPages}
|
||||
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
|
||||
>
|
||||
Nasta
|
||||
<ChevronRight className="h-4 w-4 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
{/* Stats tab */}
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
<TabsContent value="stats" className="space-y-6 mt-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<KPICard label="Antal ordrar" value={orders.length} />
|
||||
<KPICard label="Total intakt" value={totalRevenue.toLocaleString('sv-SE')} suffix="kr" />
|
||||
<KPICard label="AOV" value={aov.toLocaleString('sv-SE')} suffix="kr" />
|
||||
</div>
|
||||
|
||||
{/* VAT analytics */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-3">Momsanalys</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-4">
|
||||
<KPICard
|
||||
label="Total moms"
|
||||
value={(Math.round(totalTaxes * 100) / 100).toLocaleString('sv-SE')}
|
||||
suffix="kr"
|
||||
/>
|
||||
<KPICard
|
||||
label="Genomsnittlig momssats"
|
||||
value={avgVatRate}
|
||||
suffix="%"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{monthlyVat.length > 0 && (
|
||||
<div className="rounded-xl border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Manad</TableHead>
|
||||
<TableHead className="text-right">Subtotal</TableHead>
|
||||
<TableHead className="text-right">Moms</TableHead>
|
||||
<TableHead className="text-right">Momssats</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{monthlyVat.map(m => (
|
||||
<TableRow key={m.month}>
|
||||
<TableCell className="font-medium">{m.month}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{m.subtotal.toLocaleString('sv-SE')} kr</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{m.taxes.toLocaleString('sv-SE')} kr</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{m.rate}%</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{monthlyTrend.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-3">Intakt per manad</h3>
|
||||
<MonthlyTrendTable rows={monthlyTrend} valueLabel="Intakt" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{paymentBreakdown.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-3">Per betalmetod</h3>
|
||||
<div className="rounded-xl border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Betalmetod</TableHead>
|
||||
<TableHead className="text-right">Ordrar</TableHead>
|
||||
<TableHead className="text-right">Total</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{paymentBreakdown.map(p => (
|
||||
<TableRow key={p.method}>
|
||||
<TableCell className="font-medium">{p.method}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{p.count}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{p.total.toLocaleString('sv-SE')} kr</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fulfillmentBreakdown.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-3">Per leveransstatus</h3>
|
||||
<div className="rounded-xl border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right">Ordrar</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{fulfillmentBreakdown.map(f => (
|
||||
<TableRow key={f.status}>
|
||||
<TableCell className="font-medium">{f.status}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{f.count}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user