refactor: consolidate extension system to general-only with manifest-driven architecture
- Remove all sector-specific extensions (construction, ecommerce, export, hotel, restaurant, tech) — only general-purpose extensions remain - Move NE-bilaga and SRU export from extensions to core reports (lib/reports/) - Move moms-box-mapping from extensions/export/shared to lib/vat/ - Replace per-extension API routes with catch-all dispatcher (app/api/extensions/ext/[...path]/route.ts) - Add manifest.json for each extension with metadata, env vars, and deps - Add api-routes.ts pattern for extension-defined API endpoints - Add code generation scripts (generate-extension-registry, create-extension) - Add extensions.config.json for opt-in extension loading - Add extensions.schema.json for config validation - Add email service interface with noop default (lib/email/service.ts) - Add CI workflow (core-build.yml) to verify core builds with zero extensions - Add migration 045: expand account_type CHECK for untaxed_reserves - Update CLAUDE.md with comprehensive extension system documentation - Update all report engines and bookkeeping services for new imports - Clean up extensions.schema.json to only list existing extensions Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
39e407644d
commit
03b569d708
@@ -60,7 +60,7 @@ export function useChatStream(options: UseChatStreamOptions = {}): UseChatStream
|
||||
try {
|
||||
abortControllerRef.current = new AbortController()
|
||||
|
||||
const response = await fetch('/api/extensions/ai-chat/stream', {
|
||||
const response = await fetch('/api/extensions/ext/ai-chat/stream', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -175,7 +175,7 @@ export function useChatStream(options: UseChatStreamOptions = {}): UseChatStream
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/extensions/ai-chat/sessions/${id}`)
|
||||
const response = await fetch(`/api/extensions/ext/ai-chat/sessions/${id}`)
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load session')
|
||||
}
|
||||
|
||||
@@ -241,7 +241,6 @@ export default function DashboardContent({ firstName, settings, summary, onboard
|
||||
// Quick action items
|
||||
const quickActions = [
|
||||
{ href: '/invoices/new', icon: Receipt, label: 'Ny faktura', desc: 'Skapa och skicka', accent: true },
|
||||
{ href: '/receipts/scan', icon: Camera, label: 'Skanna kvitto', desc: 'Fotografera & spara' },
|
||||
{ href: '/customers', icon: Users, label: 'Ny kund', desc: 'Lägg till kunduppgifter' },
|
||||
{ href: '/transactions', icon: ArrowLeftRight, label: 'Transaktioner', desc: 'Bokför' },
|
||||
]
|
||||
|
||||
@@ -1,864 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useMemo } from 'react'
|
||||
import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
|
||||
import { useExtensionData } from '@/lib/extensions/use-extension-data'
|
||||
import KPICard from '@/components/extensions/shared/KPICard'
|
||||
import ExtensionLoadingSkeleton from '@/components/extensions/shared/ExtensionLoadingSkeleton'
|
||||
import ConfirmDeleteDialog from '@/components/extensions/shared/ConfirmDeleteDialog'
|
||||
import EditEntryDialog from '@/components/extensions/shared/EditEntryDialog'
|
||||
import DateRangeFilter from '@/components/extensions/shared/DateRangeFilter'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Pencil, Plus, ChevronDown, ChevronUp, Trash2, AlertTriangle, CheckCircle } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface Project {
|
||||
id: string
|
||||
name: string
|
||||
budget: number
|
||||
status: 'active' | 'completed'
|
||||
startDate: string
|
||||
}
|
||||
|
||||
interface CostEntry {
|
||||
id: string
|
||||
projectId: string
|
||||
description: string
|
||||
amount: number
|
||||
date: string
|
||||
category: string
|
||||
}
|
||||
|
||||
interface RevenueEntry {
|
||||
id: string
|
||||
projectId: string
|
||||
description: string
|
||||
amount: number
|
||||
date: string
|
||||
}
|
||||
|
||||
const COST_CATEGORIES = ['Material', 'Arbetskraft', 'Underentreprenor', 'Maskiner', 'Ovrigt']
|
||||
|
||||
function getBudgetStatus(totalCost: number, budget: number): 'ok' | 'warning' | 'danger' {
|
||||
if (budget <= 0) return 'ok'
|
||||
const ratio = totalCost / budget
|
||||
if (ratio >= 1) return 'danger'
|
||||
if (ratio >= 0.8) return 'warning'
|
||||
return 'ok'
|
||||
}
|
||||
|
||||
function getProgressColor(status: 'ok' | 'warning' | 'danger'): string {
|
||||
switch (status) {
|
||||
case 'danger': return '[&>div]:bg-red-500'
|
||||
case 'warning': return '[&>div]:bg-amber-500'
|
||||
default: return ''
|
||||
}
|
||||
}
|
||||
|
||||
export default function ProjectCostWorkspace({}: WorkspaceComponentProps) {
|
||||
const { data, save, remove, refresh, isLoading } = useExtensionData('construction', 'project-cost')
|
||||
|
||||
// --- Date range filter ---
|
||||
const now = new Date()
|
||||
const [dateRange, setDateRange] = useState<{ start: string; end: string } | null>(null)
|
||||
|
||||
// --- Parse data ---
|
||||
const projects = useMemo(() =>
|
||||
data.filter(d => d.key.startsWith('project:'))
|
||||
.map(d => ({ id: d.key.replace('project:', ''), ...(d.value as Omit<Project, 'id'>) }))
|
||||
.sort((a, b) => b.startDate.localeCompare(a.startDate))
|
||||
, [data])
|
||||
|
||||
const allCosts = useMemo(() =>
|
||||
data.filter(d => d.key.startsWith('cost:'))
|
||||
.map(d => ({ id: d.key.replace('cost:', ''), ...(d.value as Omit<CostEntry, 'id'>) }))
|
||||
.sort((a, b) => b.date.localeCompare(a.date))
|
||||
, [data])
|
||||
|
||||
const allRevenues = useMemo(() =>
|
||||
data.filter(d => d.key.startsWith('revenue:'))
|
||||
.map(d => ({ id: d.key.replace('revenue:', ''), ...(d.value as Omit<RevenueEntry, 'id'>) }))
|
||||
.sort((a, b) => b.date.localeCompare(a.date))
|
||||
, [data])
|
||||
|
||||
// Filtered costs/revenues based on date range
|
||||
const costs = useMemo(() => {
|
||||
if (!dateRange) return allCosts
|
||||
return allCosts.filter(c => c.date >= dateRange.start && c.date <= dateRange.end)
|
||||
}, [allCosts, dateRange])
|
||||
|
||||
const revenues = useMemo(() => {
|
||||
if (!dateRange) return allRevenues
|
||||
return allRevenues.filter(r => r.date >= dateRange.start && r.date <= dateRange.end)
|
||||
}, [allRevenues, dateRange])
|
||||
|
||||
// --- UI state ---
|
||||
const [expandedProject, setExpandedProject] = useState<string | null>(null)
|
||||
const [showNewProject, setShowNewProject] = useState(false)
|
||||
const [newProjectName, setNewProjectName] = useState('')
|
||||
const [newProjectBudget, setNewProjectBudget] = useState('')
|
||||
|
||||
// Cost/Revenue entry forms
|
||||
const [costDesc, setCostDesc] = useState('')
|
||||
const [costAmount, setCostAmount] = useState('')
|
||||
const [costCategory, setCostCategory] = useState(COST_CATEGORIES[0])
|
||||
const [revDesc, setRevDesc] = useState('')
|
||||
const [revAmount, setRevAmount] = useState('')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
// Delete confirmation state
|
||||
const [deleteTarget, setDeleteTarget] = useState<{
|
||||
type: 'cost' | 'revenue' | 'project'
|
||||
id: string
|
||||
label: string
|
||||
} | null>(null)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
|
||||
// Edit cost/revenue entry state
|
||||
const [editEntry, setEditEntry] = useState<{
|
||||
type: 'cost' | 'revenue'
|
||||
id: string
|
||||
projectId: string
|
||||
description: string
|
||||
amount: string
|
||||
date: string
|
||||
category?: string
|
||||
} | null>(null)
|
||||
const [isSavingEdit, setIsSavingEdit] = useState(false)
|
||||
|
||||
// Edit project state
|
||||
const [editProject, setEditProject] = useState<{
|
||||
id: string
|
||||
name: string
|
||||
budget: string
|
||||
} | null>(null)
|
||||
const [isSavingProject, setIsSavingProject] = useState(false)
|
||||
|
||||
// Complete project confirmation state
|
||||
const [completeProjectId, setCompleteProjectId] = useState<string | null>(null)
|
||||
|
||||
// --- Computed stats ---
|
||||
const projectStats = useMemo(() => {
|
||||
return projects.map(p => {
|
||||
const projectCosts = costs.filter(c => c.projectId === p.id)
|
||||
const projectRevenues = revenues.filter(r => r.projectId === p.id)
|
||||
const totalCost = projectCosts.reduce((s, c) => s + c.amount, 0)
|
||||
const totalRevenue = projectRevenues.reduce((s, r) => s + r.amount, 0)
|
||||
const margin = totalRevenue > 0 ? Math.round(((totalRevenue - totalCost) / totalRevenue) * 100) : 0
|
||||
const budgetUsed = p.budget > 0 ? Math.round((totalCost / p.budget) * 100) : 0
|
||||
const budgetStatus = getBudgetStatus(totalCost, p.budget)
|
||||
|
||||
// Cost category breakdown
|
||||
const categoryTotals = COST_CATEGORIES.map(cat => {
|
||||
const catTotal = projectCosts
|
||||
.filter(c => c.category === cat)
|
||||
.reduce((s, c) => s + c.amount, 0)
|
||||
return {
|
||||
category: cat,
|
||||
total: catTotal,
|
||||
pct: totalCost > 0 ? Math.round((catTotal / totalCost) * 100) : 0,
|
||||
}
|
||||
}).filter(ct => ct.total > 0)
|
||||
|
||||
return {
|
||||
...p,
|
||||
totalCost,
|
||||
totalRevenue,
|
||||
margin,
|
||||
budgetUsed,
|
||||
budgetStatus,
|
||||
costs: projectCosts,
|
||||
revenues: projectRevenues,
|
||||
categoryTotals,
|
||||
}
|
||||
})
|
||||
}, [projects, costs, revenues])
|
||||
|
||||
const activeProjects = projectStats.filter(p => p.status === 'active')
|
||||
const completedProjects = projectStats.filter(p => p.status === 'completed')
|
||||
const totalRevenue = projectStats.reduce((s, p) => s + p.totalRevenue, 0)
|
||||
const totalCosts = projectStats.reduce((s, p) => s + p.totalCost, 0)
|
||||
const avgMargin = totalRevenue > 0 ? Math.round(((totalRevenue - totalCosts) / totalRevenue) * 100) : 0
|
||||
|
||||
// --- Handlers ---
|
||||
const handleAddProject = async () => {
|
||||
if (!newProjectName.trim()) return
|
||||
const id = crypto.randomUUID()
|
||||
await save(`project:${id}`, {
|
||||
name: newProjectName.trim(),
|
||||
budget: Math.round((parseFloat(newProjectBudget) || 0) * 100) / 100,
|
||||
status: 'active',
|
||||
startDate: new Date().toISOString().slice(0, 10),
|
||||
})
|
||||
setNewProjectName('')
|
||||
setNewProjectBudget('')
|
||||
setShowNewProject(false)
|
||||
await refresh()
|
||||
}
|
||||
|
||||
const handleAddCost = async (projectId: string) => {
|
||||
const amt = parseFloat(costAmount)
|
||||
if (isNaN(amt) || amt <= 0) return
|
||||
setIsSubmitting(true)
|
||||
const id = crypto.randomUUID()
|
||||
await save(`cost:${id}`, {
|
||||
projectId,
|
||||
description: costDesc,
|
||||
amount: Math.round(amt * 100) / 100,
|
||||
date: new Date().toISOString().slice(0, 10),
|
||||
category: costCategory,
|
||||
})
|
||||
setCostDesc('')
|
||||
setCostAmount('')
|
||||
await refresh()
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
|
||||
const handleAddRevenue = async (projectId: string) => {
|
||||
const amt = parseFloat(revAmount)
|
||||
if (isNaN(amt) || amt <= 0) return
|
||||
setIsSubmitting(true)
|
||||
const id = crypto.randomUUID()
|
||||
await save(`revenue:${id}`, {
|
||||
projectId,
|
||||
description: revDesc,
|
||||
amount: Math.round(amt * 100) / 100,
|
||||
date: new Date().toISOString().slice(0, 10),
|
||||
})
|
||||
setRevDesc('')
|
||||
setRevAmount('')
|
||||
await refresh()
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget) return
|
||||
setIsDeleting(true)
|
||||
if (deleteTarget.type === 'project') {
|
||||
// Delete all costs and revenues for the project, then the project itself
|
||||
const projectCosts = allCosts.filter(c => c.projectId === deleteTarget.id)
|
||||
const projectRevenues = allRevenues.filter(r => r.projectId === deleteTarget.id)
|
||||
for (const c of projectCosts) {
|
||||
await remove(`cost:${c.id}`)
|
||||
}
|
||||
for (const r of projectRevenues) {
|
||||
await remove(`revenue:${r.id}`)
|
||||
}
|
||||
await remove(`project:${deleteTarget.id}`)
|
||||
} else if (deleteTarget.type === 'cost') {
|
||||
await remove(`cost:${deleteTarget.id}`)
|
||||
} else {
|
||||
await remove(`revenue:${deleteTarget.id}`)
|
||||
}
|
||||
await refresh()
|
||||
setIsDeleting(false)
|
||||
setDeleteTarget(null)
|
||||
}
|
||||
|
||||
const handleSaveEditEntry = async () => {
|
||||
if (!editEntry) return
|
||||
const amt = parseFloat(editEntry.amount)
|
||||
if (isNaN(amt) || amt <= 0) return
|
||||
setIsSavingEdit(true)
|
||||
if (editEntry.type === 'cost') {
|
||||
await save(`cost:${editEntry.id}`, {
|
||||
projectId: editEntry.projectId,
|
||||
description: editEntry.description,
|
||||
amount: Math.round(amt * 100) / 100,
|
||||
date: editEntry.date,
|
||||
category: editEntry.category || COST_CATEGORIES[0],
|
||||
})
|
||||
} else {
|
||||
await save(`revenue:${editEntry.id}`, {
|
||||
projectId: editEntry.projectId,
|
||||
description: editEntry.description,
|
||||
amount: Math.round(amt * 100) / 100,
|
||||
date: editEntry.date,
|
||||
})
|
||||
}
|
||||
await refresh()
|
||||
setIsSavingEdit(false)
|
||||
setEditEntry(null)
|
||||
}
|
||||
|
||||
const handleSaveEditProject = async () => {
|
||||
if (!editProject) return
|
||||
const project = projects.find(p => p.id === editProject.id)
|
||||
if (!project) return
|
||||
setIsSavingProject(true)
|
||||
await save(`project:${editProject.id}`, {
|
||||
name: editProject.name.trim(),
|
||||
budget: Math.round((parseFloat(editProject.budget) || 0) * 100) / 100,
|
||||
status: project.status,
|
||||
startDate: project.startDate,
|
||||
})
|
||||
await refresh()
|
||||
setIsSavingProject(false)
|
||||
setEditProject(null)
|
||||
}
|
||||
|
||||
const handleCompleteProject = async () => {
|
||||
if (!completeProjectId) return
|
||||
const project = projects.find(p => p.id === completeProjectId)
|
||||
if (!project) return
|
||||
await save(`project:${completeProjectId}`, {
|
||||
name: project.name,
|
||||
budget: project.budget,
|
||||
status: 'completed' as const,
|
||||
startDate: project.startDate,
|
||||
})
|
||||
await refresh()
|
||||
setCompleteProjectId(null)
|
||||
}
|
||||
|
||||
if (isLoading) return <ExtensionLoadingSkeleton />
|
||||
|
||||
// --- Render helper for budget alert banner ---
|
||||
const renderBudgetAlert = (p: (typeof projectStats)[number]) => {
|
||||
if (p.budget <= 0) return null
|
||||
if (p.budgetStatus === 'danger') {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-md bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-800 px-3 py-2 text-sm text-red-700 dark:text-red-400">
|
||||
<AlertTriangle className="h-4 w-4 shrink-0" />
|
||||
<span>Kostnaden overskrider budgeten ({p.budgetUsed}% anvant)</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (p.budgetStatus === 'warning') {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-md bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800 px-3 py-2 text-sm text-amber-700 dark:text-amber-400">
|
||||
<AlertTriangle className="h-4 w-4 shrink-0" />
|
||||
<span>Budgetvarning: {p.budgetUsed}% av budgeten anvand</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// --- Render helper for category breakdown ---
|
||||
const renderCategoryBreakdown = (categoryTotals: { category: string; total: number; pct: number }[]) => {
|
||||
if (categoryTotals.length === 0) return null
|
||||
return (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">Kostnadsfordelning per kategori</h4>
|
||||
<div className="rounded-lg border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Kategori</TableHead>
|
||||
<TableHead className="text-right">Belopp</TableHead>
|
||||
<TableHead className="text-right">Andel</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{categoryTotals.map(ct => (
|
||||
<TableRow key={ct.category}>
|
||||
<TableCell className="font-medium">{ct.category}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{ct.total.toLocaleString('sv-SE')} kr
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{ct.pct}%</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Render project card for the Projects tab ---
|
||||
const renderProjectCard = (p: (typeof projectStats)[number]) => {
|
||||
const isExpanded = expandedProject === p.id
|
||||
return (
|
||||
<Card key={p.id}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div
|
||||
className="flex items-center gap-2 cursor-pointer flex-1"
|
||||
onClick={() => setExpandedProject(isExpanded ? null : p.id)}
|
||||
>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
{p.name}
|
||||
<Badge variant={p.status === 'active' ? 'default' : 'secondary'}>
|
||||
{p.status === 'active' ? 'Aktiv' : 'Avslutad'}
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setEditProject({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
budget: String(p.budget),
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setDeleteTarget({ type: 'project', id: p.id, label: p.name })
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
<div
|
||||
className="cursor-pointer p-1"
|
||||
onClick={() => setExpandedProject(isExpanded ? null : p.id)}
|
||||
>
|
||||
{isExpanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
{isExpanded && (
|
||||
<CardContent className="space-y-4">
|
||||
{/* Budget alert banner */}
|
||||
{renderBudgetAlert(p)}
|
||||
|
||||
{/* Stats row */}
|
||||
<div className="grid grid-cols-3 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground">Kostnad</p>
|
||||
<p className="font-semibold tabular-nums">{p.totalCost.toLocaleString('sv-SE')} kr</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Intakt</p>
|
||||
<p className="font-semibold tabular-nums">{p.totalRevenue.toLocaleString('sv-SE')} kr</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Marginal</p>
|
||||
<p className="font-semibold tabular-nums">{p.margin}%</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Budget progress */}
|
||||
{p.budget > 0 && (
|
||||
<div>
|
||||
<div className="flex justify-between text-xs text-muted-foreground mb-1">
|
||||
<span>Budget anvand</span>
|
||||
<span>{p.budgetUsed}% av {p.budget.toLocaleString('sv-SE')} kr</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={Math.min(p.budgetUsed, 100)}
|
||||
className={cn('h-2', getProgressColor(p.budgetStatus))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Category breakdown */}
|
||||
{renderCategoryBreakdown(p.categoryTotals)}
|
||||
|
||||
{/* Cost entries */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">Kostnader</h4>
|
||||
<div className="flex gap-2 mb-2 flex-wrap">
|
||||
<Input placeholder="Beskrivning" value={costDesc} onChange={e => setCostDesc(e.target.value)} className="max-w-xs" />
|
||||
<Input type="number" placeholder="Belopp" value={costAmount} onChange={e => setCostAmount(e.target.value)} className="w-28" />
|
||||
<Select value={costCategory} onValueChange={setCostCategory}>
|
||||
<SelectTrigger className="w-40"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{COST_CATEGORIES.map(c => <SelectItem key={c} value={c}>{c}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button size="sm" onClick={() => handleAddCost(p.id)} disabled={isSubmitting}>Lagg till</Button>
|
||||
</div>
|
||||
{p.costs.length > 0 && (
|
||||
<div className="rounded-lg border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Datum</TableHead>
|
||||
<TableHead>Beskrivning</TableHead>
|
||||
<TableHead>Kategori</TableHead>
|
||||
<TableHead className="text-right">Belopp</TableHead>
|
||||
<TableHead className="w-20"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{p.costs.map(c => (
|
||||
<TableRow key={c.id}>
|
||||
<TableCell>{c.date}</TableCell>
|
||||
<TableCell>{c.description}</TableCell>
|
||||
<TableCell>{c.category}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{c.amount.toLocaleString('sv-SE')} kr</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setEditEntry({
|
||||
type: 'cost',
|
||||
id: c.id,
|
||||
projectId: c.projectId,
|
||||
description: c.description,
|
||||
amount: String(c.amount),
|
||||
date: c.date,
|
||||
category: c.category,
|
||||
})}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setDeleteTarget({
|
||||
type: 'cost',
|
||||
id: c.id,
|
||||
label: c.description || 'kostnad',
|
||||
})}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Revenue entries */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">Intakter</h4>
|
||||
<div className="flex gap-2 mb-2 flex-wrap">
|
||||
<Input placeholder="Beskrivning" value={revDesc} onChange={e => setRevDesc(e.target.value)} className="max-w-xs" />
|
||||
<Input type="number" placeholder="Belopp" value={revAmount} onChange={e => setRevAmount(e.target.value)} className="w-28" />
|
||||
<Button size="sm" onClick={() => handleAddRevenue(p.id)} disabled={isSubmitting}>Lagg till</Button>
|
||||
</div>
|
||||
{p.revenues.length > 0 && (
|
||||
<div className="rounded-lg border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Datum</TableHead>
|
||||
<TableHead>Beskrivning</TableHead>
|
||||
<TableHead className="text-right">Belopp</TableHead>
|
||||
<TableHead className="w-20"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{p.revenues.map(r => (
|
||||
<TableRow key={r.id}>
|
||||
<TableCell>{r.date}</TableCell>
|
||||
<TableCell>{r.description}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{r.amount.toLocaleString('sv-SE')} kr</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setEditEntry({
|
||||
type: 'revenue',
|
||||
id: r.id,
|
||||
projectId: r.projectId,
|
||||
description: r.description,
|
||||
amount: String(r.amount),
|
||||
date: r.date,
|
||||
})}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setDeleteTarget({
|
||||
type: 'revenue',
|
||||
id: r.id,
|
||||
label: r.description || 'intakt',
|
||||
})}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Complete project button (active only) */}
|
||||
{p.status === 'active' && (
|
||||
<div className="pt-2 border-t">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCompleteProjectId(p.id)}
|
||||
className="text-green-700 dark:text-green-400 border-green-300 dark:border-green-700 hover:bg-green-50 dark:hover:bg-green-950/30"
|
||||
>
|
||||
<CheckCircle className="h-4 w-4 mr-1" />
|
||||
Avsluta projekt
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Tabs defaultValue="overview">
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview">Oversikt</TabsTrigger>
|
||||
<TabsTrigger value="projects">Projekt</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview" className="space-y-6 mt-4">
|
||||
<DateRangeFilter onRangeChange={(start, end) => setDateRange({ start, end })} />
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-4 gap-4">
|
||||
<KPICard label="Aktiva projekt" value={activeProjects.length} />
|
||||
<KPICard label="Total intakt" value={totalRevenue.toLocaleString('sv-SE')} suffix="kr" />
|
||||
<KPICard label="Total kostnad" value={totalCosts.toLocaleString('sv-SE')} suffix="kr" />
|
||||
<KPICard label="Snittmarginal" value={avgMargin} suffix="%" />
|
||||
</div>
|
||||
|
||||
{/* Active projects */}
|
||||
{activeProjects.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-semibold">Aktiva projekt</h3>
|
||||
{activeProjects.map(p => (
|
||||
<Card key={p.id}>
|
||||
<CardContent className="pt-4">
|
||||
{/* Budget alert */}
|
||||
{renderBudgetAlert(p)}
|
||||
|
||||
<div className="flex items-center justify-between mb-2 mt-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-medium text-sm">{p.name}</p>
|
||||
<Badge variant="default">Aktiv</Badge>
|
||||
</div>
|
||||
<div className="text-right text-sm">
|
||||
<span className="text-muted-foreground">Marginal: </span>
|
||||
<span className="font-medium tabular-nums">{p.margin}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground mb-2">
|
||||
<span>Kostnad: {p.totalCost.toLocaleString('sv-SE')} kr</span>
|
||||
<span>Intakt: {p.totalRevenue.toLocaleString('sv-SE')} kr</span>
|
||||
{p.budget > 0 && <span>Budget: {p.budget.toLocaleString('sv-SE')} kr</span>}
|
||||
</div>
|
||||
{p.budget > 0 && (
|
||||
<Progress
|
||||
value={Math.min(p.budgetUsed, 100)}
|
||||
className={cn('h-2', getProgressColor(p.budgetStatus))}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Completed projects */}
|
||||
{completedProjects.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-semibold">Avslutade projekt</h3>
|
||||
{completedProjects.map(p => (
|
||||
<Card key={p.id} className="border-muted">
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-medium text-sm">{p.name}</p>
|
||||
<Badge variant="secondary">Avslutad</Badge>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className={cn(
|
||||
'text-lg font-bold tabular-nums',
|
||||
p.margin >= 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'
|
||||
)}>
|
||||
{p.margin}% marginal
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<span>Kostnad: {p.totalCost.toLocaleString('sv-SE')} kr</span>
|
||||
<span>Intakt: {p.totalRevenue.toLocaleString('sv-SE')} kr</span>
|
||||
<span>Resultat: {(Math.round((p.totalRevenue - p.totalCost) * 100) / 100).toLocaleString('sv-SE')} kr</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="projects" className="space-y-6 mt-4">
|
||||
{!showNewProject ? (
|
||||
<Button size="sm" variant="outline" onClick={() => setShowNewProject(true)}>
|
||||
<Plus className="h-4 w-4 mr-1" /> Nytt projekt
|
||||
</Button>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Input placeholder="Projektnamn" value={newProjectName} onChange={e => setNewProjectName(e.target.value)} className="max-w-xs" />
|
||||
<Input type="number" placeholder="Budget (kr)" value={newProjectBudget} onChange={e => setNewProjectBudget(e.target.value)} className="max-w-xs" />
|
||||
<Button size="sm" onClick={handleAddProject} disabled={!newProjectName.trim()}>Skapa</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setShowNewProject(false)}>Avbryt</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeProjects.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold">Aktiva projekt</h3>
|
||||
{activeProjects.map(p => renderProjectCard(p))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{completedProjects.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground">Avslutade projekt</h3>
|
||||
{completedProjects.map(p => renderProjectCard(p))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
<ConfirmDeleteDialog
|
||||
open={deleteTarget !== null}
|
||||
onOpenChange={(open) => { if (!open) setDeleteTarget(null) }}
|
||||
title={
|
||||
deleteTarget?.type === 'project'
|
||||
? 'Ta bort projekt'
|
||||
: deleteTarget?.type === 'cost'
|
||||
? 'Ta bort kostnad'
|
||||
: 'Ta bort intakt'
|
||||
}
|
||||
description={
|
||||
deleteTarget?.type === 'project'
|
||||
? `Vill du ta bort projektet "${deleteTarget?.label}"? Alla kostnader och intakter kopplade till projektet tas ocksa bort. Atgarden kan inte angras.`
|
||||
: `Vill du ta bort "${deleteTarget?.label}"? Atgarden kan inte angras.`
|
||||
}
|
||||
onConfirm={handleDelete}
|
||||
isDeleting={isDeleting}
|
||||
/>
|
||||
|
||||
{/* Edit cost/revenue entry dialog */}
|
||||
<EditEntryDialog
|
||||
open={editEntry !== null}
|
||||
onOpenChange={(open) => { if (!open) setEditEntry(null) }}
|
||||
title={editEntry?.type === 'cost' ? 'Redigera kostnad' : 'Redigera intakt'}
|
||||
description="Andra uppgifterna och klicka Spara."
|
||||
onSave={handleSaveEditEntry}
|
||||
isSaving={isSavingEdit}
|
||||
>
|
||||
{editEntry && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-desc">Beskrivning</Label>
|
||||
<Input
|
||||
id="edit-desc"
|
||||
value={editEntry.description}
|
||||
onChange={e => setEditEntry({ ...editEntry, description: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-amount">Belopp (kr)</Label>
|
||||
<Input
|
||||
id="edit-amount"
|
||||
type="number"
|
||||
value={editEntry.amount}
|
||||
onChange={e => setEditEntry({ ...editEntry, amount: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-date">Datum</Label>
|
||||
<Input
|
||||
id="edit-date"
|
||||
type="date"
|
||||
value={editEntry.date}
|
||||
onChange={e => setEditEntry({ ...editEntry, date: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
{editEntry.type === 'cost' && (
|
||||
<div className="space-y-2">
|
||||
<Label>Kategori</Label>
|
||||
<Select
|
||||
value={editEntry.category || COST_CATEGORIES[0]}
|
||||
onValueChange={val => setEditEntry({ ...editEntry, category: val })}
|
||||
>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{COST_CATEGORIES.map(c => <SelectItem key={c} value={c}>{c}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</EditEntryDialog>
|
||||
|
||||
{/* Edit project dialog */}
|
||||
<EditEntryDialog
|
||||
open={editProject !== null}
|
||||
onOpenChange={(open) => { if (!open) setEditProject(null) }}
|
||||
title="Redigera projekt"
|
||||
description="Andra projektnamn och budget."
|
||||
onSave={handleSaveEditProject}
|
||||
isSaving={isSavingProject}
|
||||
>
|
||||
{editProject && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-proj-name">Projektnamn</Label>
|
||||
<Input
|
||||
id="edit-proj-name"
|
||||
value={editProject.name}
|
||||
onChange={e => setEditProject({ ...editProject, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-proj-budget">Budget (kr)</Label>
|
||||
<Input
|
||||
id="edit-proj-budget"
|
||||
type="number"
|
||||
value={editProject.budget}
|
||||
onChange={e => setEditProject({ ...editProject, budget: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</EditEntryDialog>
|
||||
|
||||
{/* Complete project confirmation dialog */}
|
||||
<ConfirmDeleteDialog
|
||||
open={completeProjectId !== null}
|
||||
onOpenChange={(open) => { if (!open) setCompleteProjectId(null) }}
|
||||
title="Avsluta projekt"
|
||||
description={`Vill du markera projektet som avslutat? Projektet flyttas till "Avslutade" och kan inte ateraktiveras.`}
|
||||
onConfirm={handleCompleteProject}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,613 +0,0 @@
|
||||
'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<Customer[]>(() =>
|
||||
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<Job[]>(() =>
|
||||
data.filter(d => d.key.startsWith('job:'))
|
||||
.map(d => ({ id: d.key.replace('job:', ''), ...(d.value as Omit<Job, 'id'>) }))
|
||||
.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<Customer | null>(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<Job | null>(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<string | null>(null)
|
||||
const [isDeletingJob, setIsDeletingJob] = useState(false)
|
||||
|
||||
// Per-customer used quota for selected year (only completed jobs count)
|
||||
const customerYearlyUsed = useMemo(() => {
|
||||
const map = new Map<string, number>()
|
||||
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 <ExtensionLoadingSkeleton />
|
||||
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
<Tabs defaultValue="calculator">
|
||||
<TabsList>
|
||||
<TabsTrigger value="calculator">Kalkylator</TabsTrigger>
|
||||
<TabsTrigger value="customers">Kunder</TabsTrigger>
|
||||
<TabsTrigger value="jobs">Jobb</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="calculator" className="space-y-6 mt-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-sm">Ar:</Label>
|
||||
<Select value={selectedYear} onValueChange={setSelectedYear}>
|
||||
<SelectTrigger className="w-28">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{yearOptions.map(y => (
|
||||
<SelectItem key={y} value={String(y)}>{y}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{customers.length === 0 ? (
|
||||
<div className="rounded-xl border p-6 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Lagg till kunder under fliken "Kunder" for att borja berakna ROT-avdrag.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<DataEntryForm
|
||||
title="Berakna ROT-avdrag"
|
||||
onSubmit={handleSubmitJob}
|
||||
submitLabel="Spara jobb"
|
||||
isSubmitting={isSubmitting}
|
||||
>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Kund</Label>
|
||||
<Select value={customerId} onValueChange={setCustomerId}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{customers.map(c => <SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Beskrivning</Label>
|
||||
<Input placeholder="T.ex. Badrumsrenovering" value={description} onChange={e => setDescription(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Totalt belopp (inkl. moms)</Label>
|
||||
<Input type="number" min="0" placeholder="0" value={total} onChange={e => setTotal(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Materialkostnad</Label>
|
||||
<Input type="number" min="0" placeholder="0" value={material} onChange={e => setMaterial(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{totalNum > 0 && (
|
||||
<Card className="bg-muted/50">
|
||||
<CardContent className="pt-4 space-y-2">
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<span className="text-muted-foreground">Arbetskostnad:</span>
|
||||
<span className="text-right tabular-nums">{labor.toLocaleString('sv-SE')} kr</span>
|
||||
<span className="text-muted-foreground">ROT-avdrag (30%):</span>
|
||||
<span className="text-right tabular-nums font-medium text-green-600">-{rotDeduction.toLocaleString('sv-SE')} kr</span>
|
||||
<span className="text-muted-foreground">Kunden betalar:</span>
|
||||
<span className="text-right tabular-nums font-semibold">{customerPays.toLocaleString('sv-SE')} kr</span>
|
||||
<span className="text-muted-foreground">Kvarvarande kvot:</span>
|
||||
<span className="text-right tabular-nums">{Math.max(remainingQuota - rotDeduction, 0).toLocaleString('sv-SE')} kr</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</DataEntryForm>
|
||||
</>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="customers" className="space-y-6 mt-4">
|
||||
<div className="flex gap-2 flex-wrap items-start">
|
||||
<Input placeholder="Kundnamn" value={newCustomerName} onChange={e => setNewCustomerName(e.target.value)} className="max-w-xs" />
|
||||
<div className="space-y-1">
|
||||
<Input
|
||||
placeholder="Personnummer (YYYYMMDD-XXXX)"
|
||||
value={newCustomerPnr}
|
||||
onChange={e => setNewCustomerPnr(e.target.value)}
|
||||
className="max-w-xs"
|
||||
/>
|
||||
{newCustomerPnrError && (
|
||||
<p className="text-xs text-red-600">{newCustomerPnrError}</p>
|
||||
)}
|
||||
</div>
|
||||
<Button size="sm" onClick={handleAddCustomer} disabled={!canAddCustomer}>
|
||||
<Plus className="h-4 w-4 mr-1" /> Lagg till
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{customers.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Inga kunder tillagda annu.</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{customers.map(cust => {
|
||||
const used = customerYearlyUsed.get(cust.id) ?? 0
|
||||
const pct = Math.min(Math.round((used / MAX_ROT_YEARLY) * 100), 100)
|
||||
return (
|
||||
<Card key={cust.id}>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div>
|
||||
<p className="font-medium text-sm">{cust.name}</p>
|
||||
{cust.personalNumber && (
|
||||
<p className="text-xs text-muted-foreground">{cust.personalNumber}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm tabular-nums">
|
||||
{used.toLocaleString('sv-SE')} / {MAX_ROT_YEARLY.toLocaleString('sv-SE')} kr
|
||||
</span>
|
||||
<Button variant="ghost" size="sm" onClick={() => openEditCustomer(cust)}>
|
||||
<Pencil className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Progress value={pct} className="h-2" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="jobs" className="space-y-6 mt-4">
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-sm">Ar:</Label>
|
||||
<Select value={selectedYear} onValueChange={setSelectedYear}>
|
||||
<SelectTrigger className="w-28">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{yearOptions.map(y => (
|
||||
<SelectItem key={y} value={String(y)}>{y}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{completedJobCount > 0 && (
|
||||
<Button variant="outline" size="sm" onClick={handleExportCsv}>
|
||||
<Download className="h-4 w-4 mr-1" /> Exportera CSV
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<KPICard label="Antal jobb" value={jobs.length} />
|
||||
<KPICard label="Total ROT" value={totalRot.toLocaleString('sv-SE')} suffix="kr" />
|
||||
<KPICard label="Total omsattning" value={totalRevenue.toLocaleString('sv-SE')} suffix="kr" />
|
||||
</div>
|
||||
|
||||
{jobs.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Inga jobb registrerade for {selectedYear}.</p>
|
||||
) : (
|
||||
<div className="rounded-xl border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Datum</TableHead>
|
||||
<TableHead>Kund</TableHead>
|
||||
<TableHead>Beskrivning</TableHead>
|
||||
<TableHead className="text-right">Totalt</TableHead>
|
||||
<TableHead className="text-right">ROT-avdrag</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="w-28"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{jobs.map(job => (
|
||||
<TableRow key={job.id}>
|
||||
<TableCell>{job.date}</TableCell>
|
||||
<TableCell className="font-medium">{job.customerName}</TableCell>
|
||||
<TableCell>{job.description}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{job.total.toLocaleString('sv-SE')} kr</TableCell>
|
||||
<TableCell className="text-right tabular-nums text-green-600">{job.rotDeduction.toLocaleString('sv-SE')} kr</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={job.status === 'completed' ? 'default' : 'secondary'}>
|
||||
{job.status === 'completed' ? 'Klar' : 'Utkast'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
{job.status === 'draft' && (
|
||||
<Button variant="ghost" size="sm" onClick={() => handleMarkCompleted(job)} title="Markera som klar">
|
||||
<Check className="h-3.5 w-3.5 text-green-600" />
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" size="sm" onClick={() => openEditJob(job)} title="Redigera">
|
||||
<Pencil className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setDeletingJobId(job.id)} title="Ta bort">
|
||||
<Trash2 className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Edit Customer Dialog */}
|
||||
<EditEntryDialog
|
||||
open={editingCustomer !== null}
|
||||
onOpenChange={open => { if (!open) setEditingCustomer(null) }}
|
||||
title="Redigera kund"
|
||||
description="Uppdatera kunduppgifter."
|
||||
onSave={handleSaveCustomer}
|
||||
isSaving={isSavingCustomer}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label>Namn</Label>
|
||||
<Input value={editCustomerName} onChange={e => setEditCustomerName(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Personnummer</Label>
|
||||
<Input
|
||||
placeholder="YYYYMMDD-XXXX"
|
||||
value={editCustomerPnr}
|
||||
onChange={e => setEditCustomerPnr(e.target.value)}
|
||||
/>
|
||||
{editCustomerPnrError && (
|
||||
<p className="text-xs text-red-600">{editCustomerPnrError}</p>
|
||||
)}
|
||||
</div>
|
||||
</EditEntryDialog>
|
||||
|
||||
{/* Edit Job Dialog */}
|
||||
<EditEntryDialog
|
||||
open={editingJob !== null}
|
||||
onOpenChange={open => { if (!open) setEditingJob(null) }}
|
||||
title="Redigera jobb"
|
||||
description="Uppdatera jobbdetaljer. ROT-avdrag beraknas om automatiskt."
|
||||
onSave={handleSaveJob}
|
||||
isSaving={isSavingJob}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label>Kund</Label>
|
||||
<Select value={editJobCustomerId} onValueChange={setEditJobCustomerId}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{customers.map(c => <SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Beskrivning</Label>
|
||||
<Input value={editJobDescription} onChange={e => setEditJobDescription(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Totalt belopp (inkl. moms)</Label>
|
||||
<Input type="number" min="0" value={editJobTotal} onChange={e => setEditJobTotal(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Materialkostnad</Label>
|
||||
<Input type="number" min="0" value={editJobMaterial} onChange={e => setEditJobMaterial(e.target.value)} />
|
||||
</div>
|
||||
{(() => {
|
||||
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 ? (
|
||||
<div className="rounded-lg border p-3 bg-muted/50 text-sm space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Arbetskostnad:</span>
|
||||
<span className="tabular-nums">{editLabor.toLocaleString('sv-SE')} kr</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">ROT-avdrag (30%):</span>
|
||||
<span className="tabular-nums font-medium text-green-600">-{editRot.toLocaleString('sv-SE')} kr</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null
|
||||
})()}
|
||||
</EditEntryDialog>
|
||||
|
||||
{/* Delete Job Confirmation */}
|
||||
<ConfirmDeleteDialog
|
||||
open={deletingJobId !== null}
|
||||
onOpenChange={open => { 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}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,989 +0,0 @@
|
||||
'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 (
|
||||
<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 (
|
||||
<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,875 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useMemo } from 'react'
|
||||
import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
|
||||
import { useExtensionData } from '@/lib/extensions/use-extension-data'
|
||||
import KPICard from '@/components/extensions/shared/KPICard'
|
||||
import 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 />
|
||||
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -1,715 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
|
||||
import { useMockData } from '@/lib/extensions/use-mock-data'
|
||||
import ExtensionLoadingSkeleton from '@/components/extensions/shared/ExtensionLoadingSkeleton'
|
||||
import MockDataBanner from '@/components/extensions/shared/MockDataBanner'
|
||||
import MockDataImportDialog from '@/components/extensions/shared/MockDataImportDialog'
|
||||
import type { CsvFieldDef } from '@/components/extensions/shared/MockDataImportDialog'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
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 {
|
||||
TrendingUp, TrendingDown, RefreshCw, Info, ArrowUpDown, FlaskConical,
|
||||
} from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────
|
||||
|
||||
interface CurrencyExposure {
|
||||
currency: string
|
||||
totalForeignAmount: number
|
||||
bookedSekValue: number
|
||||
currentSekValue: number
|
||||
unrealizedGainLoss: number
|
||||
invoiceCount: number
|
||||
averageBookedRate: number
|
||||
currentRate: number
|
||||
}
|
||||
|
||||
interface ForeignReceivable {
|
||||
invoiceId: string
|
||||
invoiceNumber: string
|
||||
customerName: string
|
||||
customerCountry: string
|
||||
currency: string
|
||||
foreignAmount: number
|
||||
bookedSekAmount: number
|
||||
bookedRate: number
|
||||
currentSekAmount: number
|
||||
currentRate: number
|
||||
unrealizedGainLoss: number
|
||||
invoiceDate: string
|
||||
dueDate: string
|
||||
daysOutstanding: number
|
||||
}
|
||||
|
||||
interface MonthlyFXTrend {
|
||||
month: string
|
||||
realizedGains: number
|
||||
realizedLosses: number
|
||||
netRealized: number
|
||||
}
|
||||
|
||||
interface ExchangeRateInfo {
|
||||
currency: string
|
||||
rate: number
|
||||
date: string
|
||||
}
|
||||
|
||||
interface RevalPreview {
|
||||
totalUnrealizedGainLoss: number
|
||||
gains: number
|
||||
losses: number
|
||||
}
|
||||
|
||||
interface ReportData {
|
||||
referenceDate: string
|
||||
exchangeRates: ExchangeRateInfo[]
|
||||
exposureByCurrency: CurrencyExposure[]
|
||||
receivables: ForeignReceivable[]
|
||||
realizedGainLoss: {
|
||||
year: number
|
||||
gains: number
|
||||
losses: number
|
||||
net: number
|
||||
}
|
||||
monthlyTrend: MonthlyFXTrend[]
|
||||
revalPreview: RevalPreview
|
||||
totals: {
|
||||
bookedSekValue: number
|
||||
currentSekValue: number
|
||||
totalUnrealizedGainLoss: number
|
||||
receivableCount: number
|
||||
currencyCount: number
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────
|
||||
|
||||
function formatSEK(amount: number): string {
|
||||
return Math.round(amount).toLocaleString('sv-SE')
|
||||
}
|
||||
|
||||
function formatAmount(amount: number, decimals = 2): string {
|
||||
return amount.toLocaleString('sv-SE', {
|
||||
minimumFractionDigits: decimals,
|
||||
maximumFractionDigits: decimals,
|
||||
})
|
||||
}
|
||||
|
||||
const CURRENCY_SYMBOLS: Record<string, string> = {
|
||||
EUR: '€', USD: '$', GBP: '£', NOK: 'kr', DKK: 'kr', SEK: 'kr',
|
||||
}
|
||||
|
||||
function currencySymbol(code: string): string {
|
||||
return CURRENCY_SYMBOLS[code] || code
|
||||
}
|
||||
|
||||
const MONTH_NAMES = [
|
||||
'Jan', 'Feb', 'Mar', 'Apr', 'Maj', 'Jun',
|
||||
'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dec',
|
||||
]
|
||||
|
||||
function monthLabel(monthKey: string): string {
|
||||
const m = parseInt(monthKey.split('-')[1], 10)
|
||||
return MONTH_NAMES[m - 1] || monthKey
|
||||
}
|
||||
|
||||
function currentYear(): number { return new Date().getFullYear() }
|
||||
|
||||
type SortField = 'unrealizedGainLoss' | 'foreignAmount' | 'daysOutstanding' | 'customerName'
|
||||
type SortDir = 'asc' | 'desc'
|
||||
|
||||
// ── Mock Data Config ──────────────────────────────────────────
|
||||
|
||||
const MOCK_CSV_FIELDS: CsvFieldDef[] = [
|
||||
{ key: 'invoiceNumber', label: 'Fakturanummer', required: true },
|
||||
{ key: 'customerName', label: 'Kund', required: true },
|
||||
{ key: 'currency', label: 'Valuta', required: true },
|
||||
{ key: 'foreignAmount', label: 'Belopp (utl. valuta)', required: true },
|
||||
{ key: 'bookedSekAmount', label: 'Bokfört (SEK)' },
|
||||
{ key: 'bookedRate', label: 'Bokförd kurs' },
|
||||
{ key: 'currentSekAmount', label: 'Aktuellt (SEK)' },
|
||||
{ key: 'currentRate', label: 'Aktuell kurs' },
|
||||
{ key: 'invoiceDate', label: 'Fakturadatum' },
|
||||
{ key: 'dueDate', label: 'Förfallodatum' },
|
||||
]
|
||||
|
||||
const MOCK_CSV_TEMPLATE = `invoiceNumber;customerName;currency;foreignAmount;bookedSekAmount;bookedRate;currentSekAmount;currentRate;invoiceDate;dueDate
|
||||
1001;Beispiel GmbH;EUR;10000;112500;11.25;114200;11.42;2025-01-15;2025-02-15
|
||||
1002;Example Corp;USD;25000;262500;10.50;260000;10.40;2025-01-20;2025-02-20
|
||||
1003;London Ltd;GBP;8000;106400;13.30;108000;13.50;2025-02-01;2025-03-01`
|
||||
|
||||
function parseMockCsvRows(rows: Record<string, string>[]): ReportData {
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
const receivables: ForeignReceivable[] = rows.map(r => {
|
||||
const foreignAmount = parseFloat(r.foreignAmount || '0') || 0
|
||||
const bookedRate = parseFloat(r.bookedRate || '0') || 0
|
||||
const currentRate = parseFloat(r.currentRate || '0') || bookedRate
|
||||
const bookedSek = parseFloat(r.bookedSekAmount || '0') || Math.round(foreignAmount * bookedRate * 100) / 100
|
||||
const currentSek = parseFloat(r.currentSekAmount || '0') || Math.round(foreignAmount * currentRate * 100) / 100
|
||||
const invoiceDate = r.invoiceDate || today
|
||||
const dueDate = r.dueDate || today
|
||||
const daysOutstanding = Math.max(0, Math.floor((Date.now() - new Date(invoiceDate).getTime()) / 86400000))
|
||||
|
||||
return {
|
||||
invoiceId: r.invoiceNumber || '',
|
||||
invoiceNumber: r.invoiceNumber || '',
|
||||
customerName: r.customerName || '',
|
||||
customerCountry: '',
|
||||
currency: r.currency || 'EUR',
|
||||
foreignAmount,
|
||||
bookedSekAmount: bookedSek,
|
||||
bookedRate,
|
||||
currentSekAmount: currentSek,
|
||||
currentRate,
|
||||
unrealizedGainLoss: Math.round((currentSek - bookedSek) * 100) / 100,
|
||||
invoiceDate,
|
||||
dueDate,
|
||||
daysOutstanding,
|
||||
}
|
||||
})
|
||||
|
||||
// Group by currency for exposure
|
||||
const currencyMap = new Map<string, CurrencyExposure>()
|
||||
for (const r of receivables) {
|
||||
const existing = currencyMap.get(r.currency)
|
||||
if (existing) {
|
||||
existing.totalForeignAmount += r.foreignAmount
|
||||
existing.bookedSekValue += r.bookedSekAmount
|
||||
existing.currentSekValue += r.currentSekAmount
|
||||
existing.unrealizedGainLoss += r.unrealizedGainLoss
|
||||
existing.invoiceCount++
|
||||
} else {
|
||||
currencyMap.set(r.currency, {
|
||||
currency: r.currency,
|
||||
totalForeignAmount: r.foreignAmount,
|
||||
bookedSekValue: r.bookedSekAmount,
|
||||
currentSekValue: r.currentSekAmount,
|
||||
unrealizedGainLoss: r.unrealizedGainLoss,
|
||||
invoiceCount: 1,
|
||||
averageBookedRate: r.bookedRate,
|
||||
currentRate: r.currentRate,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const exposureByCurrency = Array.from(currencyMap.values())
|
||||
const totalBookedSek = receivables.reduce((s, r) => s + r.bookedSekAmount, 0)
|
||||
const totalCurrentSek = receivables.reduce((s, r) => s + r.currentSekAmount, 0)
|
||||
const totalUnrealized = Math.round((totalCurrentSek - totalBookedSek) * 100) / 100
|
||||
|
||||
return {
|
||||
referenceDate: today,
|
||||
exchangeRates: exposureByCurrency.map(e => ({ currency: e.currency, rate: e.currentRate, date: today })),
|
||||
exposureByCurrency,
|
||||
receivables,
|
||||
realizedGainLoss: { year: new Date().getFullYear(), gains: 0, losses: 0, net: 0 },
|
||||
monthlyTrend: [],
|
||||
revalPreview: {
|
||||
totalUnrealizedGainLoss: totalUnrealized,
|
||||
gains: Math.max(0, totalUnrealized),
|
||||
losses: Math.abs(Math.min(0, totalUnrealized)),
|
||||
},
|
||||
totals: {
|
||||
bookedSekValue: totalBookedSek,
|
||||
currentSekValue: totalCurrentSek,
|
||||
totalUnrealizedGainLoss: totalUnrealized,
|
||||
receivableCount: receivables.length,
|
||||
currencyCount: exposureByCurrency.length,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function validateMockReport(data: unknown): { valid: boolean; error?: string } {
|
||||
if (!data || typeof data !== 'object') return { valid: false, error: 'Data måste vara ett objekt' }
|
||||
const obj = data as Record<string, unknown>
|
||||
if (!Array.isArray(obj.receivables) && !Array.isArray(obj.exposureByCurrency)) {
|
||||
return { valid: false, error: 'Fältet "receivables" eller "exposureByCurrency" saknas' }
|
||||
}
|
||||
if (!obj.totals || typeof obj.totals !== 'object') return { valid: false, error: 'Fältet "totals" saknas' }
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────
|
||||
|
||||
export default function CurrencyReceivablesWorkspace({ userId }: WorkspaceComponentProps) {
|
||||
void userId
|
||||
|
||||
// Mock data
|
||||
const { mockReport, isMockActive, isLoading: mockLoading, importedAt, saveMockData, clearMockData } = useMockData<ReportData>('export', 'currency-receivables')
|
||||
const [importDialogOpen, setImportDialogOpen] = useState(false)
|
||||
|
||||
const [year, setYear] = useState(currentYear())
|
||||
const [report, setReport] = useState<ReportData | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
|
||||
const [sortField, setSortField] = useState<SortField>('unrealizedGainLoss')
|
||||
const [sortDir, setSortDir] = useState<SortDir>('desc')
|
||||
|
||||
const years = [currentYear(), currentYear() - 1, currentYear() - 2]
|
||||
|
||||
const fetchReport = useCallback(async () => {
|
||||
if (isMockActive && mockReport) {
|
||||
setReport(mockReport)
|
||||
setIsLoading(false)
|
||||
setRefreshing(false)
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const params = new URLSearchParams({ year: String(year) })
|
||||
const res = await fetch(`/api/extensions/export/currency-receivables/report?${params}`)
|
||||
if (!res.ok) {
|
||||
const json = await res.json()
|
||||
setError(json.error || 'Kunde inte hämta rapporten')
|
||||
setReport(null)
|
||||
return
|
||||
}
|
||||
const json = await res.json()
|
||||
setReport(json.data)
|
||||
} catch {
|
||||
setError('Nätverksfel')
|
||||
setReport(null)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
setRefreshing(false)
|
||||
}
|
||||
}, [year, isMockActive, mockReport])
|
||||
|
||||
useEffect(() => {
|
||||
fetchReport()
|
||||
}, [fetchReport])
|
||||
|
||||
const handleMockImport = useCallback(async (data: ReportData, meta: { source: 'csv' | 'json'; fileName: string; rowCount: number }) => {
|
||||
await saveMockData(data, meta)
|
||||
setReport(data)
|
||||
}, [saveMockData])
|
||||
|
||||
const handleMockClear = useCallback(async () => {
|
||||
await clearMockData()
|
||||
setReport(null)
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const params = new URLSearchParams({ year: String(year) })
|
||||
const res = await fetch(`/api/extensions/export/currency-receivables/report?${params}`)
|
||||
if (res.ok) {
|
||||
const json = await res.json()
|
||||
setReport(json.data)
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
setIsLoading(false)
|
||||
}, [clearMockData, year])
|
||||
|
||||
const handleRefresh = () => {
|
||||
setRefreshing(true)
|
||||
fetchReport()
|
||||
}
|
||||
|
||||
const toggleSort = (field: SortField) => {
|
||||
if (sortField === field) {
|
||||
setSortDir(d => d === 'asc' ? 'desc' : 'asc')
|
||||
} else {
|
||||
setSortField(field)
|
||||
setSortDir('desc')
|
||||
}
|
||||
}
|
||||
|
||||
const sortedReceivables = report?.receivables.slice().sort((a, b) => {
|
||||
const mul = sortDir === 'asc' ? 1 : -1
|
||||
switch (sortField) {
|
||||
case 'unrealizedGainLoss': return mul * (Math.abs(a.unrealizedGainLoss) - Math.abs(b.unrealizedGainLoss))
|
||||
case 'foreignAmount': return mul * (a.foreignAmount - b.foreignAmount)
|
||||
case 'daysOutstanding': return mul * (a.daysOutstanding - b.daysOutstanding)
|
||||
case 'customerName': return mul * a.customerName.localeCompare(b.customerName)
|
||||
default: return 0
|
||||
}
|
||||
}) || []
|
||||
|
||||
// Only show trend months that have data or are <= current month
|
||||
const activeTrend = report?.monthlyTrend.filter(t => {
|
||||
const m = parseInt(t.month.split('-')[1], 10)
|
||||
const trendYear = parseInt(t.month.split('-')[0], 10)
|
||||
if (trendYear < currentYear()) return true
|
||||
return m <= new Date().getMonth() + 1
|
||||
}) || []
|
||||
|
||||
if ((isLoading || mockLoading) && !report) {
|
||||
return <ExtensionLoadingSkeleton />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* ── Header ─────────────────────────────────────── */}
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">År (realiserade)</label>
|
||||
<Select value={String(year)} onValueChange={v => setYear(parseInt(v, 10))}>
|
||||
<SelectTrigger className="w-[100px]"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{years.map(y => <SelectItem key={y} value={String(y)}>{y}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex gap-2 ml-auto">
|
||||
<Button variant="outline" size="sm" onClick={() => setImportDialogOpen(true)}>
|
||||
<FlaskConical className="h-4 w-4 mr-1.5" />
|
||||
Importera testdata
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={refreshing}>
|
||||
<RefreshCw className={cn('h-4 w-4 mr-1.5', refreshing && 'animate-spin')} />
|
||||
{refreshing ? 'Uppdaterar...' : 'Uppdatera kurser'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Mock Data Banner ──────────────────────────────── */}
|
||||
{isMockActive && (
|
||||
<MockDataBanner
|
||||
importedAt={importedAt}
|
||||
onClear={handleMockClear}
|
||||
onReplace={() => setImportDialogOpen(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Card className="border-destructive/50 bg-destructive/5">
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{report && (
|
||||
<>
|
||||
{/* ── Exchange Rates ──────────────────────────── */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<p className="text-sm font-medium">Växelkurser</p>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{report.referenceDate}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{report.exchangeRates.map(r => (
|
||||
<div key={r.currency} className="flex items-baseline gap-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">{r.currency}:</span>
|
||||
<span className="text-sm font-mono tabular-nums">{formatAmount(r.rate, 4)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ── Exposure Cards ─────────────────────────── */}
|
||||
{report.exposureByCurrency.length > 0 ? (
|
||||
<div className={cn(
|
||||
'grid gap-4',
|
||||
report.exposureByCurrency.length === 1 ? 'grid-cols-1 sm:grid-cols-2' :
|
||||
report.exposureByCurrency.length === 2 ? 'grid-cols-1 sm:grid-cols-2' :
|
||||
'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3'
|
||||
)}>
|
||||
{report.exposureByCurrency.map(exp => (
|
||||
<ExposureCard key={exp.currency} exposure={exp} />
|
||||
))}
|
||||
|
||||
{/* Total card */}
|
||||
<Card className="border-2">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">Totalt</span>
|
||||
<Badge variant="outline">{report.totals.receivableCount} fakturor</Badge>
|
||||
</div>
|
||||
<p className="text-2xl font-semibold tabular-nums">
|
||||
{formatSEK(report.totals.currentSekValue)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">SEK (aktuell kurs)</p>
|
||||
<div className="mt-3 pt-3 border-t">
|
||||
<FXIndicator label="Orealiserat" amount={report.totals.totalUnrealizedGainLoss} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-sm text-muted-foreground text-center py-6">
|
||||
Inga öppna fordringar i utländsk valuta.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ── Receivables Table ──────────────────────── */}
|
||||
{sortedReceivables.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">Öppna fordringar</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Faktura</TableHead>
|
||||
<SortableHead field="customerName" label="Kund" current={sortField} dir={sortDir} onSort={toggleSort} />
|
||||
<TableHead>Valuta</TableHead>
|
||||
<SortableHead field="foreignAmount" label="Belopp" current={sortField} dir={sortDir} onSort={toggleSort} className="text-right" />
|
||||
<TableHead className="text-right">Bokfört (SEK)</TableHead>
|
||||
<TableHead className="text-right">Aktuellt (SEK)</TableHead>
|
||||
<SortableHead field="unrealizedGainLoss" label="Orealiserat" current={sortField} dir={sortDir} onSort={toggleSort} className="text-right" />
|
||||
<SortableHead field="daysOutstanding" label="Dagar" current={sortField} dir={sortDir} onSort={toggleSort} className="text-right" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sortedReceivables.map(r => (
|
||||
<TableRow key={r.invoiceId}>
|
||||
<TableCell className="font-mono text-sm">{r.invoiceNumber}</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
<div>
|
||||
<span>{r.customerName}</span>
|
||||
{r.customerCountry && (
|
||||
<Badge variant="outline" className="ml-1.5 text-xs">{r.customerCountry}</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="text-xs">{r.currency}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-sm tabular-nums">
|
||||
{currencySymbol(r.currency)}{formatAmount(r.foreignAmount)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-sm tabular-nums">
|
||||
{formatSEK(r.bookedSekAmount)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-sm tabular-nums">
|
||||
{formatSEK(r.currentSekAmount)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<FXBadge amount={r.unrealizedGainLoss} />
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-sm tabular-nums">
|
||||
<span className={cn(
|
||||
r.daysOutstanding > 30 ? 'text-destructive font-medium' :
|
||||
r.daysOutstanding > 14 ? 'text-warning-foreground' : ''
|
||||
)}>
|
||||
{r.daysOutstanding}
|
||||
</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ── Realized FX Trend ──────────────────────── */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">
|
||||
Realiserade kursdifferenser {year}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{activeTrend.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-6">
|
||||
Inga realiserade kursdifferenser för {year}.
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Månad</TableHead>
|
||||
<TableHead className="text-right">Vinst (3960)</TableHead>
|
||||
<TableHead className="text-right">Förlust (7960)</TableHead>
|
||||
<TableHead className="text-right">Netto</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{activeTrend.map(t => (
|
||||
<TableRow key={t.month}>
|
||||
<TableCell className="text-sm">{monthLabel(t.month)}</TableCell>
|
||||
<TableCell className="text-right font-mono text-sm tabular-nums text-green-600">
|
||||
{t.realizedGains > 0 ? `+${formatSEK(t.realizedGains)}` : '—'}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-sm tabular-nums text-red-600">
|
||||
{t.realizedLosses > 0 ? `-${formatSEK(t.realizedLosses)}` : '—'}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-sm tabular-nums">
|
||||
<FXBadge amount={t.netRealized} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{/* Totals row */}
|
||||
<TableRow className="border-t-2 font-medium">
|
||||
<TableCell className="text-sm">Totalt {year}</TableCell>
|
||||
<TableCell className="text-right font-mono text-sm tabular-nums text-green-600">
|
||||
+{formatSEK(report.realizedGainLoss.gains)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-sm tabular-nums text-red-600">
|
||||
-{formatSEK(report.realizedGainLoss.losses)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<FXBadge amount={report.realizedGainLoss.net} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ── Revaluation Preview ────────────────────── */}
|
||||
{report.receivables.length > 0 && (
|
||||
<Card className="border-l-4 border-l-blue-500/50">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<Info className="h-5 w-5 text-blue-500 mt-0.5 shrink-0" />
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">Omvärdering vid periodbokslut</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Om bokslut görs idag: netto orealiserad{' '}
|
||||
<span className={cn(
|
||||
'font-medium',
|
||||
report.revalPreview.totalUnrealizedGainLoss >= 0 ? 'text-green-600' : 'text-red-600'
|
||||
)}>
|
||||
{report.revalPreview.totalUnrealizedGainLoss >= 0 ? 'vinst' : 'förlust'}{' '}
|
||||
{report.revalPreview.totalUnrealizedGainLoss >= 0 ? '+' : ''}
|
||||
{formatSEK(report.revalPreview.totalUnrealizedGainLoss)} SEK
|
||||
</span>
|
||||
</p>
|
||||
{report.revalPreview.gains > 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Konto 3969 (orealiserad kursvinst): {formatSEK(report.revalPreview.gains)} kr
|
||||
</p>
|
||||
)}
|
||||
{report.revalPreview.losses > 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Konto 7969 (orealiserad kursförlust): {formatSEK(report.revalPreview.losses)} kr
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
Bokföringsposterna skapas inte av detta tillägg. Använd värdena ovan som underlag vid periodbokslut.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Mock Data Import Dialog ───────────────────────── */}
|
||||
<MockDataImportDialog<ReportData>
|
||||
open={importDialogOpen}
|
||||
onOpenChange={setImportDialogOpen}
|
||||
csvFields={MOCK_CSV_FIELDS}
|
||||
parseCsvRows={parseMockCsvRows}
|
||||
validateReport={validateMockReport}
|
||||
templateCsvContent={MOCK_CSV_TEMPLATE}
|
||||
templateFileName="currency-receivables-template.csv"
|
||||
onImport={handleMockImport}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Sub-components ────────────────────────────────────────────
|
||||
|
||||
function ExposureCard({ exposure }: { exposure: CurrencyExposure }) {
|
||||
const sym = currencySymbol(exposure.currency)
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<Badge variant="outline" className="text-sm font-medium">{exposure.currency}</Badge>
|
||||
<span className="text-xs text-muted-foreground">{exposure.invoiceCount} fakturor</span>
|
||||
</div>
|
||||
<p className="text-lg font-mono tabular-nums">
|
||||
{sym}{formatAmount(exposure.totalForeignAmount)}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground tabular-nums">
|
||||
{formatSEK(exposure.currentSekValue)} SEK
|
||||
</p>
|
||||
<div className="mt-3 pt-3 border-t space-y-1">
|
||||
<FXIndicator label="Orealiserat" amount={exposure.unrealizedGainLoss} />
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>Bokförd kurs: {formatAmount(exposure.averageBookedRate, 4)}</span>
|
||||
<span>Aktuell: {formatAmount(exposure.currentRate, 4)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function FXIndicator({ label, amount }: { label: string; amount: number }) {
|
||||
const isGain = amount >= 0
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
<div className={cn(
|
||||
'flex items-center gap-1 text-sm font-medium tabular-nums',
|
||||
isGain ? 'text-green-600' : 'text-red-600'
|
||||
)}>
|
||||
{isGain ? <TrendingUp className="h-3.5 w-3.5" /> : <TrendingDown className="h-3.5 w-3.5" />}
|
||||
<span>{isGain ? '+' : ''}{formatSEK(amount)} kr</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FXBadge({ amount }: { amount: number }) {
|
||||
if (amount === 0) return <span className="text-sm text-muted-foreground">—</span>
|
||||
const isGain = amount > 0
|
||||
return (
|
||||
<span className={cn(
|
||||
'text-sm font-mono tabular-nums font-medium',
|
||||
isGain ? 'text-green-600' : 'text-red-600'
|
||||
)}>
|
||||
{isGain ? '+' : ''}{formatSEK(amount)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function SortableHead({
|
||||
field, label, current, dir, onSort, className,
|
||||
}: {
|
||||
field: SortField
|
||||
label: string
|
||||
current: SortField
|
||||
dir: SortDir
|
||||
onSort: (f: SortField) => void
|
||||
className?: string
|
||||
}) {
|
||||
const isActive = current === field
|
||||
return (
|
||||
<TableHead className={className}>
|
||||
<button
|
||||
className="flex items-center gap-1 hover:text-foreground transition-colors"
|
||||
onClick={() => onSort(field)}
|
||||
>
|
||||
{label}
|
||||
<ArrowUpDown className={cn('h-3 w-3', isActive ? 'text-foreground' : 'text-muted-foreground/50')} />
|
||||
{isActive && <span className="text-xs">{dir === 'asc' ? '↑' : '↓'}</span>}
|
||||
</button>
|
||||
</TableHead>
|
||||
)
|
||||
}
|
||||
@@ -1,777 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react'
|
||||
import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
|
||||
import { useMockData } from '@/lib/extensions/use-mock-data'
|
||||
import ExtensionLoadingSkeleton from '@/components/extensions/shared/ExtensionLoadingSkeleton'
|
||||
import MockDataBanner from '@/components/extensions/shared/MockDataBanner'
|
||||
import MockDataImportDialog from '@/components/extensions/shared/MockDataImportDialog'
|
||||
import type { CsvFieldDef } from '@/components/extensions/shared/MockDataImportDialog'
|
||||
import KPICard from '@/components/extensions/shared/KPICard'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
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 {
|
||||
AlertTriangle, CheckCircle2, FileSpreadsheet, FileCode,
|
||||
Clock, ChevronDown, ChevronUp, Users, Package, Briefcase,
|
||||
FlaskConical,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────
|
||||
|
||||
interface ECSalesListLine {
|
||||
customerVatNumber: string
|
||||
customerName: string
|
||||
customerCountry: string
|
||||
customerId: string
|
||||
goodsAmount: number
|
||||
servicesAmount: number
|
||||
triangulationAmount: number
|
||||
invoiceCount: number
|
||||
}
|
||||
|
||||
interface ECSalesListWarning {
|
||||
type: string
|
||||
severity: 'error' | 'warning'
|
||||
invoiceId?: string
|
||||
invoiceNumber?: string
|
||||
customerId?: string
|
||||
customerName?: string
|
||||
message: string
|
||||
}
|
||||
|
||||
interface CrossCheckResult {
|
||||
box35Match: boolean
|
||||
box35ReportTotal: number
|
||||
box35GLTotal: number
|
||||
box39Match: boolean
|
||||
box39ReportTotal: number
|
||||
box39GLTotal: number
|
||||
}
|
||||
|
||||
interface ReportData {
|
||||
period: { year: number; month?: number; quarter?: number }
|
||||
filingType: 'monthly' | 'quarterly'
|
||||
reporterVatNumber: string
|
||||
reporterName: string
|
||||
lines: ECSalesListLine[]
|
||||
totals: { goods: number; services: number; triangulation: number; total: number }
|
||||
warnings: ECSalesListWarning[]
|
||||
crossCheck: CrossCheckResult | null
|
||||
invoiceCount: number
|
||||
customerCount: number
|
||||
deadline: string
|
||||
daysUntilDeadline: number
|
||||
}
|
||||
|
||||
type SortField = 'country' | 'vatNumber' | 'goods' | 'services' | 'invoices'
|
||||
type SortDir = 'asc' | 'desc'
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────
|
||||
|
||||
function formatSEK(amount: number): string {
|
||||
return Math.round(amount).toLocaleString('sv-SE')
|
||||
}
|
||||
|
||||
function formatDeadlineDate(dateStr: string): string {
|
||||
const d = new Date(dateStr + 'T00:00:00')
|
||||
return d.toLocaleDateString('sv-SE', { year: 'numeric', month: 'long', day: 'numeric' })
|
||||
}
|
||||
|
||||
const MONTHS = [
|
||||
'Januari', 'Februari', 'Mars', 'April', 'Maj', 'Juni',
|
||||
'Juli', 'Augusti', 'September', 'Oktober', 'November', 'December',
|
||||
]
|
||||
|
||||
const QUARTERS = ['Q1 (jan–mar)', 'Q2 (apr–jun)', 'Q3 (jul–sep)', 'Q4 (okt–dec)']
|
||||
|
||||
function currentYear(): number {
|
||||
return new Date().getFullYear()
|
||||
}
|
||||
|
||||
function currentMonth(): number {
|
||||
return new Date().getMonth() + 1
|
||||
}
|
||||
|
||||
function currentQuarter(): number {
|
||||
return Math.ceil(currentMonth() / 3)
|
||||
}
|
||||
|
||||
// ── Mock Data Config ──────────────────────────────────────────
|
||||
|
||||
const MOCK_CSV_FIELDS: CsvFieldDef[] = [
|
||||
{ key: 'customerVatNumber', label: 'VAT-nummer', required: true },
|
||||
{ key: 'customerName', label: 'Kundnamn', required: true },
|
||||
{ key: 'customerCountry', label: 'Land', required: true },
|
||||
{ key: 'goodsAmount', label: 'Varor (SEK)' },
|
||||
{ key: 'servicesAmount', label: 'Tjänster (SEK)' },
|
||||
{ key: 'triangulationAmount', label: 'Trepartshandel (SEK)' },
|
||||
{ key: 'invoiceCount', label: 'Antal fakturor' },
|
||||
]
|
||||
|
||||
const MOCK_CSV_TEMPLATE = `customerVatNumber;customerName;customerCountry;goodsAmount;servicesAmount;triangulationAmount;invoiceCount
|
||||
DE123456789;Beispiel GmbH;DE;150000;25000;0;3
|
||||
FR987654321;Exemple SARL;FR;0;80000;0;2
|
||||
NL456789012;Voorbeeld BV;NL;45000;0;12000;1`
|
||||
|
||||
function parseMockCsvRows(rows: Record<string, string>[]): ReportData {
|
||||
const lines: ECSalesListLine[] = rows.map(r => ({
|
||||
customerVatNumber: r.customerVatNumber || '',
|
||||
customerName: r.customerName || '',
|
||||
customerCountry: r.customerCountry || '',
|
||||
customerId: r.customerVatNumber || '',
|
||||
goodsAmount: parseFloat(r.goodsAmount || '0') || 0,
|
||||
servicesAmount: parseFloat(r.servicesAmount || '0') || 0,
|
||||
triangulationAmount: parseFloat(r.triangulationAmount || '0') || 0,
|
||||
invoiceCount: parseInt(r.invoiceCount || '1', 10) || 1,
|
||||
}))
|
||||
|
||||
const goods = lines.reduce((s, l) => s + l.goodsAmount, 0)
|
||||
const services = lines.reduce((s, l) => s + l.servicesAmount, 0)
|
||||
const triangulation = lines.reduce((s, l) => s + l.triangulationAmount, 0)
|
||||
const invoiceCount = lines.reduce((s, l) => s + l.invoiceCount, 0)
|
||||
|
||||
return {
|
||||
period: { year: new Date().getFullYear(), quarter: Math.ceil((new Date().getMonth() + 1) / 3) },
|
||||
filingType: 'quarterly',
|
||||
reporterVatNumber: 'SE000000000001',
|
||||
reporterName: 'Testdata',
|
||||
lines,
|
||||
totals: { goods, services, triangulation, total: goods + services + triangulation },
|
||||
warnings: [],
|
||||
crossCheck: null,
|
||||
invoiceCount,
|
||||
customerCount: lines.length,
|
||||
deadline: new Date(Date.now() + 30 * 86400000).toISOString().slice(0, 10),
|
||||
daysUntilDeadline: 30,
|
||||
}
|
||||
}
|
||||
|
||||
function validateMockReport(data: unknown): { valid: boolean; error?: string } {
|
||||
if (!data || typeof data !== 'object') return { valid: false, error: 'Data måste vara ett objekt' }
|
||||
const obj = data as Record<string, unknown>
|
||||
if (!Array.isArray(obj.lines)) return { valid: false, error: 'Fältet "lines" saknas eller är inte en array' }
|
||||
if (!obj.totals || typeof obj.totals !== 'object') return { valid: false, error: 'Fältet "totals" saknas' }
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────
|
||||
|
||||
export default function EuSalesListWorkspace({ userId }: WorkspaceComponentProps) {
|
||||
void userId
|
||||
|
||||
// Mock data
|
||||
const { mockReport, isMockActive, isLoading: mockLoading, importedAt, saveMockData, clearMockData } = useMockData<ReportData>('export', 'eu-sales-list')
|
||||
const [importDialogOpen, setImportDialogOpen] = useState(false)
|
||||
|
||||
// Period selection state
|
||||
const [year, setYear] = useState(currentYear())
|
||||
const [periodType, setPeriodType] = useState<'monthly' | 'quarterly'>('quarterly')
|
||||
const [month, setMonth] = useState(currentMonth())
|
||||
const [quarter, setQuarter] = useState(currentQuarter())
|
||||
|
||||
// Report state
|
||||
const [report, setReport] = useState<ReportData | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Table sort
|
||||
const [sortField, setSortField] = useState<SortField>('country')
|
||||
const [sortDir, setSortDir] = useState<SortDir>('asc')
|
||||
|
||||
// Warning expansion
|
||||
const [warningsExpanded, setWarningsExpanded] = useState(false)
|
||||
|
||||
// Download state
|
||||
const [downloading, setDownloading] = useState<'csv' | 'xml' | null>(null)
|
||||
|
||||
// Available years (current year and 2 previous)
|
||||
const years = useMemo(() => {
|
||||
const cy = currentYear()
|
||||
return [cy, cy - 1, cy - 2]
|
||||
}, [])
|
||||
|
||||
// Fetch report
|
||||
const fetchReport = useCallback(async () => {
|
||||
if (isMockActive && mockReport) {
|
||||
setReport(mockReport)
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
const params = new URLSearchParams({ year: String(year) })
|
||||
if (periodType === 'monthly') {
|
||||
params.set('month', String(month))
|
||||
} else {
|
||||
params.set('quarter', String(quarter))
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/extensions/export/eu-sales-list/report?${params}`)
|
||||
if (!res.ok) {
|
||||
const json = await res.json()
|
||||
setError(json.error || 'Kunde inte generera rapporten')
|
||||
setReport(null)
|
||||
return
|
||||
}
|
||||
const json = await res.json()
|
||||
setReport(json.data)
|
||||
} catch {
|
||||
setError('Nätverksfel — kunde inte hämta rapporten')
|
||||
setReport(null)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [year, month, quarter, periodType, isMockActive, mockReport])
|
||||
|
||||
useEffect(() => {
|
||||
fetchReport()
|
||||
}, [fetchReport])
|
||||
|
||||
const handleMockImport = useCallback(async (data: ReportData, meta: { source: 'csv' | 'json'; fileName: string; rowCount: number }) => {
|
||||
await saveMockData(data, meta)
|
||||
setReport(data)
|
||||
}, [saveMockData])
|
||||
|
||||
const handleMockClear = useCallback(async () => {
|
||||
await clearMockData()
|
||||
setReport(null)
|
||||
// Re-fetch from API
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
const params = new URLSearchParams({ year: String(year) })
|
||||
if (periodType === 'monthly') {
|
||||
params.set('month', String(month))
|
||||
} else {
|
||||
params.set('quarter', String(quarter))
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`/api/extensions/export/eu-sales-list/report?${params}`)
|
||||
if (res.ok) {
|
||||
const json = await res.json()
|
||||
setReport(json.data)
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
setIsLoading(false)
|
||||
}, [clearMockData, year, month, quarter, periodType])
|
||||
|
||||
// Sort lines
|
||||
const sortedLines = useMemo(() => {
|
||||
if (!report) return []
|
||||
const lines = [...report.lines]
|
||||
lines.sort((a, b) => {
|
||||
let cmp = 0
|
||||
switch (sortField) {
|
||||
case 'country':
|
||||
cmp = a.customerCountry.localeCompare(b.customerCountry)
|
||||
break
|
||||
case 'vatNumber':
|
||||
cmp = a.customerVatNumber.localeCompare(b.customerVatNumber)
|
||||
break
|
||||
case 'goods':
|
||||
cmp = a.goodsAmount - b.goodsAmount
|
||||
break
|
||||
case 'services':
|
||||
cmp = a.servicesAmount - b.servicesAmount
|
||||
break
|
||||
case 'invoices':
|
||||
cmp = a.invoiceCount - b.invoiceCount
|
||||
break
|
||||
}
|
||||
return sortDir === 'asc' ? cmp : -cmp
|
||||
})
|
||||
return lines
|
||||
}, [report, sortField, sortDir])
|
||||
|
||||
// Toggle sort
|
||||
const toggleSort = (field: SortField) => {
|
||||
if (sortField === field) {
|
||||
setSortDir(d => d === 'asc' ? 'desc' : 'asc')
|
||||
} else {
|
||||
setSortField(field)
|
||||
setSortDir('asc')
|
||||
}
|
||||
}
|
||||
|
||||
// Download handler
|
||||
const handleDownload = async (format: 'csv' | 'xml') => {
|
||||
setDownloading(format)
|
||||
const params = new URLSearchParams({ year: String(year), format })
|
||||
if (periodType === 'monthly') {
|
||||
params.set('month', String(month))
|
||||
} else {
|
||||
params.set('quarter', String(quarter))
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/extensions/export/eu-sales-list/download?${params}`)
|
||||
if (!res.ok) {
|
||||
const json = await res.json()
|
||||
setError(json.error || 'Kunde inte ladda ner filen')
|
||||
return
|
||||
}
|
||||
|
||||
const blob = await res.blob()
|
||||
const disposition = res.headers.get('Content-Disposition') || ''
|
||||
const filenameMatch = disposition.match(/filename="(.+)"/)
|
||||
const filename = filenameMatch ? filenameMatch[1] : `PS_${year}.${format}`
|
||||
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
} catch {
|
||||
setError('Kunde inte ladda ner filen')
|
||||
} finally {
|
||||
setDownloading(null)
|
||||
}
|
||||
}
|
||||
|
||||
// Derived counts
|
||||
const errorCount = report?.warnings.filter(w => w.severity === 'error').length ?? 0
|
||||
const warningCount = report?.warnings.filter(w => w.severity === 'warning').length ?? 0
|
||||
|
||||
if ((isLoading || mockLoading) && !report) {
|
||||
return <ExtensionLoadingSkeleton />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* ── Period Selector ─────────────────────────────────── */}
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">År</label>
|
||||
<Select value={String(year)} onValueChange={v => setYear(parseInt(v, 10))}>
|
||||
<SelectTrigger className="w-[100px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{years.map(y => (
|
||||
<SelectItem key={y} value={String(y)}>{y}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">Periodtyp</label>
|
||||
<Select value={periodType} onValueChange={v => setPeriodType(v as 'monthly' | 'quarterly')}>
|
||||
<SelectTrigger className="w-[130px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="monthly">Månad</SelectItem>
|
||||
<SelectItem value="quarterly">Kvartal</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{periodType === 'monthly' ? 'Månad' : 'Kvartal'}
|
||||
</label>
|
||||
{periodType === 'monthly' ? (
|
||||
<Select value={String(month)} onValueChange={v => setMonth(parseInt(v, 10))}>
|
||||
<SelectTrigger className="w-[150px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MONTHS.map((name, i) => (
|
||||
<SelectItem key={i + 1} value={String(i + 1)}>{name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Select value={String(quarter)} onValueChange={v => setQuarter(parseInt(v, 10))}>
|
||||
<SelectTrigger className="w-[150px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{QUARTERS.map((name, i) => (
|
||||
<SelectItem key={i + 1} value={String(i + 1)}>{name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Download + Import buttons */}
|
||||
<div className="flex gap-2 ml-auto">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setImportDialogOpen(true)}
|
||||
>
|
||||
<FlaskConical className="h-4 w-4 mr-1.5" />
|
||||
Importera testdata
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDownload('csv')}
|
||||
disabled={downloading !== null || !report || report.lines.length === 0}
|
||||
>
|
||||
<FileSpreadsheet className="h-4 w-4 mr-1.5" />
|
||||
{downloading === 'csv' ? 'Laddar...' : 'CSV'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDownload('xml')}
|
||||
disabled={downloading !== null || !report || report.lines.length === 0}
|
||||
>
|
||||
<FileCode className="h-4 w-4 mr-1.5" />
|
||||
{downloading === 'xml' ? 'Laddar...' : 'SKV XML'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Mock Data Banner ──────────────────────────────── */}
|
||||
{isMockActive && (
|
||||
<MockDataBanner
|
||||
importedAt={importedAt}
|
||||
onClear={handleMockClear}
|
||||
onReplace={() => setImportDialogOpen(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── Error state ────────────────────────────────────── */}
|
||||
{error && (
|
||||
<Card className="border-destructive/50 bg-destructive/5">
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{report && (
|
||||
<>
|
||||
{/* ── KPI Cards ────────────────────────────────────── */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<KPICard
|
||||
label="Varuförsäljning EU"
|
||||
value={formatSEK(report.totals.goods)}
|
||||
suffix="SEK"
|
||||
/>
|
||||
<KPICard
|
||||
label="Tjänsteförsäljning EU"
|
||||
value={formatSEK(report.totals.services)}
|
||||
suffix="SEK"
|
||||
/>
|
||||
<KPICard
|
||||
label="Trepartshandel"
|
||||
value={formatSEK(report.totals.triangulation)}
|
||||
suffix="SEK"
|
||||
/>
|
||||
<KPICard
|
||||
label="Kunder"
|
||||
value={report.customerCount}
|
||||
suffix={`(${report.invoiceCount} fakturor)`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── Deadline + Cross-Check Row ────────────────────── */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Deadline */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<Clock className="h-5 w-5 text-muted-foreground mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">Inlämningsdeadline</p>
|
||||
<p className="text-lg font-semibold mt-0.5">
|
||||
{formatDeadlineDate(report.deadline)}
|
||||
</p>
|
||||
<p className={cn(
|
||||
'text-sm mt-1',
|
||||
report.daysUntilDeadline <= 7 ? 'text-destructive font-medium' :
|
||||
report.daysUntilDeadline <= 14 ? 'text-warning-foreground' :
|
||||
'text-muted-foreground'
|
||||
)}>
|
||||
{report.daysUntilDeadline > 0
|
||||
? `${report.daysUntilDeadline} dagar kvar`
|
||||
: report.daysUntilDeadline === 0
|
||||
? 'Deadline idag!'
|
||||
: `${Math.abs(report.daysUntilDeadline)} dagar försenad`
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Cross-check */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-sm font-medium mb-3">Avstämning mot huvudbok</p>
|
||||
{report.crossCheck ? (
|
||||
<div className="space-y-2">
|
||||
<CrossCheckRow
|
||||
label="Ruta 35 — varor"
|
||||
reportTotal={report.crossCheck.box35ReportTotal}
|
||||
glTotal={report.crossCheck.box35GLTotal}
|
||||
match={report.crossCheck.box35Match}
|
||||
/>
|
||||
<CrossCheckRow
|
||||
label="Ruta 39 — tjänster"
|
||||
reportTotal={report.crossCheck.box39ReportTotal}
|
||||
glTotal={report.crossCheck.box39GLTotal}
|
||||
match={report.crossCheck.box39Match}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Ingen bokföringsdata tillgänglig för perioden.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* ── Warnings ─────────────────────────────────────── */}
|
||||
{report.warnings.length > 0 && (
|
||||
<Card className={cn(
|
||||
'border-l-4',
|
||||
errorCount > 0 ? 'border-l-destructive' : 'border-l-warning'
|
||||
)}>
|
||||
<CardContent className="pt-6">
|
||||
<button
|
||||
className="flex items-center gap-2 w-full text-left"
|
||||
onClick={() => setWarningsExpanded(!warningsExpanded)}
|
||||
>
|
||||
<AlertTriangle className={cn(
|
||||
'h-4 w-4 shrink-0',
|
||||
errorCount > 0 ? 'text-destructive' : 'text-warning-foreground'
|
||||
)} />
|
||||
<span className="text-sm font-medium flex-1">
|
||||
{errorCount > 0 && (
|
||||
<span className="text-destructive">{errorCount} fel</span>
|
||||
)}
|
||||
{errorCount > 0 && warningCount > 0 && ', '}
|
||||
{warningCount > 0 && (
|
||||
<span className="text-warning-foreground">{warningCount} varningar</span>
|
||||
)}
|
||||
</span>
|
||||
{warningsExpanded
|
||||
? <ChevronUp className="h-4 w-4 text-muted-foreground" />
|
||||
: <ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
}
|
||||
</button>
|
||||
|
||||
{warningsExpanded && (
|
||||
<div className="mt-4 space-y-2">
|
||||
{report.warnings.map((w, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
'flex items-start gap-2 text-sm py-2 px-3 rounded-md',
|
||||
w.severity === 'error'
|
||||
? 'bg-destructive/5 text-destructive'
|
||||
: 'bg-warning/10 text-warning-foreground'
|
||||
)}
|
||||
>
|
||||
<AlertTriangle className="h-3.5 w-3.5 mt-0.5 shrink-0" />
|
||||
<span>{w.message}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ── Customer Table ────────────────────────────────── */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Users className="h-4 w-4" />
|
||||
Kunder per land
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{sortedLines.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-8 text-center">
|
||||
Inga EU-försäljningar hittades för vald period.
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<SortableHead field="country" current={sortField} dir={sortDir} onSort={toggleSort}>
|
||||
Land
|
||||
</SortableHead>
|
||||
<SortableHead field="vatNumber" current={sortField} dir={sortDir} onSort={toggleSort}>
|
||||
VAT-nummer
|
||||
</SortableHead>
|
||||
<TableHead className="text-left">Kund</TableHead>
|
||||
<SortableHead field="goods" current={sortField} dir={sortDir} onSort={toggleSort} className="text-right">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Package className="h-3.5 w-3.5" />
|
||||
Varor (ruta 35)
|
||||
</span>
|
||||
</SortableHead>
|
||||
<SortableHead field="services" current={sortField} dir={sortDir} onSort={toggleSort} className="text-right">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Briefcase className="h-3.5 w-3.5" />
|
||||
Tjänster (ruta 39)
|
||||
</span>
|
||||
</SortableHead>
|
||||
<SortableHead field="invoices" current={sortField} dir={sortDir} onSort={toggleSort} className="text-right">
|
||||
Fakturor
|
||||
</SortableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sortedLines.map(line => (
|
||||
<TableRow key={line.customerVatNumber}>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="font-mono text-xs">
|
||||
{line.customerCountry}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-sm">
|
||||
{line.customerVatNumber}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{line.customerName}</TableCell>
|
||||
<TableCell className="text-right font-mono text-sm tabular-nums">
|
||||
{line.goodsAmount !== 0 ? formatSEK(line.goodsAmount) : '—'}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-sm tabular-nums">
|
||||
{line.servicesAmount !== 0 ? formatSEK(line.servicesAmount) : '—'}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-sm tabular-nums">
|
||||
{line.invoiceCount}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
|
||||
{/* Totals row */}
|
||||
<TableRow className="border-t-2 font-medium">
|
||||
<TableCell colSpan={3} className="text-sm">
|
||||
Summa ({sortedLines.length} kunder)
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-sm tabular-nums">
|
||||
{formatSEK(report.totals.goods)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-sm tabular-nums">
|
||||
{formatSEK(report.totals.services)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-sm tabular-nums">
|
||||
{report.invoiceCount}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ── Filing Info Footer ────────────────────────────── */}
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground px-1">
|
||||
<span>
|
||||
Uppgiftslämnare: {report.reporterName} ({report.reporterVatNumber})
|
||||
</span>
|
||||
<span>
|
||||
Redovisningsperiod: {report.period.year}
|
||||
{report.period.month !== undefined && `, ${MONTHS[report.period.month - 1]}`}
|
||||
{report.period.quarter !== undefined && `, ${QUARTERS[report.period.quarter - 1]}`}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Mock Data Import Dialog ───────────────────────── */}
|
||||
<MockDataImportDialog<ReportData>
|
||||
open={importDialogOpen}
|
||||
onOpenChange={setImportDialogOpen}
|
||||
csvFields={MOCK_CSV_FIELDS}
|
||||
parseCsvRows={parseMockCsvRows}
|
||||
validateReport={validateMockReport}
|
||||
templateCsvContent={MOCK_CSV_TEMPLATE}
|
||||
templateFileName="eu-sales-list-template.csv"
|
||||
onImport={handleMockImport}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Sub-components ────────────────────────────────────────────
|
||||
|
||||
function CrossCheckRow({
|
||||
label,
|
||||
reportTotal,
|
||||
glTotal,
|
||||
match,
|
||||
}: {
|
||||
label: string
|
||||
reportTotal: number
|
||||
glTotal: number
|
||||
match: boolean
|
||||
}) {
|
||||
const diff = Math.round(reportTotal * 100) / 100 - Math.round(glTotal * 100) / 100
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
{match ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-green-600 shrink-0" />
|
||||
) : (
|
||||
<AlertTriangle className="h-4 w-4 text-destructive shrink-0" />
|
||||
)}
|
||||
<span className="flex-1">{label}</span>
|
||||
<span className="font-mono tabular-nums text-muted-foreground">
|
||||
{formatSEK(reportTotal)} SEK
|
||||
</span>
|
||||
{!match && (
|
||||
<span className="font-mono tabular-nums text-destructive text-xs">
|
||||
(diff: {diff > 0 ? '+' : ''}{formatSEK(diff)})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SortableHead({
|
||||
field,
|
||||
current,
|
||||
dir,
|
||||
onSort,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
field: SortField
|
||||
current: SortField
|
||||
dir: SortDir
|
||||
onSort: (field: SortField) => void
|
||||
className?: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const isActive = current === field
|
||||
return (
|
||||
<TableHead className={cn('cursor-pointer select-none', className)} onClick={() => onSort(field)}>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{children}
|
||||
{isActive && (
|
||||
dir === 'asc'
|
||||
? <ChevronUp className="h-3 w-3" />
|
||||
: <ChevronDown className="h-3 w-3" />
|
||||
)}
|
||||
</span>
|
||||
</TableHead>
|
||||
)
|
||||
}
|
||||
@@ -1,747 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react'
|
||||
import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
|
||||
import { useExtensionData } from '@/lib/extensions/use-extension-data'
|
||||
import { useMockData } from '@/lib/extensions/use-mock-data'
|
||||
import ExtensionLoadingSkeleton from '@/components/extensions/shared/ExtensionLoadingSkeleton'
|
||||
import MockDataBanner from '@/components/extensions/shared/MockDataBanner'
|
||||
import MockDataImportDialog from '@/components/extensions/shared/MockDataImportDialog'
|
||||
import type { CsvFieldDef } from '@/components/extensions/shared/MockDataImportDialog'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
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 {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
AlertTriangle, Plus, Pencil, Trash2, FileSpreadsheet, Clock,
|
||||
ChevronDown, ChevronUp, Package, FlaskConical,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────
|
||||
|
||||
interface IntrastatLine {
|
||||
cnCode: string
|
||||
partnerCountry: string
|
||||
countryOfOrigin: string
|
||||
transactionNature: string
|
||||
deliveryTerms: string
|
||||
invoicedValue: number
|
||||
netMass: number
|
||||
supplementaryUnit: number | null
|
||||
supplementaryUnitType: string | null
|
||||
partnerVatId: string
|
||||
}
|
||||
|
||||
interface ThresholdStatus {
|
||||
cumulativeValue: number
|
||||
threshold: number
|
||||
isObligated: boolean
|
||||
percentageUsed: number
|
||||
}
|
||||
|
||||
interface IntrastatWarning {
|
||||
type: string
|
||||
severity: 'error' | 'warning'
|
||||
invoiceId?: string
|
||||
invoiceNumber?: string
|
||||
productId?: string
|
||||
message: string
|
||||
}
|
||||
|
||||
interface ReportData {
|
||||
period: { year: number; month: number }
|
||||
reporterVatNumber: string
|
||||
reporterName: string
|
||||
lines: IntrastatLine[]
|
||||
totals: { invoicedValue: number; netMass: number; lineCount: number }
|
||||
thresholdStatus: ThresholdStatus
|
||||
warnings: IntrastatWarning[]
|
||||
invoiceCount: number
|
||||
}
|
||||
|
||||
interface ProductRecord {
|
||||
key: string
|
||||
productId: string
|
||||
description: string
|
||||
cn_code: string | null
|
||||
net_weight_kg: number | null
|
||||
country_of_origin: string
|
||||
}
|
||||
|
||||
interface ProductForm {
|
||||
productId: string
|
||||
description: string
|
||||
cnCode: string
|
||||
netWeightKg: string
|
||||
countryOfOrigin: string
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────
|
||||
|
||||
function formatSEK(amount: number): string {
|
||||
return Math.round(amount).toLocaleString('sv-SE')
|
||||
}
|
||||
|
||||
const MONTHS = [
|
||||
'Januari', 'Februari', 'Mars', 'April', 'Maj', 'Juni',
|
||||
'Juli', 'Augusti', 'September', 'Oktober', 'November', 'December',
|
||||
]
|
||||
|
||||
function currentYear(): number { return new Date().getFullYear() }
|
||||
function currentMonth(): number { return new Date().getMonth() + 1 }
|
||||
|
||||
const EMPTY_PRODUCT: ProductForm = {
|
||||
productId: '', description: '', cnCode: '', netWeightKg: '', countryOfOrigin: 'SE',
|
||||
}
|
||||
|
||||
// ── Mock Data Config ──────────────────────────────────────────
|
||||
|
||||
const MOCK_CSV_FIELDS: CsvFieldDef[] = [
|
||||
{ key: 'cnCode', label: 'CN-kod', required: true },
|
||||
{ key: 'partnerCountry', label: 'Partnerland', required: true },
|
||||
{ key: 'countryOfOrigin', label: 'Ursprungsland' },
|
||||
{ key: 'transactionNature', label: 'Transaktionstyp' },
|
||||
{ key: 'deliveryTerms', label: 'Leveransvillkor' },
|
||||
{ key: 'invoicedValue', label: 'Fakturerat värde (SEK)', required: true },
|
||||
{ key: 'netMass', label: 'Nettovikt (kg)' },
|
||||
{ key: 'partnerVatId', label: 'Partner VAT-ID' },
|
||||
]
|
||||
|
||||
const MOCK_CSV_TEMPLATE = `cnCode;partnerCountry;countryOfOrigin;transactionNature;deliveryTerms;invoicedValue;netMass;partnerVatId
|
||||
72163100;DE;SE;11;DAP;245000;4500;DE123456789
|
||||
84713000;FR;CN;11;EXW;128000;85;FR987654321
|
||||
39269090;NL;SE;11;FCA;67000;320;NL456789012`
|
||||
|
||||
function parseMockCsvRows(rows: Record<string, string>[]): ReportData {
|
||||
const lines: IntrastatLine[] = rows.map(r => ({
|
||||
cnCode: r.cnCode || '00000000',
|
||||
partnerCountry: r.partnerCountry || '',
|
||||
countryOfOrigin: r.countryOfOrigin || 'SE',
|
||||
transactionNature: r.transactionNature || '11',
|
||||
deliveryTerms: r.deliveryTerms || 'DAP',
|
||||
invoicedValue: parseFloat(r.invoicedValue || '0') || 0,
|
||||
netMass: parseFloat(r.netMass || '0') || 0,
|
||||
supplementaryUnit: null,
|
||||
supplementaryUnitType: null,
|
||||
partnerVatId: r.partnerVatId || '',
|
||||
}))
|
||||
|
||||
const invoicedValue = lines.reduce((s, l) => s + l.invoicedValue, 0)
|
||||
const netMass = lines.reduce((s, l) => s + l.netMass, 0)
|
||||
|
||||
return {
|
||||
period: { year: new Date().getFullYear(), month: new Date().getMonth() + 1 },
|
||||
reporterVatNumber: 'SE000000000001',
|
||||
reporterName: 'Testdata',
|
||||
lines,
|
||||
totals: { invoicedValue, netMass, lineCount: lines.length },
|
||||
thresholdStatus: {
|
||||
cumulativeValue: invoicedValue,
|
||||
threshold: 9000000,
|
||||
isObligated: invoicedValue >= 9000000,
|
||||
percentageUsed: Math.round(invoicedValue / 9000000 * 100),
|
||||
},
|
||||
warnings: [],
|
||||
invoiceCount: lines.length,
|
||||
}
|
||||
}
|
||||
|
||||
function validateMockReport(data: unknown): { valid: boolean; error?: string } {
|
||||
if (!data || typeof data !== 'object') return { valid: false, error: 'Data måste vara ett objekt' }
|
||||
const obj = data as Record<string, unknown>
|
||||
if (!Array.isArray(obj.lines)) return { valid: false, error: 'Fältet "lines" saknas eller är inte en array' }
|
||||
if (!obj.totals || typeof obj.totals !== 'object') return { valid: false, error: 'Fältet "totals" saknas' }
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────
|
||||
|
||||
export default function IntrastatWorkspace({ userId }: WorkspaceComponentProps) {
|
||||
void userId
|
||||
|
||||
// Mock data
|
||||
const { mockReport, isMockActive, isLoading: mockLoading, importedAt, saveMockData, clearMockData } = useMockData<ReportData>('export', 'intrastat')
|
||||
const [importDialogOpen, setImportDialogOpen] = useState(false)
|
||||
|
||||
const [year, setYear] = useState(currentYear())
|
||||
const [month, setMonth] = useState(currentMonth())
|
||||
|
||||
const [report, setReport] = useState<ReportData | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [downloading, setDownloading] = useState(false)
|
||||
|
||||
const [warningsExpanded, setWarningsExpanded] = useState(false)
|
||||
|
||||
// Product CRUD
|
||||
const { data: extData, save, remove, isLoading: productsLoading } = useExtensionData('export', 'intrastat')
|
||||
const [productDialogOpen, setProductDialogOpen] = useState(false)
|
||||
const [editingProduct, setEditingProduct] = useState<string | null>(null)
|
||||
const [productForm, setProductForm] = useState<ProductForm>(EMPTY_PRODUCT)
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null)
|
||||
|
||||
const years = useMemo(() => {
|
||||
const cy = currentYear()
|
||||
return [cy, cy - 1, cy - 2]
|
||||
}, [])
|
||||
|
||||
// Parse products from extension data
|
||||
const products: ProductRecord[] = useMemo(() => {
|
||||
return extData
|
||||
.filter(d => d.key.startsWith('product:'))
|
||||
.map(d => ({
|
||||
key: d.key,
|
||||
productId: d.key.replace('product:', ''),
|
||||
description: String(d.value.description || ''),
|
||||
cn_code: d.value.cn_code ? String(d.value.cn_code) : null,
|
||||
net_weight_kg: d.value.net_weight_kg !== undefined ? Number(d.value.net_weight_kg) : null,
|
||||
country_of_origin: String(d.value.country_of_origin || 'SE'),
|
||||
}))
|
||||
.sort((a, b) => a.description.localeCompare(b.description))
|
||||
}, [extData])
|
||||
|
||||
// Fetch report
|
||||
const fetchReport = useCallback(async () => {
|
||||
if (isMockActive && mockReport) {
|
||||
setReport(mockReport)
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({ year: String(year), month: String(month) })
|
||||
const res = await fetch(`/api/extensions/export/intrastat/report?${params}`)
|
||||
if (!res.ok) {
|
||||
const json = await res.json()
|
||||
setError(json.error || 'Kunde inte generera rapporten')
|
||||
setReport(null)
|
||||
return
|
||||
}
|
||||
const json = await res.json()
|
||||
setReport(json.data)
|
||||
} catch {
|
||||
setError('Nätverksfel')
|
||||
setReport(null)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [year, month, isMockActive, mockReport])
|
||||
|
||||
useEffect(() => {
|
||||
fetchReport()
|
||||
}, [fetchReport])
|
||||
|
||||
const handleMockImport = useCallback(async (data: ReportData, meta: { source: 'csv' | 'json'; fileName: string; rowCount: number }) => {
|
||||
await saveMockData(data, meta)
|
||||
setReport(data)
|
||||
}, [saveMockData])
|
||||
|
||||
const handleMockClear = useCallback(async () => {
|
||||
await clearMockData()
|
||||
setReport(null)
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const params = new URLSearchParams({ year: String(year), month: String(month) })
|
||||
const res = await fetch(`/api/extensions/export/intrastat/report?${params}`)
|
||||
if (res.ok) {
|
||||
const json = await res.json()
|
||||
setReport(json.data)
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
setIsLoading(false)
|
||||
}, [clearMockData, year, month])
|
||||
|
||||
// Product CRUD handlers
|
||||
const openNewProduct = () => {
|
||||
setEditingProduct(null)
|
||||
setProductForm(EMPTY_PRODUCT)
|
||||
setProductDialogOpen(true)
|
||||
}
|
||||
|
||||
const openEditProduct = (productId: string) => {
|
||||
const product = products.find(p => p.productId === productId)
|
||||
if (!product) return
|
||||
setEditingProduct(productId)
|
||||
setProductForm({
|
||||
productId,
|
||||
description: product.description,
|
||||
cnCode: product.cn_code || '',
|
||||
netWeightKg: product.net_weight_kg !== null ? String(product.net_weight_kg) : '',
|
||||
countryOfOrigin: product.country_of_origin,
|
||||
})
|
||||
setProductDialogOpen(true)
|
||||
}
|
||||
|
||||
const saveProduct = async () => {
|
||||
const id = editingProduct || productForm.productId.trim()
|
||||
if (!id) return
|
||||
|
||||
await save(`product:${id}`, {
|
||||
description: productForm.description.trim(),
|
||||
cn_code: productForm.cnCode.trim() || null,
|
||||
net_weight_kg: productForm.netWeightKg ? parseFloat(productForm.netWeightKg) : null,
|
||||
country_of_origin: productForm.countryOfOrigin || 'SE',
|
||||
})
|
||||
|
||||
setProductDialogOpen(false)
|
||||
// Refresh report to pick up new product metadata
|
||||
fetchReport()
|
||||
}
|
||||
|
||||
const deleteProduct = async (productId: string) => {
|
||||
await remove(`product:${productId}`)
|
||||
setDeleteConfirm(null)
|
||||
fetchReport()
|
||||
}
|
||||
|
||||
// Download handler
|
||||
const handleDownload = async () => {
|
||||
setDownloading(true)
|
||||
try {
|
||||
const params = new URLSearchParams({ year: String(year), month: String(month) })
|
||||
const res = await fetch(`/api/extensions/export/intrastat/download?${params}`)
|
||||
if (!res.ok) {
|
||||
setError('Kunde inte ladda ner filen')
|
||||
return
|
||||
}
|
||||
const blob = await res.blob()
|
||||
const disposition = res.headers.get('Content-Disposition') || ''
|
||||
const match = disposition.match(/filename="(.+)"/)
|
||||
const filename = match ? match[1] : `INTRASTAT_${year}-${String(month).padStart(2, '0')}.csv`
|
||||
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
} catch {
|
||||
setError('Kunde inte ladda ner filen')
|
||||
} finally {
|
||||
setDownloading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const errorCount = report?.warnings.filter(w => w.severity === 'error').length ?? 0
|
||||
const warningCount = report?.warnings.filter(w => w.severity === 'warning').length ?? 0
|
||||
|
||||
if ((isLoading || productsLoading || mockLoading) && !report) {
|
||||
return <ExtensionLoadingSkeleton />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* ── Period Selector ─────────────────────────────────── */}
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">År</label>
|
||||
<Select value={String(year)} onValueChange={v => setYear(parseInt(v, 10))}>
|
||||
<SelectTrigger className="w-[100px]"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{years.map(y => <SelectItem key={y} value={String(y)}>{y}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">Månad</label>
|
||||
<Select value={String(month)} onValueChange={v => setMonth(parseInt(v, 10))}>
|
||||
<SelectTrigger className="w-[150px]"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{MONTHS.map((name, i) => <SelectItem key={i + 1} value={String(i + 1)}>{name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex gap-2 ml-auto">
|
||||
<Button
|
||||
variant="outline" size="sm"
|
||||
onClick={() => setImportDialogOpen(true)}
|
||||
>
|
||||
<FlaskConical className="h-4 w-4 mr-1.5" />
|
||||
Importera testdata
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline" size="sm"
|
||||
onClick={handleDownload}
|
||||
disabled={downloading || !report || report.lines.length === 0}
|
||||
>
|
||||
<FileSpreadsheet className="h-4 w-4 mr-1.5" />
|
||||
{downloading ? 'Laddar...' : 'IDEP.web CSV'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Mock Data Banner ──────────────────────────────── */}
|
||||
{isMockActive && (
|
||||
<MockDataBanner
|
||||
importedAt={importedAt}
|
||||
onClear={handleMockClear}
|
||||
onReplace={() => setImportDialogOpen(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Card className="border-destructive/50 bg-destructive/5">
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{report && (
|
||||
<>
|
||||
{/* ── Threshold Progress ───────────────────────────── */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-sm font-medium">Tröskelvärde Intrastat (utförsel)</p>
|
||||
<Badge variant={report.thresholdStatus.isObligated ? 'destructive' : 'outline'}>
|
||||
{report.thresholdStatus.isObligated ? 'Obligatorisk rapportering' : 'Frivillig rapportering'}
|
||||
</Badge>
|
||||
</div>
|
||||
<Progress
|
||||
value={Math.min(report.thresholdStatus.percentageUsed, 100)}
|
||||
className="h-3"
|
||||
/>
|
||||
<div className="flex items-center justify-between mt-2 text-xs text-muted-foreground">
|
||||
<span>
|
||||
Ackumulerat (12 mån): {formatSEK(report.thresholdStatus.cumulativeValue)} SEK
|
||||
</span>
|
||||
<span>
|
||||
{report.thresholdStatus.percentageUsed}% av {formatSEK(report.thresholdStatus.threshold)} SEK
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ── KPI Row ──────────────────────────────────────── */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-sm text-muted-foreground">Fakturerat värde</p>
|
||||
<p className="text-2xl font-semibold tabular-nums mt-1">{formatSEK(report.totals.invoicedValue)}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">SEK</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-sm text-muted-foreground">Nettovikt</p>
|
||||
<p className="text-2xl font-semibold tabular-nums mt-1">{report.totals.netMass.toLocaleString('sv-SE')}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">kg</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-sm text-muted-foreground">Deklarationsrader</p>
|
||||
<p className="text-2xl font-semibold tabular-nums mt-1">{report.totals.lineCount}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{report.invoiceCount} fakturor</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* ── Product Registry ─────────────────────────────── */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Package className="h-4 w-4" />
|
||||
Produktregister
|
||||
</CardTitle>
|
||||
<Button variant="outline" size="sm" onClick={openNewProduct}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Lägg till
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{products.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-6 text-center">
|
||||
Inga produkter registrerade. Lägg till produkter med CN-kod och vikt för att generera Intrastat-deklarationer.
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Produkt</TableHead>
|
||||
<TableHead>CN-kod</TableHead>
|
||||
<TableHead className="text-right">Vikt (kg)</TableHead>
|
||||
<TableHead>Ursprung</TableHead>
|
||||
<TableHead className="w-20" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{products.map(p => (
|
||||
<TableRow key={p.productId}>
|
||||
<TableCell className="text-sm">
|
||||
<div>
|
||||
<span className="font-medium">{p.description || p.productId}</span>
|
||||
{p.productId !== p.description && (
|
||||
<span className="text-xs text-muted-foreground ml-1">({p.productId})</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{p.cn_code ? (
|
||||
<Badge variant="outline" className="font-mono text-xs">{p.cn_code}</Badge>
|
||||
) : (
|
||||
<span className="text-destructive text-xs flex items-center gap-1">
|
||||
<AlertTriangle className="h-3 w-3" /> Saknas
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-sm tabular-nums">
|
||||
{p.net_weight_kg !== null
|
||||
? String(p.net_weight_kg)
|
||||
: <span className="text-muted-foreground">—</span>
|
||||
}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{p.country_of_origin}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-1 justify-end">
|
||||
<Button variant="ghost" size="sm" onClick={() => openEditProduct(p.productId)}>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setDeleteConfirm(p.productId)}>
|
||||
<Trash2 className="h-3.5 w-3.5 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ── Declaration Table ─────────────────────────────── */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">
|
||||
Deklaration {MONTHS[month - 1]} {year}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{report.lines.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-6 text-center">
|
||||
Inga EU-varuförsäljningar hittades för perioden.
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>CN-kod</TableHead>
|
||||
<TableHead>Land</TableHead>
|
||||
<TableHead>Urspr.</TableHead>
|
||||
<TableHead className="text-right">Värde (SEK)</TableHead>
|
||||
<TableHead className="text-right">Vikt (kg)</TableHead>
|
||||
<TableHead>Partner-VAT</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{report.lines.map((line, i) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={line.cnCode === '00000000' ? 'destructive' : 'outline'}
|
||||
className="font-mono text-xs"
|
||||
>
|
||||
{line.cnCode}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="text-xs">{line.partnerCountry}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">{line.countryOfOrigin}</TableCell>
|
||||
<TableCell className="text-right font-mono text-sm tabular-nums">
|
||||
{formatSEK(line.invoicedValue)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-sm tabular-nums">
|
||||
{line.netMass > 0 ? line.netMass.toLocaleString('sv-SE') : '—'}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">{line.partnerVatId || '—'}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
<TableRow className="border-t-2 font-medium">
|
||||
<TableCell colSpan={3} className="text-sm">Summa</TableCell>
|
||||
<TableCell className="text-right font-mono text-sm tabular-nums">
|
||||
{formatSEK(report.totals.invoicedValue)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-sm tabular-nums">
|
||||
{report.totals.netMass.toLocaleString('sv-SE')}
|
||||
</TableCell>
|
||||
<TableCell />
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ── Warnings ─────────────────────────────────────── */}
|
||||
{report.warnings.length > 0 && (
|
||||
<Card className={cn('border-l-4', errorCount > 0 ? 'border-l-destructive' : 'border-l-warning')}>
|
||||
<CardContent className="pt-6">
|
||||
<button className="flex items-center gap-2 w-full text-left" onClick={() => setWarningsExpanded(!warningsExpanded)}>
|
||||
<AlertTriangle className={cn('h-4 w-4 shrink-0', errorCount > 0 ? 'text-destructive' : 'text-warning-foreground')} />
|
||||
<span className="text-sm font-medium flex-1">
|
||||
{errorCount > 0 && <span className="text-destructive">{errorCount} fel</span>}
|
||||
{errorCount > 0 && warningCount > 0 && ', '}
|
||||
{warningCount > 0 && <span className="text-warning-foreground">{warningCount} varningar</span>}
|
||||
</span>
|
||||
{warningsExpanded ? <ChevronUp className="h-4 w-4 text-muted-foreground" /> : <ChevronDown className="h-4 w-4 text-muted-foreground" />}
|
||||
</button>
|
||||
{warningsExpanded && (
|
||||
<div className="mt-4 space-y-2">
|
||||
{report.warnings.map((w, i) => (
|
||||
<div key={i} className={cn('flex items-start gap-2 text-sm py-2 px-3 rounded-md', w.severity === 'error' ? 'bg-destructive/5 text-destructive' : 'bg-warning/10 text-warning-foreground')}>
|
||||
<AlertTriangle className="h-3.5 w-3.5 mt-0.5 shrink-0" />
|
||||
<span>{w.message}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ── Deadline Footer ───────────────────────────────── */}
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground px-1">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
<span>
|
||||
Deadline: 10:e arbetsdagen efter redovisningsperiodens slut
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Mock Data Import Dialog ───────────────────────── */}
|
||||
<MockDataImportDialog<ReportData>
|
||||
open={importDialogOpen}
|
||||
onOpenChange={setImportDialogOpen}
|
||||
csvFields={MOCK_CSV_FIELDS}
|
||||
parseCsvRows={parseMockCsvRows}
|
||||
validateReport={validateMockReport}
|
||||
templateCsvContent={MOCK_CSV_TEMPLATE}
|
||||
templateFileName="intrastat-template.csv"
|
||||
onImport={handleMockImport}
|
||||
/>
|
||||
|
||||
{/* ── Product Dialog ────────────────────────────────────── */}
|
||||
<Dialog open={productDialogOpen} onOpenChange={setProductDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingProduct ? 'Redigera produkt' : 'Lägg till produkt'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
{!editingProduct && (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="productId">Produkt-ID (SKU)</Label>
|
||||
<Input
|
||||
id="productId"
|
||||
value={productForm.productId}
|
||||
onChange={e => setProductForm(f => ({ ...f, productId: e.target.value }))}
|
||||
placeholder="T.ex. STALBALK-M8"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="description">Beskrivning</Label>
|
||||
<Input
|
||||
id="description"
|
||||
value={productForm.description}
|
||||
onChange={e => setProductForm(f => ({ ...f, description: e.target.value }))}
|
||||
placeholder="T.ex. Stålbalk M8 200mm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="cnCode">CN-kod (8 siffror)</Label>
|
||||
<Input
|
||||
id="cnCode"
|
||||
value={productForm.cnCode}
|
||||
onChange={e => setProductForm(f => ({ ...f, cnCode: e.target.value.replace(/\D/g, '').slice(0, 8) }))}
|
||||
placeholder="T.ex. 72163100"
|
||||
maxLength={8}
|
||||
className="font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="weight">Nettovikt per enhet (kg)</Label>
|
||||
<Input
|
||||
id="weight"
|
||||
type="number"
|
||||
step="0.001"
|
||||
value={productForm.netWeightKg}
|
||||
onChange={e => setProductForm(f => ({ ...f, netWeightKg: e.target.value }))}
|
||||
placeholder="45.5"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="origin">Ursprungsland</Label>
|
||||
<Input
|
||||
id="origin"
|
||||
value={productForm.countryOfOrigin}
|
||||
onChange={e => setProductForm(f => ({ ...f, countryOfOrigin: e.target.value.toUpperCase().slice(0, 2) }))}
|
||||
placeholder="SE"
|
||||
maxLength={2}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setProductDialogOpen(false)}>Avbryt</Button>
|
||||
<Button
|
||||
onClick={saveProduct}
|
||||
disabled={!editingProduct && !productForm.productId.trim()}
|
||||
>
|
||||
{editingProduct ? 'Spara' : 'Lägg till'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* ── Delete Confirmation ───────────────────────────────── */}
|
||||
<Dialog open={deleteConfirm !== null} onOpenChange={() => setDeleteConfirm(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Ta bort produkt?</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Är du säker på att du vill ta bort produkten “{deleteConfirm}”? Denna åtgärd kan inte ångras.
|
||||
</p>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteConfirm(null)}>Avbryt</Button>
|
||||
<Button variant="destructive" onClick={() => deleteConfirm && deleteProduct(deleteConfirm)}>
|
||||
Ta bort
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,656 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react'
|
||||
import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
|
||||
import { useMockData } from '@/lib/extensions/use-mock-data'
|
||||
import ExtensionLoadingSkeleton from '@/components/extensions/shared/ExtensionLoadingSkeleton'
|
||||
import MockDataBanner from '@/components/extensions/shared/MockDataBanner'
|
||||
import MockDataImportDialog from '@/components/extensions/shared/MockDataImportDialog'
|
||||
import type { CsvFieldDef } from '@/components/extensions/shared/MockDataImportDialog'
|
||||
import KPICard from '@/components/extensions/shared/KPICard'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
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 {
|
||||
AlertTriangle, CheckCircle2, ChevronDown, ChevronUp,
|
||||
ArrowUp, ArrowDown, Minus, BarChart3, FlaskConical,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────
|
||||
|
||||
interface VatBoxData {
|
||||
boxNumber: string
|
||||
label: string
|
||||
amount: number
|
||||
accounts: string[]
|
||||
}
|
||||
|
||||
interface RevenueBreakdown {
|
||||
domestic: { amount: number; percentage: number }
|
||||
euGoods: { amount: number; percentage: number }
|
||||
euServices: { amount: number; percentage: number }
|
||||
exportGoods: { amount: number; percentage: number }
|
||||
exportServices: { amount: number; percentage: number }
|
||||
triangular: { amount: number; percentage: number }
|
||||
totalRevenue: number
|
||||
}
|
||||
|
||||
interface PeriodDelta {
|
||||
current: number
|
||||
previous: number
|
||||
change: number
|
||||
changePercent: number | null
|
||||
}
|
||||
|
||||
interface PeriodComparison {
|
||||
domestic: PeriodDelta
|
||||
euGoods: PeriodDelta
|
||||
euServices: PeriodDelta
|
||||
exportGoods: PeriodDelta
|
||||
exportServices: PeriodDelta
|
||||
triangular: PeriodDelta
|
||||
totalRevenue: PeriodDelta
|
||||
netVat: PeriodDelta
|
||||
}
|
||||
|
||||
interface VatMonitorWarning {
|
||||
type: string
|
||||
severity: 'error' | 'warning'
|
||||
invoiceId?: string
|
||||
invoiceNumber?: string
|
||||
customerName?: string
|
||||
message: string
|
||||
}
|
||||
|
||||
interface ReportData {
|
||||
period: { year: number; month?: number; quarter?: number }
|
||||
boxes: VatBoxData[]
|
||||
revenueBreakdown: RevenueBreakdown
|
||||
vatSummary: {
|
||||
outputVat25: number
|
||||
outputVat12: number
|
||||
outputVat6: number
|
||||
totalOutputVat: number
|
||||
inputVat: number
|
||||
netVat: number
|
||||
isRefund: boolean
|
||||
}
|
||||
warnings: VatMonitorWarning[]
|
||||
comparison: PeriodComparison | null
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────
|
||||
|
||||
function formatSEK(amount: number): string {
|
||||
return Math.round(amount).toLocaleString('sv-SE')
|
||||
}
|
||||
|
||||
const MONTHS = [
|
||||
'Januari', 'Februari', 'Mars', 'April', 'Maj', 'Juni',
|
||||
'Juli', 'Augusti', 'September', 'Oktober', 'November', 'December',
|
||||
]
|
||||
|
||||
const QUARTERS = ['Q1 (jan–mar)', 'Q2 (apr–jun)', 'Q3 (jul–sep)', 'Q4 (okt–dec)']
|
||||
|
||||
function currentYear(): number {
|
||||
return new Date().getFullYear()
|
||||
}
|
||||
|
||||
function currentMonth(): number {
|
||||
return new Date().getMonth() + 1
|
||||
}
|
||||
|
||||
function currentQuarter(): number {
|
||||
return Math.ceil(currentMonth() / 3)
|
||||
}
|
||||
|
||||
// Revenue breakdown cards config
|
||||
const REVENUE_CARDS: { key: keyof Omit<RevenueBreakdown, 'totalRevenue'>; label: string; compKey: keyof PeriodComparison }[] = [
|
||||
{ key: 'domestic', label: 'Inrikes', compKey: 'domestic' },
|
||||
{ key: 'euGoods', label: 'EU varor', compKey: 'euGoods' },
|
||||
{ key: 'euServices', label: 'EU tjänster', compKey: 'euServices' },
|
||||
{ key: 'exportGoods', label: 'Export varor', compKey: 'exportGoods' },
|
||||
{ key: 'exportServices', label: 'Export tjänster', compKey: 'exportServices' },
|
||||
{ key: 'triangular', label: 'Trepartshandel', compKey: 'triangular' },
|
||||
]
|
||||
|
||||
// Box display order (only show relevant ones)
|
||||
const DISPLAY_BOX_ORDER = ['05', '10', '11', '12', '35', '36', '38', '39', '40', '48', '49']
|
||||
|
||||
// ── Mock Data Config ──────────────────────────────────────────
|
||||
|
||||
const MOCK_CSV_FIELDS: CsvFieldDef[] = [
|
||||
{ key: 'boxNumber', label: 'Ruta', required: true },
|
||||
{ key: 'label', label: 'Beskrivning', required: true },
|
||||
{ key: 'amount', label: 'Belopp (SEK)', required: true },
|
||||
{ key: 'accounts', label: 'Konton (kommaseparerade)' },
|
||||
]
|
||||
|
||||
const MOCK_CSV_TEMPLATE = `boxNumber;label;amount;accounts
|
||||
05;Momspliktiga intäkter;500000;3001,3002,3003
|
||||
10;Utgående moms 25%;100000;2611
|
||||
11;Utgående moms 12%;6000;2621
|
||||
12;Utgående moms 6%;3000;2631
|
||||
35;Varuförsäljning EU;75000;3305
|
||||
36;Tjänsteförsäljning EU;45000;3308
|
||||
38;Exportförsäljning;30000;3305
|
||||
39;Omvänd skattskyldighet;20000;
|
||||
40;Inköp varor EU;60000;
|
||||
48;Ingående moms;65000;2641
|
||||
49;Moms att betala;44000;`
|
||||
|
||||
function parseMockCsvRows(rows: Record<string, string>[]): ReportData {
|
||||
const boxes: VatBoxData[] = rows.map(r => ({
|
||||
boxNumber: r.boxNumber || '',
|
||||
label: r.label || '',
|
||||
amount: parseFloat(r.amount || '0') || 0,
|
||||
accounts: r.accounts ? r.accounts.split(',').map(a => a.trim()) : [],
|
||||
}))
|
||||
|
||||
// Derive revenue breakdown from box values
|
||||
const getBox = (num: string) => boxes.find(b => b.boxNumber === num)?.amount || 0
|
||||
const domestic = getBox('05')
|
||||
const euGoods = getBox('35')
|
||||
const euServices = getBox('36')
|
||||
const exportGoods = getBox('38')
|
||||
const exportServices = 0
|
||||
const triangular = getBox('39')
|
||||
const totalRevenue = domestic + euGoods + euServices + exportGoods + exportServices + triangular
|
||||
|
||||
const revenueBreakdown: RevenueBreakdown = {
|
||||
domestic: { amount: domestic, percentage: totalRevenue > 0 ? Math.round(domestic / totalRevenue * 100) : 0 },
|
||||
euGoods: { amount: euGoods, percentage: totalRevenue > 0 ? Math.round(euGoods / totalRevenue * 100) : 0 },
|
||||
euServices: { amount: euServices, percentage: totalRevenue > 0 ? Math.round(euServices / totalRevenue * 100) : 0 },
|
||||
exportGoods: { amount: exportGoods, percentage: totalRevenue > 0 ? Math.round(exportGoods / totalRevenue * 100) : 0 },
|
||||
exportServices: { amount: exportServices, percentage: 0 },
|
||||
triangular: { amount: triangular, percentage: totalRevenue > 0 ? Math.round(triangular / totalRevenue * 100) : 0 },
|
||||
totalRevenue,
|
||||
}
|
||||
|
||||
const outputVat25 = getBox('10')
|
||||
const outputVat12 = getBox('11')
|
||||
const outputVat6 = getBox('12')
|
||||
const inputVat = getBox('48')
|
||||
const netVat = getBox('49')
|
||||
|
||||
return {
|
||||
period: { year: new Date().getFullYear(), month: new Date().getMonth() + 1 },
|
||||
boxes,
|
||||
revenueBreakdown,
|
||||
vatSummary: {
|
||||
outputVat25,
|
||||
outputVat12,
|
||||
outputVat6,
|
||||
totalOutputVat: outputVat25 + outputVat12 + outputVat6,
|
||||
inputVat,
|
||||
netVat,
|
||||
isRefund: netVat < 0,
|
||||
},
|
||||
warnings: [],
|
||||
comparison: null,
|
||||
}
|
||||
}
|
||||
|
||||
function validateMockReport(data: unknown): { valid: boolean; error?: string } {
|
||||
if (!data || typeof data !== 'object') return { valid: false, error: 'Data måste vara ett objekt' }
|
||||
const obj = data as Record<string, unknown>
|
||||
if (!Array.isArray(obj.boxes)) return { valid: false, error: 'Fältet "boxes" saknas eller är inte en array' }
|
||||
if (!obj.revenueBreakdown || typeof obj.revenueBreakdown !== 'object') {
|
||||
return { valid: false, error: 'Fältet "revenueBreakdown" saknas' }
|
||||
}
|
||||
if (!obj.vatSummary || typeof obj.vatSummary !== 'object') {
|
||||
return { valid: false, error: 'Fältet "vatSummary" saknas' }
|
||||
}
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────
|
||||
|
||||
export default function VatMonitorWorkspace({ userId }: WorkspaceComponentProps) {
|
||||
void userId
|
||||
|
||||
// Mock data
|
||||
const { mockReport, isMockActive, isLoading: mockLoading, importedAt, saveMockData, clearMockData } = useMockData<ReportData>('export', 'vat-monitor')
|
||||
const [importDialogOpen, setImportDialogOpen] = useState(false)
|
||||
|
||||
const [year, setYear] = useState(currentYear())
|
||||
const [periodType, setPeriodType] = useState<'monthly' | 'quarterly'>('monthly')
|
||||
const [month, setMonth] = useState(currentMonth())
|
||||
const [quarter, setQuarter] = useState(currentQuarter())
|
||||
const [compareEnabled, setCompareEnabled] = useState(true)
|
||||
|
||||
const [report, setReport] = useState<ReportData | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const [warningsExpanded, setWarningsExpanded] = useState(false)
|
||||
|
||||
const years = useMemo(() => {
|
||||
const cy = currentYear()
|
||||
return [cy, cy - 1, cy - 2]
|
||||
}, [])
|
||||
|
||||
const fetchReport = useCallback(async () => {
|
||||
if (isMockActive && mockReport) {
|
||||
setReport(mockReport)
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
const params = new URLSearchParams({ year: String(year) })
|
||||
if (periodType === 'monthly') {
|
||||
params.set('month', String(month))
|
||||
} else {
|
||||
params.set('quarter', String(quarter))
|
||||
}
|
||||
if (compareEnabled) {
|
||||
params.set('compare', 'previous')
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/extensions/export/vat-monitor/report?${params}`)
|
||||
if (!res.ok) {
|
||||
const json = await res.json()
|
||||
setError(json.error || 'Kunde inte generera rapporten')
|
||||
setReport(null)
|
||||
return
|
||||
}
|
||||
const json = await res.json()
|
||||
setReport(json.data)
|
||||
} catch {
|
||||
setError('Nätverksfel — kunde inte hämta rapporten')
|
||||
setReport(null)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [year, month, quarter, periodType, compareEnabled, isMockActive, mockReport])
|
||||
|
||||
useEffect(() => {
|
||||
fetchReport()
|
||||
}, [fetchReport])
|
||||
|
||||
const handleMockImport = useCallback(async (data: ReportData, meta: { source: 'csv' | 'json'; fileName: string; rowCount: number }) => {
|
||||
await saveMockData(data, meta)
|
||||
setReport(data)
|
||||
}, [saveMockData])
|
||||
|
||||
const handleMockClear = useCallback(async () => {
|
||||
await clearMockData()
|
||||
setReport(null)
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
const params = new URLSearchParams({ year: String(year) })
|
||||
if (periodType === 'monthly') {
|
||||
params.set('month', String(month))
|
||||
} else {
|
||||
params.set('quarter', String(quarter))
|
||||
}
|
||||
if (compareEnabled) {
|
||||
params.set('compare', 'previous')
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`/api/extensions/export/vat-monitor/report?${params}`)
|
||||
if (res.ok) {
|
||||
const json = await res.json()
|
||||
setReport(json.data)
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
setIsLoading(false)
|
||||
}, [clearMockData, year, month, quarter, periodType, compareEnabled])
|
||||
|
||||
const errorCount = report?.warnings.filter(w => w.severity === 'error').length ?? 0
|
||||
const warningCount = report?.warnings.filter(w => w.severity === 'warning').length ?? 0
|
||||
|
||||
// Filter boxes to only show ones in display order that have data or are always shown
|
||||
const displayBoxes = useMemo(() => {
|
||||
if (!report) return []
|
||||
const boxMap = new Map(report.boxes.map(b => [b.boxNumber, b]))
|
||||
return DISPLAY_BOX_ORDER
|
||||
.map(num => boxMap.get(num))
|
||||
.filter((b): b is VatBoxData => b !== undefined)
|
||||
}, [report])
|
||||
|
||||
if ((isLoading || mockLoading) && !report) {
|
||||
return <ExtensionLoadingSkeleton />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* ── Period Selector ─────────────────────────────────── */}
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">År</label>
|
||||
<Select value={String(year)} onValueChange={v => setYear(parseInt(v, 10))}>
|
||||
<SelectTrigger className="w-[100px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{years.map(y => (
|
||||
<SelectItem key={y} value={String(y)}>{y}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">Periodtyp</label>
|
||||
<Select value={periodType} onValueChange={v => setPeriodType(v as 'monthly' | 'quarterly')}>
|
||||
<SelectTrigger className="w-[130px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="monthly">Månad</SelectItem>
|
||||
<SelectItem value="quarterly">Kvartal</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{periodType === 'monthly' ? 'Månad' : 'Kvartal'}
|
||||
</label>
|
||||
{periodType === 'monthly' ? (
|
||||
<Select value={String(month)} onValueChange={v => setMonth(parseInt(v, 10))}>
|
||||
<SelectTrigger className="w-[150px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MONTHS.map((name, i) => (
|
||||
<SelectItem key={i + 1} value={String(i + 1)}>{name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Select value={String(quarter)} onValueChange={v => setQuarter(parseInt(v, 10))}>
|
||||
<SelectTrigger className="w-[150px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{QUARTERS.map((name, i) => (
|
||||
<SelectItem key={i + 1} value={String(i + 1)}>{name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 ml-auto">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setImportDialogOpen(true)}
|
||||
>
|
||||
<FlaskConical className="h-4 w-4 mr-1.5" />
|
||||
Importera testdata
|
||||
</Button>
|
||||
<Button
|
||||
variant={compareEnabled ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setCompareEnabled(!compareEnabled)}
|
||||
>
|
||||
<BarChart3 className="h-4 w-4 mr-1.5" />
|
||||
{compareEnabled ? 'Jämförelse på' : 'Jämför perioder'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Mock Data Banner ──────────────────────────────── */}
|
||||
{isMockActive && (
|
||||
<MockDataBanner
|
||||
importedAt={importedAt}
|
||||
onClear={handleMockClear}
|
||||
onReplace={() => setImportDialogOpen(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── Error state ────────────────────────────────────── */}
|
||||
{error && (
|
||||
<Card className="border-destructive/50 bg-destructive/5">
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{report && (
|
||||
<>
|
||||
{/* ── Revenue Breakdown Cards ──────────────────────── */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-muted-foreground mb-3">Intäktsfördelning</h3>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
|
||||
{REVENUE_CARDS.map(({ key, label, compKey }) => {
|
||||
const data = report.revenueBreakdown[key]
|
||||
const delta = report.comparison?.[compKey]
|
||||
return (
|
||||
<Card key={key} className={cn(data.amount === 0 && 'opacity-50')}>
|
||||
<CardContent className="pt-4 pb-3 px-4">
|
||||
<p className="text-xs text-muted-foreground truncate">{label}</p>
|
||||
<p className="text-lg font-semibold tabular-nums mt-0.5">
|
||||
{formatSEK(data.amount)}
|
||||
</p>
|
||||
<div className="flex items-center justify-between mt-1">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{data.percentage}%
|
||||
</span>
|
||||
{delta && <DeltaIndicator delta={delta} />}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── VAT Summary + Moms Box Table ─────────────────── */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
{/* VAT Summary cards */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">Moms</h3>
|
||||
<KPICard
|
||||
label="Utgående moms"
|
||||
value={formatSEK(report.vatSummary.totalOutputVat)}
|
||||
suffix="SEK"
|
||||
/>
|
||||
<KPICard
|
||||
label="Ingående moms"
|
||||
value={formatSEK(report.vatSummary.inputVat)}
|
||||
suffix="SEK"
|
||||
/>
|
||||
<Card className={cn(
|
||||
report.vatSummary.isRefund ? 'border-green-200 bg-green-50/50 dark:border-green-900 dark:bg-green-950/30' : ''
|
||||
)}>
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{report.vatSummary.isRefund ? 'Moms att få tillbaka' : 'Moms att betala'}
|
||||
</p>
|
||||
<div className="flex items-baseline gap-1 mt-1">
|
||||
<span className={cn(
|
||||
'text-2xl font-semibold tracking-tight',
|
||||
report.vatSummary.isRefund && 'text-green-700 dark:text-green-400'
|
||||
)}>
|
||||
{formatSEK(Math.abs(report.vatSummary.netVat))}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">SEK</span>
|
||||
</div>
|
||||
{report.comparison && (
|
||||
<div className="mt-1">
|
||||
<DeltaIndicator delta={report.comparison.netVat} invert />
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Momsdeklaration preview table */}
|
||||
<div className="lg:col-span-2">
|
||||
<h3 className="text-sm font-medium text-muted-foreground mb-3">
|
||||
Momsdeklaration (förhandsvisning)
|
||||
</h3>
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-16">Ruta</TableHead>
|
||||
<TableHead>Beskrivning</TableHead>
|
||||
<TableHead className="text-right w-36">Belopp (SEK)</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{displayBoxes.map(box => {
|
||||
const isNetVat = box.boxNumber === '49'
|
||||
const isInputVat = box.boxNumber === '48'
|
||||
return (
|
||||
<TableRow
|
||||
key={box.boxNumber}
|
||||
className={cn(isNetVat && 'font-medium border-t-2')}
|
||||
>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={isNetVat ? 'default' : isInputVat ? 'secondary' : 'outline'}
|
||||
className="font-mono text-xs"
|
||||
>
|
||||
{box.boxNumber}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{box.label}</TableCell>
|
||||
<TableCell className="text-right font-mono text-sm tabular-nums">
|
||||
{formatSEK(box.amount)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
{displayBoxes.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="text-center text-muted-foreground py-8">
|
||||
Ingen bokföringsdata för perioden.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Warnings ─────────────────────────────────────── */}
|
||||
{report.warnings.length > 0 && (
|
||||
<Card className={cn(
|
||||
'border-l-4',
|
||||
errorCount > 0 ? 'border-l-destructive' : 'border-l-warning'
|
||||
)}>
|
||||
<CardContent className="pt-6">
|
||||
<button
|
||||
className="flex items-center gap-2 w-full text-left"
|
||||
onClick={() => setWarningsExpanded(!warningsExpanded)}
|
||||
>
|
||||
<AlertTriangle className={cn(
|
||||
'h-4 w-4 shrink-0',
|
||||
errorCount > 0 ? 'text-destructive' : 'text-warning-foreground'
|
||||
)} />
|
||||
<span className="text-sm font-medium flex-1">
|
||||
{errorCount > 0 && (
|
||||
<span className="text-destructive">{errorCount} fel</span>
|
||||
)}
|
||||
{errorCount > 0 && warningCount > 0 && ', '}
|
||||
{warningCount > 0 && (
|
||||
<span className="text-warning-foreground">{warningCount} varningar</span>
|
||||
)}
|
||||
</span>
|
||||
{warningsExpanded
|
||||
? <ChevronUp className="h-4 w-4 text-muted-foreground" />
|
||||
: <ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
}
|
||||
</button>
|
||||
|
||||
{warningsExpanded && (
|
||||
<div className="mt-4 space-y-2">
|
||||
{report.warnings.map((w, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
'flex items-start gap-2 text-sm py-2 px-3 rounded-md',
|
||||
w.severity === 'error'
|
||||
? 'bg-destructive/5 text-destructive'
|
||||
: 'bg-warning/10 text-warning-foreground'
|
||||
)}
|
||||
>
|
||||
<AlertTriangle className="h-3.5 w-3.5 mt-0.5 shrink-0" />
|
||||
<span>{w.message}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ── Total Revenue Footer ─────────────────────────── */}
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground px-1">
|
||||
<span>
|
||||
Total omsättning: {formatSEK(report.revenueBreakdown.totalRevenue)} SEK
|
||||
</span>
|
||||
<span>
|
||||
{report.period.year}
|
||||
{report.period.month !== undefined && `, ${MONTHS[report.period.month - 1]}`}
|
||||
{report.period.quarter !== undefined && `, ${QUARTERS[report.period.quarter - 1]}`}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Mock Data Import Dialog ───────────────────────── */}
|
||||
<MockDataImportDialog<ReportData>
|
||||
open={importDialogOpen}
|
||||
onOpenChange={setImportDialogOpen}
|
||||
csvFields={MOCK_CSV_FIELDS}
|
||||
parseCsvRows={parseMockCsvRows}
|
||||
validateReport={validateMockReport}
|
||||
templateCsvContent={MOCK_CSV_TEMPLATE}
|
||||
templateFileName="vat-monitor-template.csv"
|
||||
onImport={handleMockImport}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Sub-components ────────────────────────────────────────────
|
||||
|
||||
function DeltaIndicator({ delta, invert = false }: { delta: PeriodDelta; invert?: boolean }) {
|
||||
if (delta.changePercent === null || delta.change === 0) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-0.5 text-xs text-muted-foreground">
|
||||
<Minus className="h-3 w-3" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// For most metrics, positive = green (revenue growing)
|
||||
// For netVat (invert=true), positive = red (paying more VAT)
|
||||
const isPositive = delta.change > 0
|
||||
const isGood = invert ? !isPositive : isPositive
|
||||
|
||||
return (
|
||||
<span className={cn(
|
||||
'inline-flex items-center gap-0.5 text-xs',
|
||||
isGood ? 'text-green-600' : 'text-red-600'
|
||||
)}>
|
||||
{isPositive
|
||||
? <ArrowUp className="h-3 w-3" />
|
||||
: <ArrowDown className="h-3 w-3" />
|
||||
}
|
||||
<span>{delta.changePercent > 0 ? '+' : ''}{delta.changePercent}%</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -45,7 +45,7 @@ export default function DocumentInboxWorkspace({ userId }: WorkspaceComponentPro
|
||||
|
||||
const fetchItems = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/extensions/invoice-inbox/inbox')
|
||||
const res = await fetch('/api/extensions/ext/invoice-inbox/inbox')
|
||||
if (res.ok) {
|
||||
const { data } = await res.json()
|
||||
setItems(data ?? [])
|
||||
@@ -59,7 +59,7 @@ export default function DocumentInboxWorkspace({ userId }: WorkspaceComponentPro
|
||||
|
||||
const fetchSettings = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/extensions/invoice-inbox/settings')
|
||||
const res = await fetch('/api/extensions/ext/invoice-inbox/settings')
|
||||
if (res.ok) {
|
||||
const { data } = await res.json()
|
||||
if (data) setSettings(data)
|
||||
@@ -115,7 +115,7 @@ export default function DocumentInboxWorkspace({ userId }: WorkspaceComponentPro
|
||||
for (let i = 0; i < 20; i++) {
|
||||
await new Promise((r) => setTimeout(r, 3000))
|
||||
try {
|
||||
const res = await fetch(`/api/extensions/invoice-inbox/inbox/${itemId}`)
|
||||
const res = await fetch(`/api/extensions/ext/invoice-inbox/inbox/${itemId}`)
|
||||
if (!res.ok) continue
|
||||
const { data } = await res.json()
|
||||
if (data && data.status !== 'processing') {
|
||||
@@ -141,7 +141,7 @@ export default function DocumentInboxWorkspace({ userId }: WorkspaceComponentPro
|
||||
const body: Record<string, string> = {}
|
||||
if (supplierId) body.supplier_id = supplierId
|
||||
|
||||
const res = await fetch(`/api/extensions/invoice-inbox/inbox/${itemId}/confirm`, {
|
||||
const res = await fetch(`/api/extensions/ext/invoice-inbox/inbox/${itemId}/confirm`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
@@ -157,7 +157,7 @@ export default function DocumentInboxWorkspace({ userId }: WorkspaceComponentPro
|
||||
}
|
||||
|
||||
async function handleReject(itemId: string) {
|
||||
const res = await fetch(`/api/extensions/invoice-inbox/inbox/${itemId}`, {
|
||||
const res = await fetch(`/api/extensions/ext/invoice-inbox/inbox/${itemId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
@@ -175,7 +175,7 @@ export default function DocumentInboxWorkspace({ userId }: WorkspaceComponentPro
|
||||
)
|
||||
setSelectedItem(null)
|
||||
|
||||
const res = await fetch(`/api/extensions/invoice-inbox/inbox/${itemId}/process`, {
|
||||
const res = await fetch(`/api/extensions/ext/invoice-inbox/inbox/${itemId}/process`, {
|
||||
method: 'POST',
|
||||
})
|
||||
|
||||
@@ -195,7 +195,7 @@ export default function DocumentInboxWorkspace({ userId }: WorkspaceComponentPro
|
||||
}
|
||||
|
||||
async function handleSaveSettings(updated: InvoiceInboxSettings) {
|
||||
const res = await fetch('/api/extensions/invoice-inbox/settings', {
|
||||
const res = await fetch('/api/extensions/ext/invoice-inbox/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updated),
|
||||
|
||||
@@ -45,7 +45,7 @@ export default function InvoiceInboxWorkspace({ userId }: WorkspaceComponentProp
|
||||
|
||||
const fetchItems = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/extensions/invoice-inbox/inbox')
|
||||
const res = await fetch('/api/extensions/ext/invoice-inbox/inbox')
|
||||
if (res.ok) {
|
||||
const { data } = await res.json()
|
||||
setItems(data ?? [])
|
||||
@@ -59,7 +59,7 @@ export default function InvoiceInboxWorkspace({ userId }: WorkspaceComponentProp
|
||||
|
||||
const fetchSettings = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/extensions/invoice-inbox/settings')
|
||||
const res = await fetch('/api/extensions/ext/invoice-inbox/settings')
|
||||
if (res.ok) {
|
||||
const { data } = await res.json()
|
||||
if (data) setSettings(data)
|
||||
@@ -99,7 +99,7 @@ export default function InvoiceInboxWorkspace({ userId }: WorkspaceComponentProp
|
||||
for (let i = 0; i < 20; i++) {
|
||||
await new Promise((r) => setTimeout(r, 3000))
|
||||
try {
|
||||
const res = await fetch(`/api/extensions/invoice-inbox/inbox/${itemId}`)
|
||||
const res = await fetch(`/api/extensions/ext/invoice-inbox/inbox/${itemId}`)
|
||||
if (!res.ok) continue
|
||||
const { data } = await res.json()
|
||||
if (data && data.status !== 'processing') {
|
||||
@@ -122,7 +122,7 @@ export default function InvoiceInboxWorkspace({ userId }: WorkspaceComponentProp
|
||||
const body: Record<string, string> = {}
|
||||
if (supplierId) body.supplier_id = supplierId
|
||||
|
||||
const res = await fetch(`/api/extensions/invoice-inbox/inbox/${itemId}/confirm`, {
|
||||
const res = await fetch(`/api/extensions/ext/invoice-inbox/inbox/${itemId}/confirm`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
@@ -138,7 +138,7 @@ export default function InvoiceInboxWorkspace({ userId }: WorkspaceComponentProp
|
||||
}
|
||||
|
||||
async function handleReject(itemId: string) {
|
||||
const res = await fetch(`/api/extensions/invoice-inbox/inbox/${itemId}`, {
|
||||
const res = await fetch(`/api/extensions/ext/invoice-inbox/inbox/${itemId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
@@ -156,7 +156,7 @@ export default function InvoiceInboxWorkspace({ userId }: WorkspaceComponentProp
|
||||
)
|
||||
setSelectedItem(null)
|
||||
|
||||
const res = await fetch(`/api/extensions/invoice-inbox/inbox/${itemId}/process`, {
|
||||
const res = await fetch(`/api/extensions/ext/invoice-inbox/inbox/${itemId}/process`, {
|
||||
method: 'POST',
|
||||
})
|
||||
|
||||
@@ -172,7 +172,7 @@ export default function InvoiceInboxWorkspace({ userId }: WorkspaceComponentProp
|
||||
}
|
||||
|
||||
async function handleSaveSettings(updated: InvoiceInboxSettings) {
|
||||
const res = await fetch('/api/extensions/invoice-inbox/settings', {
|
||||
const res = await fetch('/api/extensions/ext/invoice-inbox/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updated),
|
||||
|
||||
@@ -70,7 +70,7 @@ export default function ReceiptInboxDetail({
|
||||
if (!item?.linked_receipt_id) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch(`/api/extensions/receipt-ocr/${item.linked_receipt_id}`)
|
||||
const res = await fetch(`/api/extensions/ext/receipt-ocr/${item.linked_receipt_id}`)
|
||||
if (res.ok) {
|
||||
const { data } = await res.json()
|
||||
if (data?.line_items) {
|
||||
@@ -129,7 +129,7 @@ export default function ReceiptInboxDetail({
|
||||
}
|
||||
|
||||
const res = await fetch(
|
||||
`/api/extensions/invoice-inbox/inbox/${item.id}/confirm-receipt`,
|
||||
`/api/extensions/ext/invoice-inbox/inbox/${item.id}/confirm-receipt`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
|
||||
@@ -70,7 +70,7 @@ export default function InboxUploadZone({
|
||||
}
|
||||
}
|
||||
|
||||
const res = await fetch('/api/extensions/invoice-inbox/inbox', {
|
||||
const res = await fetch('/api/extensions/ext/invoice-inbox/inbox', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
|
||||
@@ -1,578 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useMemo } from 'react'
|
||||
import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
|
||||
import { useExtensionData } from '@/lib/extensions/use-extension-data'
|
||||
import KPICard from '@/components/extensions/shared/KPICard'
|
||||
import DateRangeFilter from '@/components/extensions/shared/DateRangeFilter'
|
||||
import DataEntryForm from '@/components/extensions/shared/DataEntryForm'
|
||||
import SetupPrompt from '@/components/extensions/shared/SetupPrompt'
|
||||
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 {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Pencil, Trash2, ArrowUp, ArrowDown, Minus, Settings } from 'lucide-react'
|
||||
|
||||
const OOO_REASONS = ['Underhall', 'Renovering', 'Blockerat', 'Ovrigt'] as const
|
||||
type OooReason = typeof OOO_REASONS[number]
|
||||
|
||||
function getOccupancyColor(pct: number): string {
|
||||
if (pct >= 80) return 'bg-green-500'
|
||||
if (pct >= 50) return 'bg-yellow-500'
|
||||
if (pct > 0) return 'bg-red-500'
|
||||
return 'bg-muted'
|
||||
}
|
||||
|
||||
function computePreviousPeriod(start: string, end: string): { start: string; end: string } {
|
||||
const startDate = new Date(start + 'T00:00:00')
|
||||
const endDate = new Date(end + 'T00:00:00')
|
||||
const durationMs = endDate.getTime() - startDate.getTime()
|
||||
const prevEnd = new Date(startDate.getTime() - 1)
|
||||
const prevStart = new Date(prevEnd.getTime() - durationMs)
|
||||
return {
|
||||
start: prevStart.toISOString().slice(0, 10),
|
||||
end: prevEnd.toISOString().slice(0, 10),
|
||||
}
|
||||
}
|
||||
|
||||
function DeltaArrow({ current, previous }: { current: number; previous: number }) {
|
||||
const delta = Math.round((current - previous) * 100) / 100
|
||||
if (delta === 0 || (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 pp</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
// For occupancy: higher is better, so positive delta = green (improving)
|
||||
const improving = delta > 0
|
||||
return (
|
||||
<span className={cn(
|
||||
'inline-flex items-center gap-0.5 text-xs',
|
||||
improving ? 'text-green-600' : 'text-red-600'
|
||||
)}>
|
||||
{delta > 0
|
||||
? <ArrowUp className="h-3 w-3" />
|
||||
: <ArrowDown className="h-3 w-3" />
|
||||
}
|
||||
<span>{delta > 0 ? '+' : ''}{delta} pp</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
interface DailyEntry {
|
||||
date: string
|
||||
roomsOccupied: number
|
||||
roomsOutOfOrder: number
|
||||
reason?: OooReason
|
||||
}
|
||||
|
||||
export default function OccupancyWorkspace({}: WorkspaceComponentProps) {
|
||||
const now = new Date()
|
||||
const [dateRange, setDateRange] = useState({
|
||||
start: new Date(now.getFullYear(), now.getMonth(), 1).toISOString().slice(0, 10),
|
||||
end: new Date(now.getFullYear(), now.getMonth() + 1, 0).toISOString().slice(0, 10),
|
||||
})
|
||||
|
||||
const prevPeriod = useMemo(
|
||||
() => computePreviousPeriod(dateRange.start, dateRange.end),
|
||||
[dateRange.start, dateRange.end]
|
||||
)
|
||||
|
||||
const { data, save, remove, refresh, isLoading } = useExtensionData('hotel', 'occupancy')
|
||||
const settings = data.find(d => d.key === 'settings')?.value as { totalRooms?: number } | undefined
|
||||
const totalRooms = settings?.totalRooms ?? 0
|
||||
|
||||
const allDailyEntries = useMemo(() =>
|
||||
data.filter(d => d.key.startsWith('daily:'))
|
||||
.map(d => ({
|
||||
date: d.key.replace('daily:', ''),
|
||||
...(d.value as { roomsOccupied: number; roomsOutOfOrder: number; reason?: OooReason }),
|
||||
}))
|
||||
, [data])
|
||||
|
||||
const entries = useMemo(() =>
|
||||
allDailyEntries
|
||||
.filter(e => e.date >= dateRange.start && e.date <= dateRange.end)
|
||||
.sort((a, b) => b.date.localeCompare(a.date))
|
||||
, [allDailyEntries, dateRange])
|
||||
|
||||
const prevEntries = useMemo(() =>
|
||||
allDailyEntries
|
||||
.filter(e => e.date >= prevPeriod.start && e.date <= prevPeriod.end)
|
||||
, [allDailyEntries, prevPeriod])
|
||||
|
||||
// Form state
|
||||
const [entryDate, setEntryDate] = useState(now.toISOString().slice(0, 10))
|
||||
const [roomsOccupied, setRoomsOccupied] = useState('')
|
||||
const [roomsOutOfOrder, setRoomsOutOfOrder] = useState('')
|
||||
const [oooReason, setOooReason] = useState<OooReason>('Underhall')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
// Edit dialog state
|
||||
const [editDialogOpen, setEditDialogOpen] = useState(false)
|
||||
const [editDate, setEditDate] = useState('')
|
||||
const [editOccupied, setEditOccupied] = useState('')
|
||||
const [editOoo, setEditOoo] = useState('')
|
||||
const [editReason, setEditReason] = useState<OooReason>('Underhall')
|
||||
const [isSavingEdit, setIsSavingEdit] = useState(false)
|
||||
|
||||
// Delete dialog state
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||
const [deleteDate, setDeleteDate] = useState('')
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
|
||||
// Settings dialog state
|
||||
const [settingsDialogOpen, setSettingsDialogOpen] = useState(false)
|
||||
const [newTotalRooms, setNewTotalRooms] = useState('')
|
||||
const [isSavingSettings, setIsSavingSettings] = useState(false)
|
||||
|
||||
// --- Validation ---
|
||||
const formOccupied = parseInt(roomsOccupied) || 0
|
||||
const formOoo = parseInt(roomsOutOfOrder) || 0
|
||||
const formExceedsTotal = totalRooms > 0 && (formOccupied + formOoo) > totalRooms
|
||||
const formIsValid = roomsOccupied !== '' && !isNaN(parseInt(roomsOccupied)) && !formExceedsTotal
|
||||
|
||||
const editOccupiedNum = parseInt(editOccupied) || 0
|
||||
const editOooNum = parseInt(editOoo) || 0
|
||||
const editExceedsTotal = totalRooms > 0 && (editOccupiedNum + editOooNum) > totalRooms
|
||||
const editIsValid = editOccupied !== '' && !isNaN(parseInt(editOccupied)) && !editExceedsTotal
|
||||
|
||||
// --- Current period KPIs ---
|
||||
const totalOccupied = entries.reduce((s, e) => s + e.roomsOccupied, 0)
|
||||
const totalOutOfOrder = entries.reduce((s, e) => s + e.roomsOutOfOrder, 0)
|
||||
const daysInRange = entries.length
|
||||
const totalAvailable = totalRooms * daysInRange
|
||||
|
||||
const occupancyPct = totalAvailable > 0
|
||||
? Math.round((totalOccupied / totalAvailable) * 10000) / 100
|
||||
: 0
|
||||
const avgOccupied = daysInRange > 0 ? Math.round(totalOccupied / daysInRange) : 0
|
||||
const avgOutOfOrder = daysInRange > 0 ? Math.round((totalOutOfOrder / daysInRange) * 10) / 10 : 0
|
||||
const avgAvailable = daysInRange > 0
|
||||
? Math.round(((totalRooms * daysInRange - totalOccupied - totalOutOfOrder) / daysInRange) * 10) / 10
|
||||
: totalRooms
|
||||
|
||||
// --- Previous period KPIs ---
|
||||
const prevTotalOccupied = prevEntries.reduce((s, e) => s + e.roomsOccupied, 0)
|
||||
const prevDaysInRange = prevEntries.length
|
||||
const prevTotalAvailable = totalRooms * prevDaysInRange
|
||||
|
||||
const prevOccupancyPct = prevTotalAvailable > 0
|
||||
? Math.round((prevTotalOccupied / prevTotalAvailable) * 10000) / 100
|
||||
: 0
|
||||
|
||||
// Calendar heatmap for current month view
|
||||
const calendarData = useMemo(() => {
|
||||
const entryMap = new Map(entries.map(e => [e.date, e]))
|
||||
const start = new Date(dateRange.start)
|
||||
const end = new Date(dateRange.end)
|
||||
const days: { date: string; occupancyPct: number; dayOfWeek: number }[] = []
|
||||
|
||||
const current = new Date(start)
|
||||
while (current <= end) {
|
||||
const dateStr = current.toISOString().slice(0, 10)
|
||||
const entry = entryMap.get(dateStr)
|
||||
const pct = entry && totalRooms > 0
|
||||
? Math.round((entry.roomsOccupied / totalRooms) * 100)
|
||||
: 0
|
||||
days.push({ date: dateStr, occupancyPct: pct, dayOfWeek: current.getDay() })
|
||||
current.setDate(current.getDate() + 1)
|
||||
}
|
||||
return days
|
||||
}, [entries, dateRange, totalRooms])
|
||||
|
||||
// --- Handlers ---
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
const occupied = parseInt(roomsOccupied)
|
||||
const outOfOrder = parseInt(roomsOutOfOrder) || 0
|
||||
if (isNaN(occupied)) return
|
||||
if (totalRooms > 0 && (occupied + outOfOrder) > totalRooms) return
|
||||
setIsSubmitting(true)
|
||||
await save(`daily:${entryDate}`, {
|
||||
roomsOccupied: occupied,
|
||||
roomsOutOfOrder: outOfOrder,
|
||||
reason: outOfOrder > 0 ? oooReason : undefined,
|
||||
})
|
||||
setRoomsOccupied('')
|
||||
setRoomsOutOfOrder('')
|
||||
setOooReason('Underhall')
|
||||
await refresh()
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
|
||||
const openEditDialog = (entry: DailyEntry) => {
|
||||
setEditDate(entry.date)
|
||||
setEditOccupied(String(entry.roomsOccupied))
|
||||
setEditOoo(String(entry.roomsOutOfOrder))
|
||||
setEditReason(entry.reason ?? 'Underhall')
|
||||
setEditDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleSaveEdit = async () => {
|
||||
const occupied = parseInt(editOccupied)
|
||||
const outOfOrder = parseInt(editOoo) || 0
|
||||
if (isNaN(occupied)) return
|
||||
if (totalRooms > 0 && (occupied + outOfOrder) > totalRooms) return
|
||||
setIsSavingEdit(true)
|
||||
await save(`daily:${editDate}`, {
|
||||
roomsOccupied: occupied,
|
||||
roomsOutOfOrder: outOfOrder,
|
||||
reason: outOfOrder > 0 ? editReason : undefined,
|
||||
})
|
||||
await refresh()
|
||||
setIsSavingEdit(false)
|
||||
}
|
||||
|
||||
const openDeleteDialog = (date: string) => {
|
||||
setDeleteDate(date)
|
||||
setDeleteDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
setIsDeleting(true)
|
||||
await remove(`daily:${deleteDate}`)
|
||||
setIsDeleting(false)
|
||||
}
|
||||
|
||||
const handleSetup = async (values: Record<string, string>) => {
|
||||
await save('settings', { totalRooms: parseInt(values.totalRooms) || 0 })
|
||||
}
|
||||
|
||||
const openSettingsDialog = () => {
|
||||
setNewTotalRooms(String(totalRooms))
|
||||
setSettingsDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleSaveSettings = async () => {
|
||||
const rooms = parseInt(newTotalRooms)
|
||||
if (isNaN(rooms) || rooms <= 0) return
|
||||
setIsSavingSettings(true)
|
||||
await save('settings', { totalRooms: rooms })
|
||||
await refresh()
|
||||
setIsSavingSettings(false)
|
||||
}
|
||||
|
||||
if (isLoading) return <ExtensionLoadingSkeleton />
|
||||
|
||||
if (!totalRooms) {
|
||||
return (
|
||||
<SetupPrompt
|
||||
title="Konfigurera belaggning"
|
||||
description="Ange antal rum pa hotellet for att borja spara belaggning."
|
||||
fields={[{ key: 'totalRooms', label: 'Antal rum', type: 'number', placeholder: 'T.ex. 50' }]}
|
||||
onSave={handleSetup}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<DateRangeFilter onRangeChange={(start, end) => setDateRange({ start, end })} />
|
||||
<Button variant="outline" size="sm" onClick={openSettingsDialog}>
|
||||
<Settings className="h-4 w-4 mr-1.5" />
|
||||
{totalRooms} rum
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* KPI Cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<KPICard label="Belaggning" value={occupancyPct} suffix="%" />
|
||||
<KPICard label="Snitt belagda rum" value={avgOccupied} suffix={`/ ${totalRooms}`} />
|
||||
<KPICard label="Snitt ur drift" value={avgOutOfOrder} suffix="rum" />
|
||||
<KPICard label="Snitt lediga rum" value={avgAvailable} suffix="rum" />
|
||||
</div>
|
||||
|
||||
{/* Period comparison */}
|
||||
<div className="rounded-xl border p-4">
|
||||
<h3 className="text-sm font-semibold mb-3">Periodjamforelse</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">Belaggning (nuvarande)</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg font-semibold tabular-nums">{occupancyPct}%</span>
|
||||
<DeltaArrow current={occupancyPct} previous={prevOccupancyPct} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">Belaggning (foregaende)</p>
|
||||
<span className="text-lg font-semibold tabular-nums">{prevOccupancyPct}%</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">Foregaende period</p>
|
||||
<span className="text-sm tabular-nums">{prevPeriod.start} — {prevPeriod.end}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mt-3 pt-3 border-t">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">Belagda rum (foregaende)</p>
|
||||
<span className="text-sm tabular-nums">
|
||||
{prevDaysInRange > 0
|
||||
? Math.round(prevTotalOccupied / prevDaysInRange)
|
||||
: 0} snitt / dag
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">Dagar med data (foregaende)</p>
|
||||
<span className="text-sm tabular-nums">{prevDaysInRange} dagar</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Entry form */}
|
||||
<DataEntryForm
|
||||
title="Registrera daglig belaggning"
|
||||
onSubmit={handleSubmit}
|
||||
submitLabel="Registrera"
|
||||
isSubmitting={isSubmitting || !formIsValid}
|
||||
>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="occ-date">Datum</Label>
|
||||
<Input id="occ-date" type="date" value={entryDate} onChange={e => setEntryDate(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="occ-occupied">Belagda rum</Label>
|
||||
<Input
|
||||
id="occ-occupied"
|
||||
type="number"
|
||||
min="0"
|
||||
max={totalRooms}
|
||||
placeholder="0"
|
||||
value={roomsOccupied}
|
||||
onChange={e => setRoomsOccupied(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="occ-ooo">Ur drift</Label>
|
||||
<Input
|
||||
id="occ-ooo"
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder="0"
|
||||
value={roomsOutOfOrder}
|
||||
onChange={e => setRoomsOutOfOrder(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="occ-reason">Orsak (ur drift)</Label>
|
||||
<Select value={oooReason} onValueChange={(val) => setOooReason(val as OooReason)}>
|
||||
<SelectTrigger id="occ-reason">
|
||||
<SelectValue placeholder="Valj orsak" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{OOO_REASONS.map(r => (
|
||||
<SelectItem key={r} value={r}>{r}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
{formExceedsTotal && (
|
||||
<p className="text-sm text-red-600">
|
||||
Belagda rum ({formOccupied}) + ur drift ({formOoo}) = {formOccupied + formOoo} overstiger totalt antal rum ({totalRooms}).
|
||||
</p>
|
||||
)}
|
||||
</DataEntryForm>
|
||||
|
||||
{/* Calendar heatmap */}
|
||||
{calendarData.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-3">Belaggningskalender</h3>
|
||||
<div className="rounded-xl border p-4">
|
||||
<div className="grid grid-cols-7 gap-1 text-xs text-muted-foreground mb-2">
|
||||
{['Man', 'Tis', 'Ons', 'Tor', 'Fre', 'Lor', 'Son'].map(d => (
|
||||
<div key={d} className="text-center">{d}</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{/* Offset for first day of month */}
|
||||
{calendarData.length > 0 && Array.from({ length: (calendarData[0].dayOfWeek + 6) % 7 }).map((_, i) => (
|
||||
<div key={`empty-${i}`} className="aspect-square" />
|
||||
))}
|
||||
{calendarData.map(day => (
|
||||
<div
|
||||
key={day.date}
|
||||
className={cn(
|
||||
'aspect-square rounded-sm flex items-center justify-center text-xs',
|
||||
getOccupancyColor(day.occupancyPct),
|
||||
day.occupancyPct > 0 ? 'text-white' : 'text-muted-foreground'
|
||||
)}
|
||||
title={`${day.date}: ${day.occupancyPct}%`}
|
||||
>
|
||||
{parseInt(day.date.slice(-2))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 mt-3 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-3 h-3 rounded-sm bg-green-500" /> 80%+
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-3 h-3 rounded-sm bg-yellow-500" /> 50-79%
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-3 h-3 rounded-sm bg-red-500" /> 1-49%
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-3 h-3 rounded-sm bg-muted" /> Ingen data
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Daily data table */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-3">Daglig data</h3>
|
||||
{entries.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Ingen data registrerad i vald period.</p>
|
||||
) : (
|
||||
<div className="rounded-xl border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Datum</TableHead>
|
||||
<TableHead className="text-right">Belagda</TableHead>
|
||||
<TableHead className="text-right">Ur drift</TableHead>
|
||||
<TableHead>Orsak</TableHead>
|
||||
<TableHead className="text-right">Lediga</TableHead>
|
||||
<TableHead className="text-right">Belaggning</TableHead>
|
||||
<TableHead className="w-20"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{entries.map(e => {
|
||||
const pct = totalRooms > 0 ? Math.round((e.roomsOccupied / totalRooms) * 100) : 0
|
||||
const available = totalRooms - e.roomsOccupied - e.roomsOutOfOrder
|
||||
return (
|
||||
<TableRow key={e.date}>
|
||||
<TableCell className="font-medium">{e.date}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{e.roomsOccupied} / {totalRooms}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{e.roomsOutOfOrder}</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
{e.roomsOutOfOrder > 0 ? (e.reason ?? '-') : '-'}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{available}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{pct}%</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<Button variant="ghost" size="sm" onClick={() => openEditDialog(e)}>
|
||||
<Pencil className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => openDeleteDialog(e.date)}>
|
||||
<Trash2 className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Edit entry dialog */}
|
||||
<EditEntryDialog
|
||||
open={editDialogOpen}
|
||||
onOpenChange={setEditDialogOpen}
|
||||
title="Redigera belaggning"
|
||||
description={`Andrar data for ${editDate}`}
|
||||
onSave={handleSaveEdit}
|
||||
isSaving={isSavingEdit}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-date">Datum</Label>
|
||||
<Input id="edit-date" type="date" value={editDate} disabled />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-occupied">Belagda rum</Label>
|
||||
<Input
|
||||
id="edit-occupied"
|
||||
type="number"
|
||||
min="0"
|
||||
max={totalRooms}
|
||||
value={editOccupied}
|
||||
onChange={e => setEditOccupied(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-ooo">Ur drift</Label>
|
||||
<Input
|
||||
id="edit-ooo"
|
||||
type="number"
|
||||
min="0"
|
||||
value={editOoo}
|
||||
onChange={e => setEditOoo(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-reason">Orsak (ur drift)</Label>
|
||||
<Select value={editReason} onValueChange={(val) => setEditReason(val as OooReason)}>
|
||||
<SelectTrigger id="edit-reason">
|
||||
<SelectValue placeholder="Valj orsak" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{OOO_REASONS.map(r => (
|
||||
<SelectItem key={r} value={r}>{r}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{editExceedsTotal && (
|
||||
<p className="text-sm text-red-600">
|
||||
Belagda rum ({editOccupiedNum}) + ur drift ({editOooNum}) = {editOccupiedNum + editOooNum} overstiger totalt antal rum ({totalRooms}).
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</EditEntryDialog>
|
||||
|
||||
{/* Confirm delete dialog */}
|
||||
<ConfirmDeleteDialog
|
||||
open={deleteDialogOpen}
|
||||
onOpenChange={setDeleteDialogOpen}
|
||||
title="Ta bort belaggningsdata"
|
||||
description={`Vill du ta bort belaggningsdata for ${deleteDate}? Atgarden kan inte angras.`}
|
||||
onConfirm={handleDelete}
|
||||
isDeleting={isDeleting}
|
||||
/>
|
||||
|
||||
{/* Settings dialog */}
|
||||
<EditEntryDialog
|
||||
open={settingsDialogOpen}
|
||||
onOpenChange={setSettingsDialogOpen}
|
||||
title="Andra antal rum"
|
||||
description={`Nuvarande antal rum: ${totalRooms}`}
|
||||
onSave={handleSaveSettings}
|
||||
isSaving={isSavingSettings}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="settings-rooms">Totalt antal rum</Label>
|
||||
<Input
|
||||
id="settings-rooms"
|
||||
type="number"
|
||||
min="1"
|
||||
value={newTotalRooms}
|
||||
onChange={e => setNewTotalRooms(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</EditEntryDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,588 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useMemo } from 'react'
|
||||
import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
|
||||
import { useExtensionData } from '@/lib/extensions/use-extension-data'
|
||||
import KPICard from '@/components/extensions/shared/KPICard'
|
||||
import DateRangeFilter from '@/components/extensions/shared/DateRangeFilter'
|
||||
import DataEntryForm from '@/components/extensions/shared/DataEntryForm'
|
||||
import SetupPrompt from '@/components/extensions/shared/SetupPrompt'
|
||||
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 {
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Pencil, Trash2, ArrowUp, ArrowDown, Minus, Settings } from 'lucide-react'
|
||||
import { validateMaxNumber, validatePositiveNumber } from '@/lib/extensions/validation'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function computePreviousPeriod(start: string, end: string): { start: string; end: string } {
|
||||
const startDate = new Date(start + 'T00:00:00')
|
||||
const endDate = new Date(end + 'T00:00:00')
|
||||
const durationMs = endDate.getTime() - startDate.getTime()
|
||||
const prevEnd = new Date(startDate.getTime() - 1)
|
||||
const prevStart = new Date(prevEnd.getTime() - durationMs)
|
||||
return {
|
||||
start: prevStart.toISOString().slice(0, 10),
|
||||
end: prevEnd.toISOString().slice(0, 10),
|
||||
}
|
||||
}
|
||||
|
||||
function DeltaArrow({ current, previous, higherIsBetter = true }: {
|
||||
current: number
|
||||
previous: number
|
||||
higherIsBetter?: boolean
|
||||
}) {
|
||||
const delta = Math.round((current - previous) * 100) / 100
|
||||
if (delta === 0 || (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>
|
||||
)
|
||||
}
|
||||
const improving = higherIsBetter ? delta > 0 : delta < 0
|
||||
return (
|
||||
<span className={cn(
|
||||
'inline-flex items-center gap-0.5 text-xs',
|
||||
improving ? 'text-green-600' : 'text-red-600'
|
||||
)}>
|
||||
{delta > 0
|
||||
? <ArrowUp className="h-3 w-3" />
|
||||
: <ArrowDown className="h-3 w-3" />
|
||||
}
|
||||
<span>{delta > 0 ? '+' : ''}{delta.toLocaleString('sv-SE')}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
interface DailyEntry {
|
||||
date: string
|
||||
roomsSold: number
|
||||
roomRevenue: number
|
||||
}
|
||||
|
||||
function computeKPIs(entries: DailyEntry[], totalRooms: number) {
|
||||
const totalRevenue = entries.reduce((s, e) => s + e.roomRevenue, 0)
|
||||
const totalRoomsSold = entries.reduce((s, e) => s + e.roomsSold, 0)
|
||||
const daysInRange = entries.length
|
||||
const totalAvailableRooms = totalRooms * daysInRange
|
||||
|
||||
const revpar = totalAvailableRooms > 0
|
||||
? Math.round((totalRevenue / totalAvailableRooms) * 100) / 100
|
||||
: 0
|
||||
const adr = totalRoomsSold > 0
|
||||
? Math.round((totalRevenue / totalRoomsSold) * 100) / 100
|
||||
: 0
|
||||
const occupancyPct = totalAvailableRooms > 0
|
||||
? Math.round((totalRoomsSold / totalAvailableRooms) * 10000) / 100
|
||||
: 0
|
||||
|
||||
return { totalRevenue, totalRoomsSold, revpar, adr, occupancyPct }
|
||||
}
|
||||
|
||||
export default function RevparWorkspace({}: WorkspaceComponentProps) {
|
||||
const now = new Date()
|
||||
const [dateRange, setDateRange] = useState({
|
||||
start: new Date(now.getFullYear(), now.getMonth(), 1).toISOString().slice(0, 10),
|
||||
end: new Date(now.getFullYear(), now.getMonth() + 1, 0).toISOString().slice(0, 10),
|
||||
})
|
||||
|
||||
const prevPeriod = useMemo(
|
||||
() => computePreviousPeriod(dateRange.start, dateRange.end),
|
||||
[dateRange.start, dateRange.end]
|
||||
)
|
||||
|
||||
const { data, save, remove, refresh, isLoading } = useExtensionData('hotel', 'revpar')
|
||||
const settings = data.find(d => d.key === 'settings')?.value as { totalRooms?: number } | undefined
|
||||
const totalRooms = settings?.totalRooms ?? 0
|
||||
|
||||
// All daily entries (unfiltered by date, for period comparison)
|
||||
const allEntries = useMemo(() =>
|
||||
data.filter(d => d.key.startsWith('daily:'))
|
||||
.map(d => ({
|
||||
date: d.key.replace('daily:', ''),
|
||||
...(d.value as { roomsSold: number; roomRevenue: number }),
|
||||
}))
|
||||
, [data])
|
||||
|
||||
// Current period entries
|
||||
const entries = useMemo(() =>
|
||||
allEntries
|
||||
.filter(e => e.date >= dateRange.start && e.date <= dateRange.end)
|
||||
.sort((a, b) => b.date.localeCompare(a.date))
|
||||
, [allEntries, dateRange])
|
||||
|
||||
// Previous period entries
|
||||
const prevEntries = useMemo(() =>
|
||||
allEntries
|
||||
.filter(e => e.date >= prevPeriod.start && e.date <= prevPeriod.end)
|
||||
, [allEntries, prevPeriod])
|
||||
|
||||
// Form state
|
||||
const [entryDate, setEntryDate] = useState(now.toISOString().slice(0, 10))
|
||||
const [roomsSold, setRoomsSold] = useState('')
|
||||
const [roomRevenue, setRoomRevenue] = useState('')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
// Edit dialog state
|
||||
const [editDialogOpen, setEditDialogOpen] = useState(false)
|
||||
const [editDate, setEditDate] = useState('')
|
||||
const [editRoomsSold, setEditRoomsSold] = useState('')
|
||||
const [editRoomRevenue, setEditRoomRevenue] = useState('')
|
||||
const [isSavingEdit, setIsSavingEdit] = useState(false)
|
||||
|
||||
// Delete dialog state
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||
const [deleteDate, setDeleteDate] = useState('')
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
|
||||
// Settings dialog state
|
||||
const [settingsDialogOpen, setSettingsDialogOpen] = useState(false)
|
||||
const [settingsRoomCount, setSettingsRoomCount] = useState('')
|
||||
const [isSavingSettings, setIsSavingSettings] = useState(false)
|
||||
|
||||
// Current period KPIs
|
||||
const current = computeKPIs(entries, totalRooms)
|
||||
|
||||
// Previous period KPIs
|
||||
const prev = computeKPIs(prevEntries, totalRooms)
|
||||
|
||||
// --- Input validation ---
|
||||
const roomsSoldNum = parseInt(roomsSold)
|
||||
const roomRevenueNum = parseFloat(roomRevenue)
|
||||
const roomsSoldError = roomsSold !== ''
|
||||
? validateMaxNumber(roomsSold, totalRooms)
|
||||
? `Kan inte overskrida ${totalRooms} rum`
|
||||
: null
|
||||
: null
|
||||
const roomRevenueError = roomRevenue !== ''
|
||||
? validatePositiveNumber(roomRevenue)
|
||||
: null
|
||||
const formValid = !isNaN(roomsSoldNum)
|
||||
&& roomsSoldNum >= 0
|
||||
&& roomsSoldNum <= totalRooms
|
||||
&& !isNaN(roomRevenueNum)
|
||||
&& roomRevenueNum > 0
|
||||
|
||||
// Edit dialog validation
|
||||
const editRoomsSoldNum = parseInt(editRoomsSold)
|
||||
const editRoomRevenueNum = parseFloat(editRoomRevenue)
|
||||
const editRoomsSoldError = editRoomsSold !== ''
|
||||
? validateMaxNumber(editRoomsSold, totalRooms)
|
||||
? `Kan inte overskrida ${totalRooms} rum`
|
||||
: null
|
||||
: null
|
||||
const editRoomRevenueError = editRoomRevenue !== ''
|
||||
? validatePositiveNumber(editRoomRevenue)
|
||||
: null
|
||||
const editFormValid = !isNaN(editRoomsSoldNum)
|
||||
&& editRoomsSoldNum >= 0
|
||||
&& editRoomsSoldNum <= totalRooms
|
||||
&& !isNaN(editRoomRevenueNum)
|
||||
&& editRoomRevenueNum > 0
|
||||
|
||||
// Settings validation
|
||||
const settingsRoomCountNum = parseInt(settingsRoomCount)
|
||||
const settingsValid = !isNaN(settingsRoomCountNum) && settingsRoomCountNum > 0
|
||||
|
||||
// Monthly trend with all three metrics
|
||||
const monthlyTrend = useMemo(() => {
|
||||
const map = new Map<string, { revenue: number; rooms: number; days: number }>()
|
||||
for (const e of entries) {
|
||||
const month = e.date.slice(0, 7)
|
||||
const existing = map.get(month) ?? { revenue: 0, rooms: 0, days: 0 }
|
||||
existing.revenue += e.roomRevenue
|
||||
existing.rooms += e.roomsSold
|
||||
existing.days++
|
||||
map.set(month, existing)
|
||||
}
|
||||
return Array.from(map.entries())
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([month, d]) => {
|
||||
const available = totalRooms * d.days
|
||||
return {
|
||||
month,
|
||||
revpar: available > 0 ? Math.round((d.revenue / available) * 100) / 100 : 0,
|
||||
adr: d.rooms > 0 ? Math.round((d.revenue / d.rooms) * 100) / 100 : 0,
|
||||
occupancy: available > 0 ? Math.round((d.rooms / available) * 10000) / 100 : 0,
|
||||
}
|
||||
})
|
||||
}, [entries, totalRooms])
|
||||
|
||||
// --- Handlers ---
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!formValid) return
|
||||
setIsSubmitting(true)
|
||||
await save(`daily:${entryDate}`, { roomsSold: roomsSoldNum, roomRevenue: roomRevenueNum })
|
||||
setRoomsSold('')
|
||||
setRoomRevenue('')
|
||||
await refresh()
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
|
||||
const openEditDialog = (entry: DailyEntry) => {
|
||||
setEditDate(entry.date)
|
||||
setEditRoomsSold(String(entry.roomsSold))
|
||||
setEditRoomRevenue(String(entry.roomRevenue))
|
||||
setEditDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleSaveEdit = async () => {
|
||||
if (!editFormValid) return
|
||||
setIsSavingEdit(true)
|
||||
await save(`daily:${editDate}`, {
|
||||
roomsSold: editRoomsSoldNum,
|
||||
roomRevenue: editRoomRevenueNum,
|
||||
})
|
||||
await refresh()
|
||||
setIsSavingEdit(false)
|
||||
}
|
||||
|
||||
const openDeleteDialog = (date: string) => {
|
||||
setDeleteDate(date)
|
||||
setDeleteDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
setIsDeleting(true)
|
||||
await remove(`daily:${deleteDate}`)
|
||||
setIsDeleting(false)
|
||||
}
|
||||
|
||||
const openSettingsDialog = () => {
|
||||
setSettingsRoomCount(String(totalRooms))
|
||||
setSettingsDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleSaveSettings = async () => {
|
||||
if (!settingsValid) return
|
||||
setIsSavingSettings(true)
|
||||
await save('settings', { totalRooms: settingsRoomCountNum })
|
||||
await refresh()
|
||||
setIsSavingSettings(false)
|
||||
}
|
||||
|
||||
const handleSetup = async (values: Record<string, string>) => {
|
||||
await save('settings', { totalRooms: parseInt(values.totalRooms) || 0 })
|
||||
}
|
||||
|
||||
if (isLoading) return <ExtensionLoadingSkeleton />
|
||||
|
||||
if (!totalRooms) {
|
||||
return (
|
||||
<SetupPrompt
|
||||
title="Konfigurera RevPAR"
|
||||
description="Ange antal rum pa hotellet for att borja berakna RevPAR."
|
||||
fields={[{ key: 'totalRooms', label: 'Antal rum', type: 'number', placeholder: 'T.ex. 50' }]}
|
||||
onSave={handleSetup}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<DateRangeFilter onRangeChange={(start, end) => setDateRange({ start, end })} />
|
||||
<Button variant="ghost" size="sm" onClick={openSettingsDialog}>
|
||||
<Settings className="h-4 w-4 mr-1.5" />
|
||||
{totalRooms} rum
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* KPI Cards with delta indicators */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-4 gap-4">
|
||||
<KPICard label="RevPAR" value={current.revpar.toLocaleString('sv-SE')} suffix="kr" />
|
||||
<KPICard label="ADR" value={current.adr.toLocaleString('sv-SE')} suffix="kr" />
|
||||
<KPICard label="Belaggning" value={current.occupancyPct} suffix="%" />
|
||||
<KPICard label="Total intakt" value={current.totalRevenue.toLocaleString('sv-SE')} suffix="kr" />
|
||||
</div>
|
||||
|
||||
{/* Period comparison */}
|
||||
<div className="rounded-xl border p-4">
|
||||
<h3 className="text-sm font-semibold mb-3">Periodjamforelse</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">RevPAR</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg font-semibold tabular-nums">
|
||||
{current.revpar.toLocaleString('sv-SE')} kr
|
||||
</span>
|
||||
<DeltaArrow current={current.revpar} previous={prev.revpar} higherIsBetter />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Foregaende: {prev.revpar.toLocaleString('sv-SE')} kr
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">ADR</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg font-semibold tabular-nums">
|
||||
{current.adr.toLocaleString('sv-SE')} kr
|
||||
</span>
|
||||
<DeltaArrow current={current.adr} previous={prev.adr} higherIsBetter />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Foregaende: {prev.adr.toLocaleString('sv-SE')} kr
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">Belaggning</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg font-semibold tabular-nums">
|
||||
{current.occupancyPct}%
|
||||
</span>
|
||||
<DeltaArrow current={current.occupancyPct} previous={prev.occupancyPct} higherIsBetter />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Foregaende: {prev.occupancyPct}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 pt-3 border-t">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Foregaende period: {prevPeriod.start} — {prevPeriod.end}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Data entry form with validation */}
|
||||
<DataEntryForm
|
||||
title="Registrera daglig data"
|
||||
onSubmit={handleSubmit}
|
||||
submitLabel="Registrera"
|
||||
isSubmitting={isSubmitting || !formValid}
|
||||
>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="revpar-date">Datum</Label>
|
||||
<Input
|
||||
id="revpar-date"
|
||||
type="date"
|
||||
value={entryDate}
|
||||
onChange={e => setEntryDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="revpar-rooms">Rum salda</Label>
|
||||
<Input
|
||||
id="revpar-rooms"
|
||||
type="number"
|
||||
min="0"
|
||||
max={totalRooms}
|
||||
placeholder="0"
|
||||
value={roomsSold}
|
||||
onChange={e => setRoomsSold(e.target.value)}
|
||||
className={cn(roomsSoldError && 'border-red-500')}
|
||||
/>
|
||||
{roomsSoldError && (
|
||||
<p className="text-xs text-red-600">{roomsSoldError}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="revpar-revenue">Rumsintakt (kr)</Label>
|
||||
<Input
|
||||
id="revpar-revenue"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
placeholder="0"
|
||||
value={roomRevenue}
|
||||
onChange={e => setRoomRevenue(e.target.value)}
|
||||
className={cn(roomRevenueError && 'border-red-500')}
|
||||
/>
|
||||
{roomRevenueError && (
|
||||
<p className="text-xs text-red-600">{roomRevenueError}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DataEntryForm>
|
||||
|
||||
{/* Monthly trend with RevPAR, ADR, Occupancy */}
|
||||
{monthlyTrend.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-3">Manadstrend</h3>
|
||||
<div className="rounded-xl border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Period</TableHead>
|
||||
<TableHead className="text-right">RevPAR</TableHead>
|
||||
<TableHead className="text-right">ADR</TableHead>
|
||||
<TableHead className="text-right">Belaggning</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{monthlyTrend.map(row => (
|
||||
<TableRow key={row.month}>
|
||||
<TableCell className="font-medium">{row.month}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{row.revpar.toLocaleString('sv-SE')} kr
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{row.adr.toLocaleString('sv-SE')} kr
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{row.occupancy}%
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Daily data table with edit and delete */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-3">Daglig data</h3>
|
||||
{entries.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Ingen data registrerad i vald period.</p>
|
||||
) : (
|
||||
<div className="rounded-xl border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Datum</TableHead>
|
||||
<TableHead className="text-right">Rum salda</TableHead>
|
||||
<TableHead className="text-right">Intakt</TableHead>
|
||||
<TableHead className="text-right">ADR</TableHead>
|
||||
<TableHead className="w-20"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{entries.map(e => (
|
||||
<TableRow key={e.date}>
|
||||
<TableCell className="font-medium">{e.date}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{e.roomsSold} / {totalRooms}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{e.roomRevenue.toLocaleString('sv-SE')} kr
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{e.roomsSold > 0
|
||||
? Math.round(e.roomRevenue / e.roomsSold).toLocaleString('sv-SE')
|
||||
: 0} kr
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => openEditDialog(e)}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => openDeleteDialog(e.date)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Edit entry dialog */}
|
||||
<EditEntryDialog
|
||||
open={editDialogOpen}
|
||||
onOpenChange={setEditDialogOpen}
|
||||
title="Redigera daglig data"
|
||||
description={`Redigera data for ${editDate}`}
|
||||
onSave={handleSaveEdit}
|
||||
isSaving={isSavingEdit}
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-date">Datum</Label>
|
||||
<Input
|
||||
id="edit-date"
|
||||
type="date"
|
||||
value={editDate}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-rooms">Rum salda</Label>
|
||||
<Input
|
||||
id="edit-rooms"
|
||||
type="number"
|
||||
min="0"
|
||||
max={totalRooms}
|
||||
value={editRoomsSold}
|
||||
onChange={e => setEditRoomsSold(e.target.value)}
|
||||
className={cn(editRoomsSoldError && 'border-red-500')}
|
||||
/>
|
||||
{editRoomsSoldError && (
|
||||
<p className="text-xs text-red-600">{editRoomsSoldError}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-revenue">Rumsintakt (kr)</Label>
|
||||
<Input
|
||||
id="edit-revenue"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={editRoomRevenue}
|
||||
onChange={e => setEditRoomRevenue(e.target.value)}
|
||||
className={cn(editRoomRevenueError && 'border-red-500')}
|
||||
/>
|
||||
{editRoomRevenueError && (
|
||||
<p className="text-xs text-red-600">{editRoomRevenueError}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</EditEntryDialog>
|
||||
|
||||
{/* Confirm delete dialog */}
|
||||
<ConfirmDeleteDialog
|
||||
open={deleteDialogOpen}
|
||||
onOpenChange={setDeleteDialogOpen}
|
||||
title="Ta bort registrering"
|
||||
description={`Vill du ta bort data for ${deleteDate}? Atgarden kan inte angras.`}
|
||||
onConfirm={handleConfirmDelete}
|
||||
isDeleting={isDeleting}
|
||||
/>
|
||||
|
||||
{/* Settings dialog */}
|
||||
<EditEntryDialog
|
||||
open={settingsDialogOpen}
|
||||
onOpenChange={setSettingsDialogOpen}
|
||||
title="Installningar"
|
||||
description="Andra antal rum pa hotellet."
|
||||
onSave={handleSaveSettings}
|
||||
isSaving={isSavingSettings}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="settings-rooms">Antal rum</Label>
|
||||
<Input
|
||||
id="settings-rooms"
|
||||
type="number"
|
||||
min="1"
|
||||
value={settingsRoomCount}
|
||||
onChange={e => setSettingsRoomCount(e.target.value)}
|
||||
/>
|
||||
{settingsRoomCount !== '' && !settingsValid && (
|
||||
<p className="text-xs text-red-600">Ange ett giltigt antal rum (minst 1)</p>
|
||||
)}
|
||||
</div>
|
||||
</EditEntryDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,817 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useMemo } from 'react'
|
||||
import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
|
||||
import { useAccountTotals } from '@/lib/extensions/use-account-totals'
|
||||
import { useExtensionData } from '@/lib/extensions/use-extension-data'
|
||||
import KPICard from '@/components/extensions/shared/KPICard'
|
||||
import DateRangeFilter from '@/components/extensions/shared/DateRangeFilter'
|
||||
import MonthlyTrendTable from '@/components/extensions/shared/MonthlyTrendTable'
|
||||
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 { 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 { Trash2, Pencil, ArrowUp, ArrowDown, Minus, Settings, Plus, X } from 'lucide-react'
|
||||
|
||||
const DEFAULT_CATEGORIES = ['Ol', 'Vin', 'Sprit']
|
||||
|
||||
const DEFAULT_PRICING: Record<string, number> = {
|
||||
Ol: 80,
|
||||
Vin: 120,
|
||||
Sprit: 200,
|
||||
}
|
||||
|
||||
interface LiterEntry {
|
||||
id: string
|
||||
date: string
|
||||
category: string
|
||||
liters: number
|
||||
}
|
||||
|
||||
interface Pricing {
|
||||
[category: string]: number
|
||||
}
|
||||
|
||||
function DeltaIndicator({ current, previous }: { current: number; previous: number }) {
|
||||
if (previous === 0) return <Minus className="h-3.5 w-3.5 text-muted-foreground inline" />
|
||||
const delta = Math.round(((current - previous) / previous) * 10000) / 100
|
||||
if (delta > 0) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-0.5 text-xs text-green-600">
|
||||
<ArrowUp className="h-3 w-3" />+{delta}%
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (delta < 0) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-0.5 text-xs text-red-600">
|
||||
<ArrowDown className="h-3 w-3" />{delta}%
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<span className="inline-flex items-center gap-0.5 text-xs text-muted-foreground">
|
||||
<Minus className="h-3 w-3" />0%
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default function EarningsPerLiterWorkspace({}: WorkspaceComponentProps) {
|
||||
const now = new Date()
|
||||
const [dateRange, setDateRange] = useState({
|
||||
start: new Date(now.getFullYear(), now.getMonth(), 1).toISOString().slice(0, 10),
|
||||
end: new Date(now.getFullYear(), now.getMonth() + 1, 0).toISOString().slice(0, 10),
|
||||
})
|
||||
|
||||
const { data, save, remove, refresh, isLoading: dataLoading } = useExtensionData('restaurant', 'earnings-per-liter')
|
||||
|
||||
// Settings: categories
|
||||
const settings = data.find(d => d.key === 'settings')?.value as { categories?: string[] } | undefined
|
||||
const categories = settings?.categories ?? DEFAULT_CATEGORIES
|
||||
|
||||
// Pricing per category (kr per liter)
|
||||
const pricingData = data.find(d => d.key === 'pricing')?.value as Pricing | undefined
|
||||
const pricing: Pricing = useMemo(() => {
|
||||
const base: Pricing = {}
|
||||
for (const cat of categories) {
|
||||
base[cat] = pricingData?.[cat] ?? DEFAULT_PRICING[cat] ?? 100
|
||||
}
|
||||
return base
|
||||
}, [categories, pricingData])
|
||||
|
||||
// Entries filtered by date range
|
||||
const entries: LiterEntry[] = useMemo(() =>
|
||||
data.filter(d => d.key.startsWith('entry:'))
|
||||
.map(d => ({
|
||||
id: d.key,
|
||||
...(d.value as { date: string; category: string; liters: number }),
|
||||
}))
|
||||
.filter(e => e.date >= dateRange.start && e.date <= dateRange.end)
|
||||
.sort((a, b) => b.date.localeCompare(a.date))
|
||||
, [data, dateRange])
|
||||
|
||||
// Previous period entries for comparison
|
||||
const prevPeriodEntries: LiterEntry[] = useMemo(() => {
|
||||
const startDate = new Date(dateRange.start)
|
||||
const endDate = new Date(dateRange.end)
|
||||
const durationMs = endDate.getTime() - startDate.getTime()
|
||||
const prevStart = new Date(startDate.getTime() - durationMs - 86400000)
|
||||
const prevEnd = new Date(startDate.getTime() - 86400000)
|
||||
const prevStartStr = prevStart.toISOString().slice(0, 10)
|
||||
const prevEndStr = prevEnd.toISOString().slice(0, 10)
|
||||
return data.filter(d => d.key.startsWith('entry:'))
|
||||
.map(d => ({
|
||||
id: d.key,
|
||||
...(d.value as { date: string; category: string; liters: number }),
|
||||
}))
|
||||
.filter(e => e.date >= prevStartStr && e.date <= prevEndStr)
|
||||
}, [data, dateRange])
|
||||
|
||||
// Form state - single entry
|
||||
const [entryDate, setEntryDate] = useState(now.toISOString().slice(0, 10))
|
||||
const [category, setCategory] = useState(categories[0])
|
||||
const [liters, setLiters] = useState('')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
// Batch entry mode
|
||||
const [batchMode, setBatchMode] = useState(false)
|
||||
const [batchDate, setBatchDate] = useState(now.toISOString().slice(0, 10))
|
||||
const [batchLiters, setBatchLiters] = useState<Record<string, string>>({})
|
||||
const [isBatchSubmitting, setIsBatchSubmitting] = useState(false)
|
||||
|
||||
// Edit state
|
||||
const [editingEntry, setEditingEntry] = useState<LiterEntry | null>(null)
|
||||
const [editDate, setEditDate] = useState('')
|
||||
const [editCategory, setEditCategory] = useState('')
|
||||
const [editLiters, setEditLiters] = useState('')
|
||||
const [isSavingEdit, setIsSavingEdit] = useState(false)
|
||||
|
||||
// Delete confirmation state
|
||||
const [deletingEntry, setDeletingEntry] = useState<LiterEntry | null>(null)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
|
||||
// Category management state
|
||||
const [newCategoryName, setNewCategoryName] = useState('')
|
||||
const [editingPricing, setEditingPricing] = useState(false)
|
||||
const [pricingInputs, setPricingInputs] = useState<Record<string, string>>({})
|
||||
|
||||
// Alcohol revenue from bookkeeping (accounts 3000-3999)
|
||||
const { totalCredit: alcoholRevenue, isLoading: revenueLoading } = useAccountTotals({
|
||||
from: '3000', to: '3999',
|
||||
dateFrom: dateRange.start, dateTo: dateRange.end,
|
||||
})
|
||||
|
||||
// Calculations
|
||||
const totalLiters = entries.reduce((s, e) => s + e.liters, 0)
|
||||
const earningsPerLiter = totalLiters > 0
|
||||
? Math.round((alcoholRevenue / totalLiters) * 100) / 100
|
||||
: 0
|
||||
|
||||
// Estimated revenue based on pricing
|
||||
const estimatedRevenue = useMemo(() => {
|
||||
let total = 0
|
||||
for (const e of entries) {
|
||||
const price = pricing[e.category] ?? 100
|
||||
total += e.liters * price
|
||||
}
|
||||
return Math.round(total * 100) / 100
|
||||
}, [entries, pricing])
|
||||
|
||||
// Previous period calculations
|
||||
const prevTotalLiters = prevPeriodEntries.reduce((s, e) => s + e.liters, 0)
|
||||
const prevEstimatedRevenue = useMemo(() => {
|
||||
let total = 0
|
||||
for (const e of prevPeriodEntries) {
|
||||
const price = pricing[e.category] ?? 100
|
||||
total += e.liters * price
|
||||
}
|
||||
return Math.round(total * 100) / 100
|
||||
}, [prevPeriodEntries, pricing])
|
||||
const prevEarningsPerLiter = prevTotalLiters > 0
|
||||
? Math.round((prevEstimatedRevenue / prevTotalLiters) * 100) / 100
|
||||
: 0
|
||||
|
||||
// Category breakdown with per-category revenue = liters x avg price
|
||||
const categoryBreakdown = useMemo(() => {
|
||||
const map = new Map<string, number>()
|
||||
for (const e of entries) {
|
||||
map.set(e.category, (map.get(e.category) ?? 0) + e.liters)
|
||||
}
|
||||
return categories.map(cat => {
|
||||
const catLiters = map.get(cat) ?? 0
|
||||
const avgPrice = pricing[cat] ?? 100
|
||||
const estimatedRev = Math.round(catLiters * avgPrice * 100) / 100
|
||||
const eplCategory = catLiters > 0
|
||||
? Math.round((estimatedRev / catLiters) * 100) / 100
|
||||
: 0
|
||||
return {
|
||||
category: cat,
|
||||
liters: catLiters,
|
||||
avgPrice,
|
||||
estimatedRevenue: estimatedRev,
|
||||
earningsPerLiter: eplCategory,
|
||||
}
|
||||
})
|
||||
}, [entries, categories, pricing])
|
||||
|
||||
// Monthly trend
|
||||
const monthlyTrend = useMemo(() => {
|
||||
const map = new Map<string, number>()
|
||||
for (const e of entries) {
|
||||
const month = e.date.slice(0, 7)
|
||||
map.set(month, (map.get(month) ?? 0) + e.liters)
|
||||
}
|
||||
return Array.from(map.entries())
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([month, liters]) => ({ month, value: liters }))
|
||||
}, [entries])
|
||||
|
||||
// --- Handlers ---
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
const val = parseFloat(liters)
|
||||
if (isNaN(val) || val <= 0) return
|
||||
setIsSubmitting(true)
|
||||
const id = crypto.randomUUID()
|
||||
await save(`entry:${id}`, { date: entryDate, category, liters: val })
|
||||
setLiters('')
|
||||
await refresh()
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
|
||||
const handleBatchSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsBatchSubmitting(true)
|
||||
for (const cat of categories) {
|
||||
const val = parseFloat(batchLiters[cat] ?? '')
|
||||
if (!isNaN(val) && val > 0) {
|
||||
const id = crypto.randomUUID()
|
||||
await save(`entry:${id}`, { date: batchDate, category: cat, liters: val })
|
||||
}
|
||||
}
|
||||
setBatchLiters({})
|
||||
await refresh()
|
||||
setIsBatchSubmitting(false)
|
||||
}
|
||||
|
||||
const openEdit = (entry: LiterEntry) => {
|
||||
setEditingEntry(entry)
|
||||
setEditDate(entry.date)
|
||||
setEditCategory(entry.category)
|
||||
setEditLiters(String(entry.liters))
|
||||
}
|
||||
|
||||
const handleSaveEdit = async () => {
|
||||
if (!editingEntry) return
|
||||
const val = parseFloat(editLiters)
|
||||
if (isNaN(val) || val <= 0) return
|
||||
setIsSavingEdit(true)
|
||||
await save(editingEntry.id, { date: editDate, category: editCategory, liters: val })
|
||||
await refresh()
|
||||
setIsSavingEdit(false)
|
||||
setEditingEntry(null)
|
||||
}
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
if (!deletingEntry) return
|
||||
setIsDeleting(true)
|
||||
await remove(deletingEntry.id)
|
||||
setIsDeleting(false)
|
||||
setDeletingEntry(null)
|
||||
}
|
||||
|
||||
// Category management
|
||||
const handleAddCategory = async () => {
|
||||
const name = newCategoryName.trim()
|
||||
if (!name || categories.includes(name)) return
|
||||
const updated = [...categories, name]
|
||||
await save('settings', { ...settings, categories: updated })
|
||||
setNewCategoryName('')
|
||||
await refresh()
|
||||
}
|
||||
|
||||
const handleRemoveCategory = async (cat: string) => {
|
||||
const updated = categories.filter(c => c !== cat)
|
||||
if (updated.length === 0) return
|
||||
await save('settings', { ...settings, categories: updated })
|
||||
// Update pricing to remove the category
|
||||
if (pricingData) {
|
||||
const updatedPricing = { ...pricingData }
|
||||
delete updatedPricing[cat]
|
||||
await save('pricing', updatedPricing)
|
||||
}
|
||||
await refresh()
|
||||
}
|
||||
|
||||
const handleRenameCategory = async (oldName: string, newName: string) => {
|
||||
if (!newName.trim() || oldName === newName.trim()) return
|
||||
const trimmed = newName.trim()
|
||||
const updated = categories.map(c => c === oldName ? trimmed : c)
|
||||
await save('settings', { ...settings, categories: updated })
|
||||
// Update pricing key
|
||||
if (pricingData) {
|
||||
const updatedPricing = { ...pricingData }
|
||||
if (updatedPricing[oldName] !== undefined) {
|
||||
updatedPricing[trimmed] = updatedPricing[oldName]
|
||||
delete updatedPricing[oldName]
|
||||
}
|
||||
await save('pricing', updatedPricing)
|
||||
}
|
||||
await refresh()
|
||||
}
|
||||
|
||||
const handleSavePricing = async () => {
|
||||
const updated: Pricing = {}
|
||||
for (const cat of categories) {
|
||||
const val = parseFloat(pricingInputs[cat] ?? '')
|
||||
updated[cat] = !isNaN(val) && val > 0 ? Math.round(val * 100) / 100 : (pricing[cat] ?? 100)
|
||||
}
|
||||
await save('pricing', updated)
|
||||
setEditingPricing(false)
|
||||
await refresh()
|
||||
}
|
||||
|
||||
const startEditPricing = () => {
|
||||
const inputs: Record<string, string> = {}
|
||||
for (const cat of categories) {
|
||||
inputs[cat] = String(pricing[cat] ?? 100)
|
||||
}
|
||||
setPricingInputs(inputs)
|
||||
setEditingPricing(true)
|
||||
}
|
||||
|
||||
if (dataLoading || revenueLoading) return <ExtensionLoadingSkeleton />
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Tabs defaultValue="register">
|
||||
<TabsList>
|
||||
<TabsTrigger value="register">Registrera</TabsTrigger>
|
||||
<TabsTrigger value="overview">Oversikt</TabsTrigger>
|
||||
<TabsTrigger value="settings">Installningar</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* ---- REGISTER TAB ---- */}
|
||||
<TabsContent value="register" className="space-y-6 mt-4">
|
||||
<DateRangeFilter onRangeChange={(start, end) => setDateRange({ start, end })} />
|
||||
|
||||
{/* KPI row */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<KPICard
|
||||
label="Intakt per liter"
|
||||
value={earningsPerLiter.toLocaleString('sv-SE')}
|
||||
suffix="kr/l"
|
||||
trend={prevEarningsPerLiter > 0 ? {
|
||||
value: Math.round(((earningsPerLiter - prevEarningsPerLiter) / prevEarningsPerLiter) * 10000) / 100,
|
||||
label: 'mot foreg. period',
|
||||
} : undefined}
|
||||
/>
|
||||
<KPICard
|
||||
label="Totalt liter"
|
||||
value={(Math.round(totalLiters * 100) / 100).toLocaleString('sv-SE')}
|
||||
suffix="l"
|
||||
trend={prevTotalLiters > 0 ? {
|
||||
value: Math.round(((totalLiters - prevTotalLiters) / prevTotalLiters) * 10000) / 100,
|
||||
label: 'mot foreg. period',
|
||||
} : undefined}
|
||||
/>
|
||||
<KPICard
|
||||
label="Uppskattad intakt"
|
||||
value={estimatedRevenue.toLocaleString('sv-SE')}
|
||||
suffix="kr"
|
||||
/>
|
||||
<KPICard
|
||||
label="Bokford intakt"
|
||||
value={alcoholRevenue.toLocaleString('sv-SE')}
|
||||
suffix="kr"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Revenue comparison */}
|
||||
{estimatedRevenue > 0 && alcoholRevenue > 0 && (
|
||||
<div className="rounded-xl border p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Uppskattad vs bokford intakt</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Differens baserat pa snittkr/liter per kategori
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-lg font-semibold tabular-nums">
|
||||
{(Math.round((estimatedRevenue - alcoholRevenue) * 100) / 100).toLocaleString('sv-SE')} kr
|
||||
</p>
|
||||
<DeltaIndicator current={estimatedRevenue} previous={alcoholRevenue} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Single entry form / batch mode toggle */}
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<Button
|
||||
variant={batchMode ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setBatchMode(!batchMode)}
|
||||
>
|
||||
{batchMode ? 'Enkel registrering' : 'Registrera flera'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{batchMode ? (
|
||||
<DataEntryForm
|
||||
title="Registrera flera kategorier"
|
||||
onSubmit={handleBatchSubmit}
|
||||
submitLabel="Registrera alla"
|
||||
isSubmitting={isBatchSubmitting}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="epl-batch-date">Datum</Label>
|
||||
<Input
|
||||
id="epl-batch-date"
|
||||
type="date"
|
||||
value={batchDate}
|
||||
onChange={e => setBatchDate(e.target.value)}
|
||||
className="max-w-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{categories.map(cat => (
|
||||
<div key={cat} className="space-y-2">
|
||||
<Label htmlFor={`epl-batch-${cat}`}>
|
||||
{cat} (liter)
|
||||
</Label>
|
||||
<Input
|
||||
id={`epl-batch-${cat}`}
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
placeholder="0.00"
|
||||
value={batchLiters[cat] ?? ''}
|
||||
onChange={e => setBatchLiters(prev => ({ ...prev, [cat]: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</DataEntryForm>
|
||||
) : (
|
||||
<DataEntryForm
|
||||
title="Registrera literforbrukning"
|
||||
onSubmit={handleSubmit}
|
||||
submitLabel="Registrera"
|
||||
isSubmitting={isSubmitting}
|
||||
>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="epl-date">Datum</Label>
|
||||
<Input id="epl-date" type="date" value={entryDate} onChange={e => setEntryDate(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="epl-category">Kategori</Label>
|
||||
<Select value={category} onValueChange={setCategory}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{categories.map(c => <SelectItem key={c} value={c}>{c}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="epl-liters">Antal liter</Label>
|
||||
<Input id="epl-liters" type="number" step="0.01" min="0" placeholder="0.00" value={liters} onChange={e => setLiters(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
</DataEntryForm>
|
||||
)}
|
||||
|
||||
{/* Entry history */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-3">Senaste registreringar</h3>
|
||||
{entries.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Inga registreringar i vald period.</p>
|
||||
) : (
|
||||
<div className="rounded-xl border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Datum</TableHead>
|
||||
<TableHead>Kategori</TableHead>
|
||||
<TableHead className="text-right">Liter</TableHead>
|
||||
<TableHead className="text-right">Uppsk. intakt</TableHead>
|
||||
<TableHead className="w-20"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{entries.slice(0, 20).map(e => {
|
||||
const entryRevenue = Math.round(e.liters * (pricing[e.category] ?? 100) * 100) / 100
|
||||
return (
|
||||
<TableRow key={e.id}>
|
||||
<TableCell>{e.date}</TableCell>
|
||||
<TableCell>{e.category}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{e.liters.toLocaleString('sv-SE')} l</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{entryRevenue.toLocaleString('sv-SE')} kr</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="sm" onClick={() => openEdit(e)}>
|
||||
<Pencil className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setDeletingEntry(e)}>
|
||||
<Trash2 className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* ---- OVERVIEW TAB ---- */}
|
||||
<TabsContent value="overview" className="space-y-6 mt-4">
|
||||
<DateRangeFilter onRangeChange={(start, end) => setDateRange({ start, end })} />
|
||||
|
||||
{/* Period comparison KPIs */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="rounded-xl border p-4 space-y-1">
|
||||
<p className="text-xs text-muted-foreground">Intakt/liter (nuvarande)</p>
|
||||
<p className="text-2xl font-semibold tabular-nums">{earningsPerLiter.toLocaleString('sv-SE')} kr/l</p>
|
||||
{prevEarningsPerLiter > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">Foreg: {prevEarningsPerLiter.toLocaleString('sv-SE')} kr/l</span>
|
||||
<DeltaIndicator current={earningsPerLiter} previous={prevEarningsPerLiter} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="rounded-xl border p-4 space-y-1">
|
||||
<p className="text-xs text-muted-foreground">Liter (nuvarande)</p>
|
||||
<p className="text-2xl font-semibold tabular-nums">{(Math.round(totalLiters * 100) / 100).toLocaleString('sv-SE')} l</p>
|
||||
{prevTotalLiters > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">Foreg: {(Math.round(prevTotalLiters * 100) / 100).toLocaleString('sv-SE')} l</span>
|
||||
<DeltaIndicator current={totalLiters} previous={prevTotalLiters} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="rounded-xl border p-4 space-y-1">
|
||||
<p className="text-xs text-muted-foreground">Uppsk. intakt (nuvarande)</p>
|
||||
<p className="text-2xl font-semibold tabular-nums">{estimatedRevenue.toLocaleString('sv-SE')} kr</p>
|
||||
{prevEstimatedRevenue > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">Foreg: {prevEstimatedRevenue.toLocaleString('sv-SE')} kr</span>
|
||||
<DeltaIndicator current={estimatedRevenue} previous={prevEstimatedRevenue} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category breakdown */}
|
||||
{categoryBreakdown.some(c => c.liters > 0) && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-3">Per kategori</h3>
|
||||
<div className="rounded-xl border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Kategori</TableHead>
|
||||
<TableHead className="text-right">Liter</TableHead>
|
||||
<TableHead className="text-right">Snittpris/l</TableHead>
|
||||
<TableHead className="text-right">Uppsk. intakt</TableHead>
|
||||
<TableHead className="text-right">Kr/liter</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{categoryBreakdown.map(c => (
|
||||
<TableRow key={c.category}>
|
||||
<TableCell className="font-medium">{c.category}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{c.liters.toLocaleString('sv-SE')} l</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{c.avgPrice.toLocaleString('sv-SE')} kr</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{c.estimatedRevenue.toLocaleString('sv-SE')} kr</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{c.earningsPerLiter.toLocaleString('sv-SE')} kr/l</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
<TableRow className="font-semibold">
|
||||
<TableCell>Totalt</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{(Math.round(totalLiters * 100) / 100).toLocaleString('sv-SE')} l</TableCell>
|
||||
<TableCell className="text-right"></TableCell>
|
||||
<TableCell className="text-right tabular-nums">{estimatedRevenue.toLocaleString('sv-SE')} kr</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{earningsPerLiter.toLocaleString('sv-SE')} kr/l</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Monthly trend */}
|
||||
{monthlyTrend.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-3">Manadstrend</h3>
|
||||
<MonthlyTrendTable rows={monthlyTrend} valueLabel="Liter" valueSuffix="l" />
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* ---- SETTINGS TAB ---- */}
|
||||
<TabsContent value="settings" className="space-y-6 mt-4">
|
||||
{/* Category management */}
|
||||
<div className="rounded-xl border p-4 space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">Kategorier</h3>
|
||||
<p className="text-xs text-muted-foreground">Lagg till, ta bort eller byt namn pa dryckkategorier.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Ny kategori"
|
||||
value={newCategoryName}
|
||||
onChange={e => setNewCategoryName(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleAddCategory()}
|
||||
className="max-w-xs"
|
||||
/>
|
||||
<Button size="sm" onClick={handleAddCategory} disabled={!newCategoryName.trim()}>
|
||||
<Plus className="h-4 w-4 mr-1" /> Lagg till
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{categories.length > 0 && (
|
||||
<div className="rounded-xl border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Namn</TableHead>
|
||||
<TableHead className="w-24"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{categories.map(cat => (
|
||||
<CategoryRow
|
||||
key={cat}
|
||||
name={cat}
|
||||
canRemove={categories.length > 1}
|
||||
onRename={(newName) => handleRenameCategory(cat, newName)}
|
||||
onRemove={() => handleRemoveCategory(cat)}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pricing settings */}
|
||||
<div className="rounded-xl border p-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">Snittpris per liter</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Anvands for att berakna uppskattad intakt per kategori.
|
||||
</p>
|
||||
</div>
|
||||
{!editingPricing && (
|
||||
<Button size="sm" variant="ghost" onClick={startEditPricing}>
|
||||
<Settings className="h-4 w-4 mr-1" /> Andra
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{editingPricing ? (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{categories.map(cat => (
|
||||
<div key={cat} className="space-y-2">
|
||||
<Label htmlFor={`pricing-${cat}`}>{cat} (kr/liter)</Label>
|
||||
<Input
|
||||
id={`pricing-${cat}`}
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={pricingInputs[cat] ?? ''}
|
||||
onChange={e => setPricingInputs(prev => ({ ...prev, [cat]: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={handleSavePricing}>Spara</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setEditingPricing(false)}>Avbryt</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Kategori</TableHead>
|
||||
<TableHead className="text-right">Pris (kr/l)</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{categories.map(cat => (
|
||||
<TableRow key={cat}>
|
||||
<TableCell className="font-medium">{cat}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{(pricing[cat] ?? 100).toLocaleString('sv-SE')} kr</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Edit entry dialog */}
|
||||
<EditEntryDialog
|
||||
open={editingEntry !== null}
|
||||
onOpenChange={(open) => { if (!open) setEditingEntry(null) }}
|
||||
title="Redigera registrering"
|
||||
description="Andra datum, kategori eller antal liter."
|
||||
onSave={handleSaveEdit}
|
||||
isSaving={isSavingEdit}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-date">Datum</Label>
|
||||
<Input id="edit-date" type="date" value={editDate} onChange={e => setEditDate(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-category">Kategori</Label>
|
||||
<Select value={editCategory} onValueChange={setEditCategory}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{categories.map(c => <SelectItem key={c} value={c}>{c}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-liters">Antal liter</Label>
|
||||
<Input id="edit-liters" type="number" step="0.01" min="0" value={editLiters} onChange={e => setEditLiters(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
</EditEntryDialog>
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
<ConfirmDeleteDialog
|
||||
open={deletingEntry !== null}
|
||||
onOpenChange={(open) => { if (!open) setDeletingEntry(null) }}
|
||||
title="Ta bort registrering"
|
||||
description={deletingEntry ? `Vill du ta bort ${deletingEntry.liters} l ${deletingEntry.category} fran ${deletingEntry.date}?` : ''}
|
||||
onConfirm={handleConfirmDelete}
|
||||
isDeleting={isDeleting}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Inline sub-component for category row with rename support
|
||||
function CategoryRow({
|
||||
name,
|
||||
canRemove,
|
||||
onRename,
|
||||
onRemove,
|
||||
}: {
|
||||
name: string
|
||||
canRemove: boolean
|
||||
onRename: (newName: string) => Promise<void>
|
||||
onRemove: () => Promise<void>
|
||||
}) {
|
||||
const [isRenaming, setIsRenaming] = useState(false)
|
||||
const [newName, setNewName] = useState(name)
|
||||
|
||||
const handleRename = async () => {
|
||||
await onRename(newName)
|
||||
setIsRenaming(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
{isRenaming ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={newName}
|
||||
onChange={e => setNewName(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleRename()}
|
||||
className="h-8 max-w-[200px]"
|
||||
autoFocus
|
||||
/>
|
||||
<Button size="sm" variant="outline" onClick={handleRename}>Spara</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => { setIsRenaming(false); setNewName(name) }}>Avbryt</Button>
|
||||
</div>
|
||||
) : (
|
||||
<span className="font-medium">{name}</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1 justify-end">
|
||||
{!isRenaming && (
|
||||
<Button variant="ghost" size="sm" onClick={() => setIsRenaming(true)}>
|
||||
<Pencil className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
)}
|
||||
{canRemove && !isRenaming && (
|
||||
<Button variant="ghost" size="sm" onClick={onRemove}>
|
||||
<X className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
}
|
||||
@@ -1,552 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useMemo, useCallback } from 'react'
|
||||
import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
|
||||
import { useAccountTotals } from '@/lib/extensions/use-account-totals'
|
||||
import { useExtensionData } from '@/lib/extensions/use-extension-data'
|
||||
import KPICard from '@/components/extensions/shared/KPICard'
|
||||
import DateRangeFilter from '@/components/extensions/shared/DateRangeFilter'
|
||||
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 { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { ArrowUp, ArrowDown, Minus } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const FOOD_CATEGORIES = ['Kott', 'Fisk', 'Gronsaker', 'Mejeri', 'Drycker', 'Ovrigt'] as const
|
||||
type FoodCategory = typeof FOOD_CATEGORIES[number]
|
||||
|
||||
function computePreviousPeriod(start: string, end: string): { start: string; end: string } {
|
||||
const startDate = new Date(start + 'T00:00:00')
|
||||
const endDate = new Date(end + 'T00:00:00')
|
||||
const durationMs = endDate.getTime() - startDate.getTime()
|
||||
const prevEnd = new Date(startDate.getTime() - 1)
|
||||
const prevStart = new Date(prevEnd.getTime() - durationMs)
|
||||
return {
|
||||
start: prevStart.toISOString().slice(0, 10),
|
||||
end: prevEnd.toISOString().slice(0, 10),
|
||||
}
|
||||
}
|
||||
|
||||
function DeltaArrow({ current, previous }: { current: number; previous: number }) {
|
||||
const delta = Math.round((current - previous) * 100) / 100
|
||||
if (delta === 0 || (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 pp</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
// For food cost: lower is better, so negative delta = green (improving)
|
||||
const improving = delta < 0
|
||||
return (
|
||||
<span className={cn(
|
||||
'inline-flex items-center gap-0.5 text-xs',
|
||||
improving ? 'text-green-600' : 'text-red-600'
|
||||
)}>
|
||||
{delta < 0
|
||||
? <ArrowDown className="h-3 w-3" />
|
||||
: <ArrowUp className="h-3 w-3" />
|
||||
}
|
||||
<span>{delta > 0 ? '+' : ''}{delta} pp</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default function FoodCostWorkspace({}: WorkspaceComponentProps) {
|
||||
const now = new Date()
|
||||
const [dateRange, setDateRange] = useState({
|
||||
start: new Date(now.getFullYear(), now.getMonth(), 1).toISOString().slice(0, 10),
|
||||
end: new Date(now.getFullYear(), now.getMonth() + 1, 0).toISOString().slice(0, 10),
|
||||
})
|
||||
|
||||
const prevPeriod = useMemo(
|
||||
() => computePreviousPeriod(dateRange.start, dateRange.end),
|
||||
[dateRange.start, dateRange.end]
|
||||
)
|
||||
|
||||
// Yearly range for monthly trend
|
||||
const yearStart = `${now.getFullYear()}-01-01`
|
||||
const yearEnd = `${now.getFullYear()}-12-31`
|
||||
|
||||
const { data: extData, save, remove, isLoading: settingsLoading } = useExtensionData('restaurant', 'food-cost')
|
||||
const settings = extData.find(d => d.key === 'settings')?.value as { targetPct?: number } | undefined
|
||||
|
||||
const [targetPctInput, setTargetPct] = useState<string | null>(null)
|
||||
const [editingTarget, setEditingTarget] = useState(false)
|
||||
const targetPct = targetPctInput ?? (settings?.targetPct != null ? String(settings.targetPct) : '')
|
||||
|
||||
// Category assignments from extension_data (key = "category:{accountNumber}")
|
||||
const categoryMap = useMemo(() => {
|
||||
const map: Record<string, FoodCategory> = {}
|
||||
for (const d of extData) {
|
||||
if (d.key.startsWith('category:')) {
|
||||
const account = d.key.replace('category:', '')
|
||||
map[account] = (d.value as { category: FoodCategory }).category
|
||||
}
|
||||
}
|
||||
return map
|
||||
}, [extData])
|
||||
|
||||
// Notes from extension_data (key = "note:YYYY-MM")
|
||||
const currentMonth = dateRange.start.slice(0, 7)
|
||||
const currentNote = extData.find(d => d.key === `note:${currentMonth}`)?.value as { text: string } | undefined
|
||||
const [noteText, setNoteText] = useState('')
|
||||
const [editingNote, setEditingNote] = useState(false)
|
||||
const [savingNote, setSavingNote] = useState(false)
|
||||
const [deleteNoteOpen, setDeleteNoteOpen] = useState(false)
|
||||
const [deletingNote, setDeletingNote] = useState(false)
|
||||
|
||||
// Target edit dialog state
|
||||
const [editTargetDialogOpen, setEditTargetDialogOpen] = useState(false)
|
||||
const [newTargetInput, setNewTargetInput] = useState('')
|
||||
const [savingTarget, setSavingTarget] = useState(false)
|
||||
|
||||
// --- Current period account totals ---
|
||||
const { totals: purchaseTotals, isLoading: purchasesLoading } = useAccountTotals({
|
||||
from: '4000', to: '4999',
|
||||
dateFrom: dateRange.start, dateTo: dateRange.end,
|
||||
})
|
||||
const { totals: revenueTotals, isLoading: revenueLoading } = useAccountTotals({
|
||||
from: '3000', to: '3999',
|
||||
dateFrom: dateRange.start, dateTo: dateRange.end,
|
||||
})
|
||||
|
||||
// --- Previous period account totals ---
|
||||
const { totals: prevPurchaseTotals, isLoading: prevPurchasesLoading } = useAccountTotals({
|
||||
from: '4000', to: '4999',
|
||||
dateFrom: prevPeriod.start, dateTo: prevPeriod.end,
|
||||
})
|
||||
const { totals: prevRevenueTotals, isLoading: prevRevenueLoading } = useAccountTotals({
|
||||
from: '3000', to: '3999',
|
||||
dateFrom: prevPeriod.start, dateTo: prevPeriod.end,
|
||||
})
|
||||
|
||||
// Monthly trend data
|
||||
const { monthly: purchaseMonthly } = useAccountTotals({
|
||||
from: '4000', to: '4999',
|
||||
dateFrom: yearStart, dateTo: yearEnd,
|
||||
groupBy: 'month',
|
||||
})
|
||||
const { monthly: revenueMonthly } = useAccountTotals({
|
||||
from: '3000', to: '3999',
|
||||
dateFrom: yearStart, dateTo: yearEnd,
|
||||
groupBy: 'month',
|
||||
})
|
||||
|
||||
// Current period calculations
|
||||
const totalPurchases = purchaseTotals.reduce((sum, t) => sum + t.debit, 0)
|
||||
const totalRevenue = revenueTotals.reduce((sum, t) => sum + t.credit, 0)
|
||||
const foodCostPct = totalRevenue > 0
|
||||
? Math.round((totalPurchases / totalRevenue) * 10000) / 100
|
||||
: 0
|
||||
|
||||
// Previous period calculations
|
||||
const prevTotalPurchases = prevPurchaseTotals.reduce((sum, t) => sum + t.debit, 0)
|
||||
const prevTotalRevenue = prevRevenueTotals.reduce((sum, t) => sum + t.credit, 0)
|
||||
const prevFoodCostPct = prevTotalRevenue > 0
|
||||
? Math.round((prevTotalPurchases / prevTotalRevenue) * 10000) / 100
|
||||
: 0
|
||||
|
||||
const target = settings?.targetPct ?? 30
|
||||
|
||||
// Monthly trend rows
|
||||
const monthlyTrend = useMemo(() => {
|
||||
const months = new Set([
|
||||
...purchaseMonthly.map(m => m.month),
|
||||
...revenueMonthly.map(m => m.month),
|
||||
])
|
||||
return Array.from(months).sort().map(month => {
|
||||
const purch = purchaseMonthly.filter(m => m.month === month).reduce((s, m) => s + m.debit, 0)
|
||||
const rev = revenueMonthly.filter(m => m.month === month).reduce((s, m) => s + m.credit, 0)
|
||||
const pct = rev > 0 ? Math.round((purch / rev) * 10000) / 100 : 0
|
||||
return { month, value: pct }
|
||||
})
|
||||
}, [purchaseMonthly, revenueMonthly])
|
||||
|
||||
// Category breakdown
|
||||
const categoryBreakdown = useMemo(() => {
|
||||
const groups: Record<string, { category: FoodCategory; total: number; accounts: string[] }> = {}
|
||||
for (const cat of FOOD_CATEGORIES) {
|
||||
groups[cat] = { category: cat, total: 0, accounts: [] }
|
||||
}
|
||||
let uncategorizedTotal = 0
|
||||
const uncategorizedAccounts: string[] = []
|
||||
|
||||
for (const t of purchaseTotals) {
|
||||
const cat = categoryMap[t.account_number]
|
||||
if (cat && groups[cat]) {
|
||||
groups[cat].total += t.debit
|
||||
groups[cat].accounts.push(t.account_number)
|
||||
} else {
|
||||
uncategorizedTotal += t.debit
|
||||
uncategorizedAccounts.push(t.account_number)
|
||||
}
|
||||
}
|
||||
|
||||
const result = FOOD_CATEGORIES
|
||||
.map(cat => groups[cat])
|
||||
.filter(g => g.total > 0 || g.accounts.length > 0)
|
||||
|
||||
if (uncategorizedTotal > 0) {
|
||||
result.push({ category: 'Ovrigt' as FoodCategory, total: uncategorizedTotal, accounts: uncategorizedAccounts })
|
||||
}
|
||||
|
||||
return result
|
||||
}, [purchaseTotals, categoryMap])
|
||||
|
||||
// --- Handlers ---
|
||||
|
||||
const saveTarget = async () => {
|
||||
const val = parseFloat(targetPct)
|
||||
if (!isNaN(val)) {
|
||||
// Save target history before changing
|
||||
const oldTarget = settings?.targetPct
|
||||
if (oldTarget != null && oldTarget !== val) {
|
||||
await save(`target-history:${Date.now()}`, {
|
||||
previousTarget: oldTarget,
|
||||
newTarget: val,
|
||||
changedAt: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
await save('settings', { targetPct: val })
|
||||
setEditingTarget(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveTargetDialog = async () => {
|
||||
setSavingTarget(true)
|
||||
const val = parseFloat(newTargetInput)
|
||||
if (!isNaN(val)) {
|
||||
const oldTarget = settings?.targetPct
|
||||
if (oldTarget != null && oldTarget !== val) {
|
||||
await save(`target-history:${Date.now()}`, {
|
||||
previousTarget: oldTarget,
|
||||
newTarget: val,
|
||||
changedAt: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
await save('settings', { targetPct: val })
|
||||
}
|
||||
setSavingTarget(false)
|
||||
}
|
||||
|
||||
const handleCategoryChange = useCallback(async (accountNumber: string, category: string) => {
|
||||
if (category === '__none__') {
|
||||
await remove(`category:${accountNumber}`)
|
||||
} else {
|
||||
await save(`category:${accountNumber}`, { category })
|
||||
}
|
||||
}, [save, remove])
|
||||
|
||||
const handleSaveNote = async () => {
|
||||
setSavingNote(true)
|
||||
await save(`note:${currentMonth}`, { text: noteText })
|
||||
setEditingNote(false)
|
||||
setSavingNote(false)
|
||||
}
|
||||
|
||||
const handleDeleteNote = async () => {
|
||||
setDeletingNote(true)
|
||||
await remove(`note:${currentMonth}`)
|
||||
setNoteText('')
|
||||
setEditingNote(false)
|
||||
setDeletingNote(false)
|
||||
}
|
||||
|
||||
const startEditNote = () => {
|
||||
setNoteText(currentNote?.text ?? '')
|
||||
setEditingNote(true)
|
||||
}
|
||||
|
||||
const isLoading = purchasesLoading || revenueLoading || prevPurchasesLoading || prevRevenueLoading || settingsLoading
|
||||
|
||||
if (isLoading) return <ExtensionLoadingSkeleton />
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<DateRangeFilter onRangeChange={(start, end) => setDateRange({ start, end })} />
|
||||
|
||||
{/* KPI Cards with period comparison */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<KPICard
|
||||
label="Food Cost %"
|
||||
value={foodCostPct}
|
||||
suffix="%"
|
||||
className={cn(
|
||||
foodCostPct <= target ? 'border-green-200' : 'border-red-200'
|
||||
)}
|
||||
/>
|
||||
<KPICard
|
||||
label="Varuinkop"
|
||||
value={totalPurchases.toLocaleString('sv-SE')}
|
||||
suffix="kr"
|
||||
/>
|
||||
<KPICard
|
||||
label="Livsmedelsintakter"
|
||||
value={totalRevenue.toLocaleString('sv-SE')}
|
||||
suffix="kr"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Period comparison */}
|
||||
<div className="rounded-xl border p-4">
|
||||
<h3 className="text-sm font-semibold mb-3">Periodjamforelse</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">Food Cost % (nuvarande)</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg font-semibold tabular-nums">{foodCostPct}%</span>
|
||||
<DeltaArrow current={foodCostPct} previous={prevFoodCostPct} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">Food Cost % (foregaende)</p>
|
||||
<span className="text-lg font-semibold tabular-nums">{prevFoodCostPct}%</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">Foregaende period</p>
|
||||
<span className="text-sm tabular-nums">{prevPeriod.start} — {prevPeriod.end}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mt-3 pt-3 border-t">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">Varuinkop (foregaende)</p>
|
||||
<span className="text-sm tabular-nums">{prevTotalPurchases.toLocaleString('sv-SE')} kr</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">Intakter (foregaende)</p>
|
||||
<span className="text-sm tabular-nums">{prevTotalRevenue.toLocaleString('sv-SE')} kr</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Target setting */}
|
||||
<div className="rounded-xl border p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Malvarde</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Riktvarde for food cost (vanligtvis 25-35%)
|
||||
</p>
|
||||
</div>
|
||||
{editingTarget ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={targetPct}
|
||||
onChange={e => setTargetPct(e.target.value)}
|
||||
className="w-20 h-8 text-sm"
|
||||
/>
|
||||
<Label className="text-sm">%</Label>
|
||||
<Button size="sm" variant="outline" onClick={saveTarget}>Spara</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setEditingTarget(false)}>Avbryt</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setNewTargetInput(String(target))
|
||||
setEditTargetDialogOpen(true)
|
||||
}}
|
||||
>
|
||||
{target}% — Andra
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Edit target dialog (saves history) */}
|
||||
<EditEntryDialog
|
||||
open={editTargetDialogOpen}
|
||||
onOpenChange={setEditTargetDialogOpen}
|
||||
title="Andra malvarde"
|
||||
description={`Nuvarande malvarde: ${target}%. Andringshistorik sparas automatiskt.`}
|
||||
onSave={handleSaveTargetDialog}
|
||||
isSaving={savingTarget}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="new-target">Nytt malvarde (%)</Label>
|
||||
<Input
|
||||
id="new-target"
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={newTargetInput}
|
||||
onChange={e => setNewTargetInput(e.target.value)}
|
||||
className="w-32"
|
||||
/>
|
||||
</div>
|
||||
</EditEntryDialog>
|
||||
|
||||
{/* Notes per period */}
|
||||
<div className="rounded-xl border p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Anteckningar for {currentMonth}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Notera avvikelser och forklaringar for perioden
|
||||
</p>
|
||||
</div>
|
||||
{!editingNote && (
|
||||
<Button size="sm" variant="ghost" onClick={startEditNote}>
|
||||
{currentNote?.text ? 'Redigera' : 'Lagg till'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{editingNote ? (
|
||||
<div className="space-y-3">
|
||||
<Textarea
|
||||
value={noteText}
|
||||
onChange={e => setNoteText(e.target.value)}
|
||||
placeholder="Beskriv avvikelser, t.ex. menyandring, leverantorsbyte..."
|
||||
rows={3}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" onClick={handleSaveNote} disabled={savingNote}>
|
||||
{savingNote ? 'Sparar...' : 'Spara'}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setEditingNote(false)}>
|
||||
Avbryt
|
||||
</Button>
|
||||
{currentNote?.text && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-red-600 hover:text-red-700"
|
||||
onClick={() => setDeleteNoteOpen(true)}
|
||||
>
|
||||
Ta bort
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : currentNote?.text ? (
|
||||
<p className="text-sm whitespace-pre-wrap">{currentNote.text}</p>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Inga anteckningar for denna period.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ConfirmDeleteDialog
|
||||
open={deleteNoteOpen}
|
||||
onOpenChange={setDeleteNoteOpen}
|
||||
title="Ta bort anteckning"
|
||||
description={`Vill du ta bort anteckningen for ${currentMonth}?`}
|
||||
onConfirm={handleDeleteNote}
|
||||
isDeleting={deletingNote}
|
||||
/>
|
||||
|
||||
{/* Monthly trend */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-3">Manadstrend {now.getFullYear()}</h3>
|
||||
<MonthlyTrendTable
|
||||
rows={monthlyTrend}
|
||||
valueLabel="Food Cost %"
|
||||
valueSuffix="%"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Category breakdown */}
|
||||
{categoryBreakdown.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-3">Varuinkop per kategori</h3>
|
||||
<div className="rounded-xl border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Kategori</TableHead>
|
||||
<TableHead className="text-right">Belopp</TableHead>
|
||||
<TableHead className="text-right">Andel av inkop</TableHead>
|
||||
<TableHead className="text-right">Andel av intakter</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{categoryBreakdown.map(g => (
|
||||
<TableRow key={g.category}>
|
||||
<TableCell className="font-medium">{g.category}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{(Math.round(g.total * 100) / 100).toLocaleString('sv-SE')} kr
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{totalPurchases > 0
|
||||
? Math.round((g.total / totalPurchases) * 100)
|
||||
: 0}%
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{totalRevenue > 0
|
||||
? Math.round((g.total / totalRevenue) * 10000) / 100
|
||||
: 0}%
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Account breakdown with category assignment */}
|
||||
{purchaseTotals.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-3">Varuinkop per konto</h3>
|
||||
<div className="rounded-xl border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Konto</TableHead>
|
||||
<TableHead>Kategori</TableHead>
|
||||
<TableHead className="text-right">Debet</TableHead>
|
||||
<TableHead className="text-right">Andel</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{purchaseTotals.map(t => (
|
||||
<TableRow key={t.account_number}>
|
||||
<TableCell className="font-medium">{t.account_number}</TableCell>
|
||||
<TableCell>
|
||||
<Select
|
||||
value={categoryMap[t.account_number] ?? '__none__'}
|
||||
onValueChange={(val) => handleCategoryChange(t.account_number, val)}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[130px] text-xs">
|
||||
<SelectValue placeholder="Valj kategori" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">Ingen</SelectItem>
|
||||
{FOOD_CATEGORIES.map(cat => (
|
||||
<SelectItem key={cat} value={cat}>{cat}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{t.debit.toLocaleString('sv-SE')} kr
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{totalPurchases > 0
|
||||
? Math.round((t.debit / totalPurchases) * 100)
|
||||
: 0}%
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,696 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useMemo } from 'react'
|
||||
import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
|
||||
import { useExtensionData } from '@/lib/extensions/use-extension-data'
|
||||
import KPICard from '@/components/extensions/shared/KPICard'
|
||||
import CsvImportWizard from '@/components/extensions/shared/CsvImportWizard'
|
||||
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 {
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Pencil, Trash2, AlertTriangle, ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
|
||||
interface DailySale {
|
||||
date: string
|
||||
total: number
|
||||
cash: number
|
||||
card: number
|
||||
swish: number
|
||||
vat: number
|
||||
}
|
||||
|
||||
interface ImportRecord {
|
||||
id: string
|
||||
date: string
|
||||
fileName: string
|
||||
rowCount: number
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
const TARGET_FIELDS = [
|
||||
{ key: 'date', label: 'Datum', required: true },
|
||||
{ key: 'total', label: 'Totalt', required: true },
|
||||
{ key: 'cash', label: 'Kontant' },
|
||||
{ key: 'card', label: 'Kort' },
|
||||
{ key: 'swish', label: 'Swish' },
|
||||
{ key: 'vat', label: 'Moms' },
|
||||
]
|
||||
|
||||
const DEFAULT_MAPPINGS: Record<string, string> = {
|
||||
date: 'Datum',
|
||||
total: 'Total',
|
||||
cash: 'Kontant',
|
||||
card: 'Kort',
|
||||
swish: 'Swish',
|
||||
vat: 'Moms',
|
||||
}
|
||||
|
||||
const parseNum = (v?: string) => {
|
||||
if (!v) return 0
|
||||
return Math.round(parseFloat(v.replace(/\s/g, '').replace(',', '.')) * 100) / 100 || 0
|
||||
}
|
||||
|
||||
export default function PosImportWorkspace({}: WorkspaceComponentProps) {
|
||||
const { data, save, remove, refresh, isLoading } = useExtensionData('restaurant', 'pos-import')
|
||||
|
||||
// Pagination
|
||||
const [page, setPage] = useState(0)
|
||||
|
||||
// Manual entry form state
|
||||
const now = new Date()
|
||||
const [entryDate, setEntryDate] = useState(now.toISOString().slice(0, 10))
|
||||
const [entryTotal, setEntryTotal] = useState('')
|
||||
const [entryCash, setEntryCash] = useState('')
|
||||
const [entryCard, setEntryCard] = useState('')
|
||||
const [entrySwish, setEntrySwish] = useState('')
|
||||
const [entryVat, setEntryVat] = useState('')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
// Edit dialog state
|
||||
const [editEntry, setEditEntry] = useState<DailySale | null>(null)
|
||||
const [editTotal, setEditTotal] = useState('')
|
||||
const [editCash, setEditCash] = useState('')
|
||||
const [editCard, setEditCard] = useState('')
|
||||
const [editSwish, setEditSwish] = useState('')
|
||||
const [editVat, setEditVat] = useState('')
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
|
||||
// Delete dialog state
|
||||
const [deleteEntry, setDeleteEntry] = useState<DailySale | null>(null)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
|
||||
const dailySales = useMemo(() =>
|
||||
data.filter(d => d.key.startsWith('daily:'))
|
||||
.map(d => ({ date: d.key.replace('daily:', ''), ...(d.value as Omit<DailySale, 'date'>) }))
|
||||
.sort((a, b) => b.date.localeCompare(a.date))
|
||||
, [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])
|
||||
|
||||
// Pagination calculations
|
||||
const totalPages = Math.max(1, Math.ceil(dailySales.length / PAGE_SIZE))
|
||||
const paginatedSales = dailySales.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE)
|
||||
|
||||
const handleImport = async (rows: Record<string, string>[]) => {
|
||||
const importId = crypto.randomUUID()
|
||||
let count = 0
|
||||
|
||||
for (const row of rows) {
|
||||
const date = row.date
|
||||
if (!date) continue
|
||||
|
||||
await save(`daily:${date}`, {
|
||||
total: parseNum(row.total),
|
||||
cash: parseNum(row.cash),
|
||||
card: parseNum(row.card),
|
||||
swish: parseNum(row.swish),
|
||||
vat: parseNum(row.vat),
|
||||
})
|
||||
count++
|
||||
}
|
||||
|
||||
await save(`import:${importId}`, {
|
||||
date: new Date().toISOString().slice(0, 10),
|
||||
fileName: `CSV-import`,
|
||||
rowCount: count,
|
||||
})
|
||||
|
||||
await refresh()
|
||||
setPage(0)
|
||||
}
|
||||
|
||||
// Manual entry validation
|
||||
const manualPaymentSum = Math.round(
|
||||
((parseFloat(entryCash) || 0) + (parseFloat(entryCard) || 0) + (parseFloat(entrySwish) || 0)) * 100
|
||||
) / 100
|
||||
const manualTotal = Math.round((parseFloat(entryTotal) || 0) * 100) / 100
|
||||
const manualDiffPct = manualTotal > 0
|
||||
? Math.round(Math.abs(manualPaymentSum - manualTotal) / manualTotal * 10000) / 100
|
||||
: 0
|
||||
const showManualWarning = manualTotal > 0 && manualPaymentSum > 0 && manualDiffPct > 5
|
||||
|
||||
const handleManualSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!entryDate || !entryTotal) return
|
||||
setIsSubmitting(true)
|
||||
|
||||
const total = Math.round((parseFloat(entryTotal) || 0) * 100) / 100
|
||||
const cash = Math.round((parseFloat(entryCash) || 0) * 100) / 100
|
||||
const card = Math.round((parseFloat(entryCard) || 0) * 100) / 100
|
||||
const swish = Math.round((parseFloat(entrySwish) || 0) * 100) / 100
|
||||
const vat = Math.round((parseFloat(entryVat) || 0) * 100) / 100
|
||||
|
||||
await save(`daily:${entryDate}`, { total, cash, card, swish, vat })
|
||||
await refresh()
|
||||
|
||||
setEntryTotal('')
|
||||
setEntryCash('')
|
||||
setEntryCard('')
|
||||
setEntrySwish('')
|
||||
setEntryVat('')
|
||||
setIsSubmitting(false)
|
||||
setPage(0)
|
||||
}
|
||||
|
||||
// Edit handlers
|
||||
const openEdit = (entry: DailySale) => {
|
||||
setEditEntry(entry)
|
||||
setEditTotal(String(entry.total))
|
||||
setEditCash(String(entry.cash))
|
||||
setEditCard(String(entry.card))
|
||||
setEditSwish(String(entry.swish))
|
||||
setEditVat(String(entry.vat))
|
||||
}
|
||||
|
||||
const handleEditSave = async () => {
|
||||
if (!editEntry) return
|
||||
setIsSaving(true)
|
||||
|
||||
const total = Math.round((parseFloat(editTotal) || 0) * 100) / 100
|
||||
const cash = Math.round((parseFloat(editCash) || 0) * 100) / 100
|
||||
const card = Math.round((parseFloat(editCard) || 0) * 100) / 100
|
||||
const swish = Math.round((parseFloat(editSwish) || 0) * 100) / 100
|
||||
const vat = Math.round((parseFloat(editVat) || 0) * 100) / 100
|
||||
|
||||
await save(`daily:${editEntry.date}`, { total, cash, card, swish, vat })
|
||||
await refresh()
|
||||
setIsSaving(false)
|
||||
}
|
||||
|
||||
// Edit dialog validation
|
||||
const editPaymentSum = Math.round(
|
||||
((parseFloat(editCash) || 0) + (parseFloat(editCard) || 0) + (parseFloat(editSwish) || 0)) * 100
|
||||
) / 100
|
||||
const editTotalVal = Math.round((parseFloat(editTotal) || 0) * 100) / 100
|
||||
const editDiffPct = editTotalVal > 0
|
||||
? Math.round(Math.abs(editPaymentSum - editTotalVal) / editTotalVal * 10000) / 100
|
||||
: 0
|
||||
const showEditWarning = editTotalVal > 0 && editPaymentSum > 0 && editDiffPct > 5
|
||||
|
||||
// Delete handler
|
||||
const handleDelete = async () => {
|
||||
if (!deleteEntry) return
|
||||
setIsDeleting(true)
|
||||
await remove(`daily:${deleteEntry.date}`)
|
||||
await refresh()
|
||||
setIsDeleting(false)
|
||||
}
|
||||
|
||||
// KPI calculations
|
||||
const totals = useMemo(() => {
|
||||
const total = dailySales.reduce((s, d) => s + d.total, 0)
|
||||
const cash = dailySales.reduce((s, d) => s + d.cash, 0)
|
||||
const card = dailySales.reduce((s, d) => s + d.card, 0)
|
||||
const swish = dailySales.reduce((s, d) => s + d.swish, 0)
|
||||
const vat = dailySales.reduce((s, d) => s + d.vat, 0)
|
||||
const avg = dailySales.length > 0 ? Math.round(total / dailySales.length) : 0
|
||||
return { total, cash, card, swish, vat, avg }
|
||||
}, [dailySales])
|
||||
|
||||
// VAT analytics
|
||||
const vatPct = totals.total > 0
|
||||
? Math.round(totals.vat / totals.total * 10000) / 100
|
||||
: 0
|
||||
const vatOutOfRange = vatPct > 0 && (vatPct < 20 || vatPct > 30)
|
||||
|
||||
// Payment method monthly breakdown
|
||||
const paymentMonthly = useMemo(() => {
|
||||
const map = new Map<string, { cash: number; card: number; swish: number; total: number }>()
|
||||
for (const d of dailySales) {
|
||||
const month = d.date.slice(0, 7)
|
||||
const existing = map.get(month) ?? { cash: 0, card: 0, swish: 0, total: 0 }
|
||||
existing.cash += d.cash
|
||||
existing.card += d.card
|
||||
existing.swish += d.swish
|
||||
existing.total += d.total
|
||||
map.set(month, existing)
|
||||
}
|
||||
return Array.from(map.entries())
|
||||
.sort(([a], [b]) => b.localeCompare(a))
|
||||
.map(([month, vals]) => ({
|
||||
month,
|
||||
cashPct: vals.total > 0 ? Math.round(vals.cash / vals.total * 10000) / 100 : 0,
|
||||
cardPct: vals.total > 0 ? Math.round(vals.card / vals.total * 10000) / 100 : 0,
|
||||
swishPct: vals.total > 0 ? Math.round(vals.swish / vals.total * 10000) / 100 : 0,
|
||||
cash: vals.cash,
|
||||
card: vals.card,
|
||||
swish: vals.swish,
|
||||
total: vals.total,
|
||||
}))
|
||||
}, [dailySales])
|
||||
|
||||
// VAT per month
|
||||
const vatMonthly = useMemo(() => {
|
||||
const map = new Map<string, { vat: number; total: number }>()
|
||||
for (const d of dailySales) {
|
||||
const month = d.date.slice(0, 7)
|
||||
const existing = map.get(month) ?? { vat: 0, total: 0 }
|
||||
existing.vat += d.vat
|
||||
existing.total += d.total
|
||||
map.set(month, existing)
|
||||
}
|
||||
return Array.from(map.entries())
|
||||
.sort(([a], [b]) => b.localeCompare(a))
|
||||
.map(([month, vals]) => ({
|
||||
month,
|
||||
vat: Math.round(vals.vat * 100) / 100,
|
||||
total: Math.round(vals.total * 100) / 100,
|
||||
vatPct: vals.total > 0 ? Math.round(vals.vat / vals.total * 10000) / 100 : 0,
|
||||
}))
|
||||
}, [dailySales])
|
||||
|
||||
if (isLoading) return <ExtensionLoadingSkeleton />
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Tabs defaultValue="import">
|
||||
<TabsList>
|
||||
<TabsTrigger value="import">Import</TabsTrigger>
|
||||
<TabsTrigger value="register">Registrera</TabsTrigger>
|
||||
<TabsTrigger value="history">Historik</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* CSV Import tab */}
|
||||
<TabsContent value="import" className="space-y-6 mt-4">
|
||||
<CsvImportWizard
|
||||
targetFields={TARGET_FIELDS}
|
||||
defaultMappings={DEFAULT_MAPPINGS}
|
||||
onImport={handleImport}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
{/* Manual entry tab */}
|
||||
<TabsContent value="register" className="space-y-6 mt-4">
|
||||
<DataEntryForm
|
||||
title="Registrera dagskassa"
|
||||
onSubmit={handleManualSubmit}
|
||||
submitLabel="Registrera"
|
||||
isSubmitting={isSubmitting}
|
||||
>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="pos-date">Datum</Label>
|
||||
<Input
|
||||
id="pos-date"
|
||||
type="date"
|
||||
value={entryDate}
|
||||
onChange={e => setEntryDate(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="pos-total">Totalt (kr)</Label>
|
||||
<Input
|
||||
id="pos-total"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
placeholder="0"
|
||||
value={entryTotal}
|
||||
onChange={e => setEntryTotal(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="pos-cash">Kontant (kr)</Label>
|
||||
<Input
|
||||
id="pos-cash"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
placeholder="0"
|
||||
value={entryCash}
|
||||
onChange={e => setEntryCash(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="pos-card">Kort (kr)</Label>
|
||||
<Input
|
||||
id="pos-card"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
placeholder="0"
|
||||
value={entryCard}
|
||||
onChange={e => setEntryCard(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="pos-swish">Swish (kr)</Label>
|
||||
<Input
|
||||
id="pos-swish"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
placeholder="0"
|
||||
value={entrySwish}
|
||||
onChange={e => setEntrySwish(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="pos-vat">Moms (kr)</Label>
|
||||
<Input
|
||||
id="pos-vat"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
placeholder="0"
|
||||
value={entryVat}
|
||||
onChange={e => setEntryVat(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showManualWarning && (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-yellow-300 bg-yellow-50 p-3 text-sm text-yellow-800 dark:border-yellow-700 dark:bg-yellow-950/30 dark:text-yellow-200">
|
||||
<AlertTriangle className="h-4 w-4 shrink-0" />
|
||||
<span>
|
||||
Kontant + Kort + Swish ({manualPaymentSum.toLocaleString('sv-SE')} kr) avviker {manualDiffPct}% fran Totalt ({manualTotal.toLocaleString('sv-SE')} kr).
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</DataEntryForm>
|
||||
</TabsContent>
|
||||
|
||||
{/* History tab */}
|
||||
<TabsContent value="history" className="space-y-6 mt-4">
|
||||
{/* KPI cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-5 gap-4">
|
||||
<KPICard label="Total forsaljning" value={totals.total.toLocaleString('sv-SE')} suffix="kr" />
|
||||
<KPICard label="Snitt per dag" value={totals.avg.toLocaleString('sv-SE')} suffix="kr" />
|
||||
<KPICard label="Kort" value={totals.card.toLocaleString('sv-SE')} suffix="kr" />
|
||||
<KPICard label="Swish" value={totals.swish.toLocaleString('sv-SE')} suffix="kr" />
|
||||
<KPICard
|
||||
label="Snitt momsandel"
|
||||
value={vatPct}
|
||||
suffix="%"
|
||||
className={vatOutOfRange ? 'border-yellow-300 dark:border-yellow-700' : ''}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* VAT alert */}
|
||||
{vatOutOfRange && dailySales.length > 0 && (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-yellow-300 bg-yellow-50 p-3 text-sm text-yellow-800 dark:border-yellow-700 dark:bg-yellow-950/30 dark:text-yellow-200">
|
||||
<AlertTriangle className="h-4 w-4 shrink-0" />
|
||||
<span>
|
||||
Momsandelen ({vatPct}%) ligger utanfor forvantat intervall (20-30%) for svenska restauranger. Kontrollera att moms registreras korrekt.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Import history */}
|
||||
{imports.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-3">Importer</h3>
|
||||
<div className="rounded-xl border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Datum</TableHead>
|
||||
<TableHead>Fil</TableHead>
|
||||
<TableHead className="text-right">Rader</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{imports.map(imp => (
|
||||
<TableRow key={imp.id}>
|
||||
<TableCell>{imp.date}</TableCell>
|
||||
<TableCell>{imp.fileName}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{imp.rowCount}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* VAT per month */}
|
||||
{vatMonthly.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-3">Moms per manad</h3>
|
||||
<div className="rounded-xl border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Period</TableHead>
|
||||
<TableHead className="text-right">Forsaljning</TableHead>
|
||||
<TableHead className="text-right">Moms</TableHead>
|
||||
<TableHead className="text-right">Momsandel</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{vatMonthly.map(row => (
|
||||
<TableRow key={row.month}>
|
||||
<TableCell className="font-medium">{row.month}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{row.total.toLocaleString('sv-SE')} kr</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{row.vat.toLocaleString('sv-SE')} kr</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
<span className={row.vatPct > 0 && (row.vatPct < 20 || row.vatPct > 30) ? 'text-yellow-600 dark:text-yellow-400' : ''}>
|
||||
{row.vatPct}%
|
||||
</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Payment method trend */}
|
||||
{paymentMonthly.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-3">Betalmetoder per manad</h3>
|
||||
<div className="rounded-xl border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Period</TableHead>
|
||||
<TableHead className="text-right">Totalt</TableHead>
|
||||
<TableHead className="text-right">Kontant</TableHead>
|
||||
<TableHead className="text-right">% Kontant</TableHead>
|
||||
<TableHead className="text-right">Kort</TableHead>
|
||||
<TableHead className="text-right">% Kort</TableHead>
|
||||
<TableHead className="text-right">Swish</TableHead>
|
||||
<TableHead className="text-right">% Swish</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{paymentMonthly.map(row => (
|
||||
<TableRow key={row.month}>
|
||||
<TableCell className="font-medium">{row.month}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{row.total.toLocaleString('sv-SE')} kr</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{row.cash.toLocaleString('sv-SE')} kr</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{row.cashPct}%</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{row.card.toLocaleString('sv-SE')} kr</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{row.cardPct}%</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{row.swish.toLocaleString('sv-SE')} kr</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{row.swishPct}%</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Daily sales table with pagination */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-3">Daglig forsaljning</h3>
|
||||
{dailySales.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Ingen data importerad annu.</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="rounded-xl border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Datum</TableHead>
|
||||
<TableHead className="text-right">Totalt</TableHead>
|
||||
<TableHead className="text-right">Kontant</TableHead>
|
||||
<TableHead className="text-right">Kort</TableHead>
|
||||
<TableHead className="text-right">Swish</TableHead>
|
||||
<TableHead className="text-right">Moms</TableHead>
|
||||
<TableHead className="text-right">Moms %</TableHead>
|
||||
<TableHead className="w-20"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{paginatedSales.map(d => {
|
||||
const rowVatPct = d.total > 0
|
||||
? Math.round(d.vat / d.total * 10000) / 100
|
||||
: 0
|
||||
return (
|
||||
<TableRow key={d.date}>
|
||||
<TableCell className="font-medium">{d.date}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{d.total.toLocaleString('sv-SE')}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{d.cash.toLocaleString('sv-SE')}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{d.card.toLocaleString('sv-SE')}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{d.swish.toLocaleString('sv-SE')}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{d.vat.toLocaleString('sv-SE')}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
<span className={rowVatPct > 0 && (rowVatPct < 20 || rowVatPct > 30) ? 'text-yellow-600 dark:text-yellow-400' : ''}>
|
||||
{rowVatPct}%
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button variant="ghost" size="sm" onClick={() => openEdit(d)}>
|
||||
<Pencil className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setDeleteEntry(d)}>
|
||||
<Trash2 className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between mt-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Visar {page * PAGE_SIZE + 1}-{Math.min((page + 1) * PAGE_SIZE, dailySales.length)} av {dailySales.length} rader
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage(p => Math.max(0, p - 1))}
|
||||
disabled={page === 0}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||
Foregaende
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Sida {page + 1} av {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))}
|
||||
disabled={page >= totalPages - 1}
|
||||
>
|
||||
Nasta
|
||||
<ChevronRight className="h-4 w-4 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Edit dialog */}
|
||||
<EditEntryDialog
|
||||
open={!!editEntry}
|
||||
onOpenChange={open => { if (!open) setEditEntry(null) }}
|
||||
title="Redigera dagskassa"
|
||||
description={editEntry ? `Redigera data for ${editEntry.date}` : ''}
|
||||
onSave={handleEditSave}
|
||||
isSaving={isSaving}
|
||||
>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-date">Datum</Label>
|
||||
<Input id="edit-date" type="date" value={editEntry?.date ?? ''} disabled />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-total">Totalt (kr)</Label>
|
||||
<Input
|
||||
id="edit-total"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={editTotal}
|
||||
onChange={e => setEditTotal(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-cash">Kontant (kr)</Label>
|
||||
<Input
|
||||
id="edit-cash"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={editCash}
|
||||
onChange={e => setEditCash(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-card">Kort (kr)</Label>
|
||||
<Input
|
||||
id="edit-card"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={editCard}
|
||||
onChange={e => setEditCard(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-swish">Swish (kr)</Label>
|
||||
<Input
|
||||
id="edit-swish"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={editSwish}
|
||||
onChange={e => setEditSwish(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-vat">Moms (kr)</Label>
|
||||
<Input
|
||||
id="edit-vat"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={editVat}
|
||||
onChange={e => setEditVat(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showEditWarning && (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-yellow-300 bg-yellow-50 p-3 text-sm text-yellow-800 dark:border-yellow-700 dark:bg-yellow-950/30 dark:text-yellow-200">
|
||||
<AlertTriangle className="h-4 w-4 shrink-0" />
|
||||
<span>
|
||||
Kontant + Kort + Swish ({editPaymentSum.toLocaleString('sv-SE')} kr) avviker {editDiffPct}% fran Totalt ({editTotalVal.toLocaleString('sv-SE')} kr).
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</EditEntryDialog>
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
<ConfirmDeleteDialog
|
||||
open={!!deleteEntry}
|
||||
onOpenChange={open => { if (!open) setDeleteEntry(null) }}
|
||||
title="Ta bort dagskassa"
|
||||
description={deleteEntry ? `Vill du ta bort data for ${deleteEntry.date}? Atgarden kan inte angras.` : ''}
|
||||
onConfirm={handleDelete}
|
||||
isDeleting={isDeleting}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,844 +0,0 @@
|
||||
'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 MonthlyTrendTable from '@/components/extensions/shared/MonthlyTrendTable'
|
||||
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 { 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 { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Pencil, Plus, Trash2, Download, ChevronDown, ChevronUp } from 'lucide-react'
|
||||
|
||||
interface Employee {
|
||||
id: string
|
||||
name: string
|
||||
active: boolean
|
||||
}
|
||||
|
||||
interface TipEntry {
|
||||
id: string
|
||||
date: string
|
||||
shift: string
|
||||
employeeId: string
|
||||
employeeName: string
|
||||
amount: number
|
||||
}
|
||||
|
||||
type SplitMethod = 'equal' | 'hours' | 'custom'
|
||||
|
||||
const SHIFTS = ['Lunch', 'Kväll', 'Heldag']
|
||||
|
||||
export default function TipTrackingWorkspace({}: WorkspaceComponentProps) {
|
||||
const now = new Date()
|
||||
const [dateRange, setDateRange] = useState({
|
||||
start: new Date(now.getFullYear(), now.getMonth(), 1).toISOString().slice(0, 10),
|
||||
end: new Date(now.getFullYear(), now.getMonth() + 1, 0).toISOString().slice(0, 10),
|
||||
})
|
||||
|
||||
const { data, save, remove, refresh, isLoading } = useExtensionData('restaurant', 'tip-tracking')
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Derived data
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const employees = useMemo(() =>
|
||||
data.filter(d => d.key.startsWith('employee:'))
|
||||
.map(d => ({
|
||||
id: d.key.replace('employee:', ''),
|
||||
...(d.value as { name: string; active: boolean }),
|
||||
}))
|
||||
, [data])
|
||||
|
||||
const entries = useMemo(() =>
|
||||
data.filter(d => d.key.startsWith('entry:'))
|
||||
.map(d => ({
|
||||
id: d.key,
|
||||
...(d.value as Omit<TipEntry, 'id'>),
|
||||
}))
|
||||
.filter(e => e.date >= dateRange.start && e.date <= dateRange.end)
|
||||
.sort((a, b) => b.date.localeCompare(a.date))
|
||||
, [data, dateRange])
|
||||
|
||||
const activeEmployees = employees.filter(e => e.active)
|
||||
|
||||
// Settings
|
||||
const settings = useMemo(() => {
|
||||
const rec = data.find(d => d.key === 'settings')
|
||||
return (rec?.value ?? {}) as Record<string, unknown>
|
||||
}, [data])
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Register tab – form state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const [entryDate, setEntryDate] = useState(now.toISOString().slice(0, 10))
|
||||
const [shift, setShift] = useState(SHIFTS[0])
|
||||
const [selectedEmployeeId, setEmployeeId] = useState('')
|
||||
const employeeId = selectedEmployeeId || (activeEmployees.length > 0 ? activeEmployees[0].id : '')
|
||||
const [amount, setAmount] = useState('')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
// New employee form
|
||||
const [newEmployeeName, setNewEmployeeName] = useState('')
|
||||
|
||||
// Edit entry dialog
|
||||
const [editEntry, setEditEntry] = useState<TipEntry | null>(null)
|
||||
const [editDate, setEditDate] = useState('')
|
||||
const [editShift, setEditShift] = useState('')
|
||||
const [editEmployeeId, setEditEmployeeId] = useState('')
|
||||
const [editAmount, setEditAmount] = useState('')
|
||||
const [isSavingEdit, setIsSavingEdit] = useState(false)
|
||||
|
||||
// Delete confirmation dialog
|
||||
const [deleteKey, setDeleteKey] = useState<string | null>(null)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
|
||||
// Edit employee dialog
|
||||
const [editEmployee, setEditEmployee] = useState<Employee | null>(null)
|
||||
const [editEmployeeName, setEditEmployeeName] = useState('')
|
||||
const [isSavingEmployee, setIsSavingEmployee] = useState(false)
|
||||
|
||||
// Per-employee expanded view in overview
|
||||
const [expandedEmployeeId, setExpandedEmployeeId] = useState<string | null>(null)
|
||||
|
||||
// Tip pool state
|
||||
const [poolAmount, setPoolAmount] = useState('')
|
||||
const [poolSelectedIds, setPoolSelectedIds] = useState<Set<string>>(new Set())
|
||||
const [splitMethod, setSplitMethod] = useState<SplitMethod>('equal')
|
||||
const [hoursMap, setHoursMap] = useState<Record<string, string>>({})
|
||||
const [customPctMap, setCustomPctMap] = useState<Record<string, string>>({})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Analytics
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const totalTips = entries.reduce((s, e) => s + e.amount, 0)
|
||||
const avgPerShift = entries.length > 0 ? Math.round(totalTips / entries.length) : 0
|
||||
|
||||
const monthlyTrend = useMemo(() => {
|
||||
const map = new Map<string, number>()
|
||||
for (const e of entries) {
|
||||
const month = e.date.slice(0, 7)
|
||||
map.set(month, (map.get(month) ?? 0) + e.amount)
|
||||
}
|
||||
return Array.from(map.entries())
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([month, value]) => ({ month, value }))
|
||||
}, [entries])
|
||||
|
||||
const employeeTotals = useMemo(() => {
|
||||
const map = new Map<string, { name: string; total: number; count: number }>()
|
||||
for (const e of entries) {
|
||||
const existing = map.get(e.employeeId) ?? { name: e.employeeName, total: 0, count: 0 }
|
||||
existing.total += e.amount
|
||||
existing.count++
|
||||
map.set(e.employeeId, existing)
|
||||
}
|
||||
return Array.from(map.entries())
|
||||
.map(([id, d]) => ({ id, ...d }))
|
||||
.sort((a, b) => b.total - a.total)
|
||||
}, [entries])
|
||||
|
||||
// Per-employee entries for expanded view
|
||||
const expandedEmployeeEntries = useMemo(() => {
|
||||
if (!expandedEmployeeId) return []
|
||||
return entries
|
||||
.filter(e => e.employeeId === expandedEmployeeId)
|
||||
.sort((a, b) => b.date.localeCompare(a.date))
|
||||
}, [entries, expandedEmployeeId])
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tip pool calculations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const poolTotal = parseFloat(poolAmount) || 0
|
||||
const poolSelectedEmployees = activeEmployees.filter(e => poolSelectedIds.has(e.id))
|
||||
|
||||
const poolDistribution = useMemo((): { id: string; name: string; share: number }[] => {
|
||||
if (poolSelectedEmployees.length === 0 || poolTotal <= 0) return []
|
||||
|
||||
if (splitMethod === 'equal') {
|
||||
const share = Math.round((poolTotal / poolSelectedEmployees.length) * 100) / 100
|
||||
return poolSelectedEmployees.map(e => ({ id: e.id, name: e.name, share }))
|
||||
}
|
||||
|
||||
if (splitMethod === 'hours') {
|
||||
const totalHours = poolSelectedEmployees.reduce((sum, e) => {
|
||||
return sum + (parseFloat(hoursMap[e.id] ?? '0') || 0)
|
||||
}, 0)
|
||||
if (totalHours <= 0) return poolSelectedEmployees.map(e => ({ id: e.id, name: e.name, share: 0 }))
|
||||
return poolSelectedEmployees.map(e => {
|
||||
const h = parseFloat(hoursMap[e.id] ?? '0') || 0
|
||||
const share = Math.round((poolTotal * (h / totalHours)) * 100) / 100
|
||||
return { id: e.id, name: e.name, share }
|
||||
})
|
||||
}
|
||||
|
||||
// custom
|
||||
return poolSelectedEmployees.map(e => {
|
||||
const pct = parseFloat(customPctMap[e.id] ?? '0') || 0
|
||||
const share = Math.round((poolTotal * (pct / 100)) * 100) / 100
|
||||
return { id: e.id, name: e.name, share }
|
||||
})
|
||||
}, [poolTotal, poolSelectedEmployees, splitMethod, hoursMap, customPctMap])
|
||||
|
||||
const customPctTotal = useMemo(() => {
|
||||
return poolSelectedEmployees.reduce((sum, e) => {
|
||||
return sum + (parseFloat(customPctMap[e.id] ?? '0') || 0)
|
||||
}, 0)
|
||||
}, [poolSelectedEmployees, customPctMap])
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
const val = parseFloat(amount)
|
||||
if (isNaN(val) || val <= 0 || !employeeId) return
|
||||
setIsSubmitting(true)
|
||||
const emp = employees.find(em => em.id === employeeId)
|
||||
const id = crypto.randomUUID()
|
||||
await save(`entry:${id}`, {
|
||||
date: entryDate,
|
||||
shift,
|
||||
employeeId,
|
||||
employeeName: emp?.name ?? '',
|
||||
amount: val,
|
||||
})
|
||||
setAmount('')
|
||||
await refresh()
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
|
||||
const openEditEntry = (entry: TipEntry) => {
|
||||
setEditEntry(entry)
|
||||
setEditDate(entry.date)
|
||||
setEditShift(entry.shift)
|
||||
setEditEmployeeId(entry.employeeId)
|
||||
setEditAmount(String(entry.amount))
|
||||
}
|
||||
|
||||
const handleSaveEdit = async () => {
|
||||
if (!editEntry) return
|
||||
const val = parseFloat(editAmount)
|
||||
if (isNaN(val) || val <= 0 || !editEmployeeId) return
|
||||
setIsSavingEdit(true)
|
||||
const emp = employees.find(em => em.id === editEmployeeId)
|
||||
await save(editEntry.id, {
|
||||
date: editDate,
|
||||
shift: editShift,
|
||||
employeeId: editEmployeeId,
|
||||
employeeName: emp?.name ?? '',
|
||||
amount: val,
|
||||
})
|
||||
await refresh()
|
||||
setIsSavingEdit(false)
|
||||
}
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
if (!deleteKey) return
|
||||
setIsDeleting(true)
|
||||
await remove(deleteKey)
|
||||
setIsDeleting(false)
|
||||
}
|
||||
|
||||
const handleAddEmployee = async () => {
|
||||
if (!newEmployeeName.trim()) return
|
||||
const id = crypto.randomUUID()
|
||||
await save(`employee:${id}`, { name: newEmployeeName.trim(), active: true })
|
||||
setNewEmployeeName('')
|
||||
await refresh()
|
||||
}
|
||||
|
||||
const handleToggleEmployee = async (emp: Employee) => {
|
||||
await save(`employee:${emp.id}`, { name: emp.name, active: !emp.active })
|
||||
await refresh()
|
||||
}
|
||||
|
||||
const openEditEmployee = (emp: Employee) => {
|
||||
setEditEmployee(emp)
|
||||
setEditEmployeeName(emp.name)
|
||||
}
|
||||
|
||||
const handleSaveEmployee = async () => {
|
||||
if (!editEmployee || !editEmployeeName.trim()) return
|
||||
setIsSavingEmployee(true)
|
||||
await save(`employee:${editEmployee.id}`, {
|
||||
name: editEmployeeName.trim(),
|
||||
active: editEmployee.active,
|
||||
})
|
||||
// Also update employeeName on existing entries for this employee
|
||||
const empEntries = data
|
||||
.filter(d => d.key.startsWith('entry:'))
|
||||
.filter(d => (d.value as { employeeId?: string }).employeeId === editEmployee.id)
|
||||
for (const rec of empEntries) {
|
||||
const val = rec.value as Record<string, unknown>
|
||||
await save(rec.key, { ...val, employeeName: editEmployeeName.trim() })
|
||||
}
|
||||
await refresh()
|
||||
setIsSavingEmployee(false)
|
||||
}
|
||||
|
||||
const handleTogglePooling = async () => {
|
||||
const newVal = !settings.poolingEnabled
|
||||
await save('settings', { ...settings, poolingEnabled: newVal })
|
||||
await refresh()
|
||||
}
|
||||
|
||||
const togglePoolEmployee = (empId: string) => {
|
||||
setPoolSelectedIds(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(empId)) {
|
||||
next.delete(empId)
|
||||
} else {
|
||||
next.add(empId)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const toggleExpandedEmployee = (empId: string) => {
|
||||
setExpandedEmployeeId(prev => prev === empId ? null : empId)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CSV export
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const handleExportCsv = useCallback(() => {
|
||||
if (entries.length === 0) return
|
||||
|
||||
const header = 'Datum,Skift,Anstalld,Belopp (kr)'
|
||||
const rows = entries.map(e =>
|
||||
`${e.date},${e.shift},${e.employeeName.replace(/,/g, ' ')},${e.amount}`
|
||||
)
|
||||
const csv = [header, ...rows].join('\n')
|
||||
const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `dricks_${dateRange.start}_${dateRange.end}.csv`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}, [entries, dateRange])
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
if (isLoading) return <ExtensionLoadingSkeleton />
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Edit entry dialog */}
|
||||
<EditEntryDialog
|
||||
open={editEntry !== null}
|
||||
onOpenChange={open => { if (!open) setEditEntry(null) }}
|
||||
title="Redigera dricksregistrering"
|
||||
description="Andra uppgifterna for denna registrering."
|
||||
onSave={handleSaveEdit}
|
||||
isSaving={isSavingEdit}
|
||||
>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<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>Skift</Label>
|
||||
<Select value={editShift} onValueChange={setEditShift}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{SHIFTS.map(s => <SelectItem key={s} value={s}>{s}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Anstalld</Label>
|
||||
<Select value={editEmployeeId} onValueChange={setEditEmployeeId}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{activeEmployees.map(e => (
|
||||
<SelectItem key={e.id} value={e.id}>{e.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Belopp (kr)</Label>
|
||||
<Input type="number" step="1" min="0" value={editAmount} onChange={e => setEditAmount(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
</EditEntryDialog>
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
<ConfirmDeleteDialog
|
||||
open={deleteKey !== null}
|
||||
onOpenChange={open => { if (!open) setDeleteKey(null) }}
|
||||
title="Ta bort registrering"
|
||||
description="Ar du saker pa att du vill ta bort denna dricksregistrering? Atgarden kan inte angras."
|
||||
onConfirm={handleConfirmDelete}
|
||||
isDeleting={isDeleting}
|
||||
/>
|
||||
|
||||
{/* Edit employee dialog */}
|
||||
<EditEntryDialog
|
||||
open={editEmployee !== null}
|
||||
onOpenChange={open => { if (!open) setEditEmployee(null) }}
|
||||
title="Redigera anstalld"
|
||||
description="Andra namn pa den anstallda."
|
||||
onSave={handleSaveEmployee}
|
||||
isSaving={isSavingEmployee}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label>Namn</Label>
|
||||
<Input value={editEmployeeName} onChange={e => setEditEmployeeName(e.target.value)} />
|
||||
</div>
|
||||
</EditEntryDialog>
|
||||
|
||||
<Tabs defaultValue="register">
|
||||
<TabsList>
|
||||
<TabsTrigger value="register">Registrera</TabsTrigger>
|
||||
<TabsTrigger value="overview">Oversikt</TabsTrigger>
|
||||
<TabsTrigger value="employees">Anstallda</TabsTrigger>
|
||||
<TabsTrigger value="pool">Drickspool</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
{/* Register tab */}
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
<TabsContent value="register" className="space-y-6 mt-4">
|
||||
{activeEmployees.length === 0 ? (
|
||||
<div className="rounded-xl border p-6 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Lagg till anstallda under fliken "Anstallda" for att borja registrera dricks.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<DataEntryForm
|
||||
title="Registrera dricks"
|
||||
onSubmit={handleSubmit}
|
||||
submitLabel="Registrera"
|
||||
isSubmitting={isSubmitting}
|
||||
>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tip-date">Datum</Label>
|
||||
<Input id="tip-date" type="date" value={entryDate} onChange={e => setEntryDate(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tip-shift">Skift</Label>
|
||||
<Select value={shift} onValueChange={setShift}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{SHIFTS.map(s => <SelectItem key={s} value={s}>{s}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tip-employee">Anstalld</Label>
|
||||
<Select value={employeeId} onValueChange={setEmployeeId}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{activeEmployees.map(e => (
|
||||
<SelectItem key={e.id} value={e.id}>{e.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tip-amount">Belopp (kr)</Label>
|
||||
<Input id="tip-amount" type="number" step="1" min="0" placeholder="0" value={amount} onChange={e => setAmount(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
</DataEntryForm>
|
||||
)}
|
||||
|
||||
{/* Recent entries */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-3">Senaste registreringar</h3>
|
||||
{entries.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Inga registreringar i vald period.</p>
|
||||
) : (
|
||||
<div className="rounded-xl border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Datum</TableHead>
|
||||
<TableHead>Skift</TableHead>
|
||||
<TableHead>Anstalld</TableHead>
|
||||
<TableHead className="text-right">Belopp</TableHead>
|
||||
<TableHead className="w-20"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{entries.slice(0, 20).map(e => (
|
||||
<TableRow key={e.id}>
|
||||
<TableCell>{e.date}</TableCell>
|
||||
<TableCell>{e.shift}</TableCell>
|
||||
<TableCell>{e.employeeName}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{e.amount.toLocaleString('sv-SE')} kr</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="sm" onClick={() => openEditEntry(e)}>
|
||||
<Pencil className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setDeleteKey(e.id)}>
|
||||
<Trash2 className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
{/* Overview tab */}
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
<TabsContent value="overview" className="space-y-6 mt-4">
|
||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||
<DateRangeFilter onRangeChange={(start, end) => setDateRange({ start, end })} />
|
||||
<Button variant="outline" size="sm" onClick={handleExportCsv} disabled={entries.length === 0}>
|
||||
<Download className="h-4 w-4 mr-1" />
|
||||
Exportera CSV
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<KPICard label="Total dricks" value={totalTips.toLocaleString('sv-SE')} suffix="kr" />
|
||||
<KPICard label="Snitt per skift" value={avgPerShift.toLocaleString('sv-SE')} suffix="kr" />
|
||||
<KPICard label="Antal registreringar" value={entries.length} />
|
||||
</div>
|
||||
|
||||
{monthlyTrend.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-3">Manadstrend</h3>
|
||||
<MonthlyTrendTable rows={monthlyTrend} valueLabel="Total dricks" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{employeeTotals.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-3">Per anstalld</h3>
|
||||
<div className="rounded-xl border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Anstalld</TableHead>
|
||||
<TableHead className="text-right">Total</TableHead>
|
||||
<TableHead className="text-right">Antal skift</TableHead>
|
||||
<TableHead className="text-right">Snitt/skift</TableHead>
|
||||
<TableHead className="w-10"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{employeeTotals.map(e => {
|
||||
const isExpanded = expandedEmployeeId === e.id
|
||||
return (
|
||||
<TableRow key={e.id} className="group">
|
||||
<TableCell>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 font-medium text-left hover:underline"
|
||||
onClick={() => toggleExpandedEmployee(e.id)}
|
||||
>
|
||||
{isExpanded
|
||||
? <ChevronUp className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
: <ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
}
|
||||
{e.name}
|
||||
</button>
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{e.total.toLocaleString('sv-SE')} kr</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{e.count}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{Math.round(e.total / e.count).toLocaleString('sv-SE')} kr</TableCell>
|
||||
<TableCell />
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Expanded employee detail */}
|
||||
{expandedEmployeeId && expandedEmployeeEntries.length > 0 && (
|
||||
<div className="mt-3 ml-4">
|
||||
<h4 className="text-sm font-medium mb-2">
|
||||
Drickshistorik for {employeeTotals.find(e => e.id === expandedEmployeeId)?.name}
|
||||
</h4>
|
||||
<div className="rounded-xl border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Datum</TableHead>
|
||||
<TableHead>Skift</TableHead>
|
||||
<TableHead className="text-right">Belopp</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{expandedEmployeeEntries.map(e => (
|
||||
<TableRow key={e.id}>
|
||||
<TableCell>{e.date}</TableCell>
|
||||
<TableCell>{e.shift}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{e.amount.toLocaleString('sv-SE')} kr</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
<TableRow className="font-medium border-t-2">
|
||||
<TableCell colSpan={2}>Totalt / Snitt</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{expandedEmployeeEntries.reduce((s, e) => s + e.amount, 0).toLocaleString('sv-SE')} kr
|
||||
{' '}({Math.round(expandedEmployeeEntries.reduce((s, e) => s + e.amount, 0) / expandedEmployeeEntries.length).toLocaleString('sv-SE')} kr/skift)
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
{/* Employees tab */}
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
<TabsContent value="employees" className="space-y-6 mt-4">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Namn pa anstalld"
|
||||
value={newEmployeeName}
|
||||
onChange={e => setNewEmployeeName(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleAddEmployee()}
|
||||
className="max-w-xs"
|
||||
/>
|
||||
<Button size="sm" onClick={handleAddEmployee} disabled={!newEmployeeName.trim()}>
|
||||
<Plus className="h-4 w-4 mr-1" /> Lagg till
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{employees.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Inga anstallda tillagda annu.</p>
|
||||
) : (
|
||||
<div className="rounded-xl border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Namn</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="w-32"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{employees.map(emp => (
|
||||
<TableRow key={emp.id}>
|
||||
<TableCell className="font-medium">{emp.name}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={emp.active ? 'default' : 'secondary'}>
|
||||
{emp.active ? 'Aktiv' : 'Inaktiv'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="sm" onClick={() => openEditEmployee(emp)}>
|
||||
<Pencil className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => handleToggleEmployee(emp)}>
|
||||
{emp.active ? 'Inaktivera' : 'Aktivera'}
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
{/* Tip pool tab */}
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
<TabsContent value="pool" className="space-y-6 mt-4">
|
||||
<div className="rounded-xl border p-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">Drickspool</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Fordela dricks fran en gemensam pool till utvalda anstallda.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant={settings.poolingEnabled ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={handleTogglePooling}
|
||||
>
|
||||
{settings.poolingEnabled ? 'Aktiverad' : 'Inaktiverad'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{Boolean(settings.poolingEnabled) && (
|
||||
<div className="space-y-6">
|
||||
{/* Pool amount */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="pool-amount">Total poolbelopp (kr)</Label>
|
||||
<Input
|
||||
id="pool-amount"
|
||||
type="number"
|
||||
step="1"
|
||||
min="0"
|
||||
placeholder="0"
|
||||
value={poolAmount}
|
||||
onChange={e => setPoolAmount(e.target.value)}
|
||||
className="max-w-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Select employees */}
|
||||
<div className="space-y-2">
|
||||
<Label>Valj anstallda</Label>
|
||||
{activeEmployees.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Inga aktiva anstallda.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{activeEmployees.map(emp => (
|
||||
<div key={emp.id} className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id={`pool-emp-${emp.id}`}
|
||||
checked={poolSelectedIds.has(emp.id)}
|
||||
onCheckedChange={() => togglePoolEmployee(emp.id)}
|
||||
/>
|
||||
<label htmlFor={`pool-emp-${emp.id}`} className="text-sm cursor-pointer">
|
||||
{emp.name}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Split method */}
|
||||
{poolSelectedEmployees.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label>Fordelningsmetod</Label>
|
||||
<Select value={splitMethod} onValueChange={v => setSplitMethod(v as SplitMethod)}>
|
||||
<SelectTrigger className="max-w-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="equal">Lika</SelectItem>
|
||||
<SelectItem value="hours">Per timmar</SelectItem>
|
||||
<SelectItem value="custom">Anpassad %</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hours input (when split by hours) */}
|
||||
{splitMethod === 'hours' && poolSelectedEmployees.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<Label>Timmar per anstalld</Label>
|
||||
{poolSelectedEmployees.map(emp => (
|
||||
<div key={emp.id} className="flex items-center gap-3">
|
||||
<span className="text-sm w-32 truncate">{emp.name}</span>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.5"
|
||||
min="0"
|
||||
placeholder="0"
|
||||
className="max-w-24"
|
||||
value={hoursMap[emp.id] ?? ''}
|
||||
onChange={e => setHoursMap(prev => ({ ...prev, [emp.id]: e.target.value }))}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">timmar</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Custom percentage input */}
|
||||
{splitMethod === 'custom' && poolSelectedEmployees.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<Label>Procent per anstalld</Label>
|
||||
{poolSelectedEmployees.map(emp => (
|
||||
<div key={emp.id} className="flex items-center gap-3">
|
||||
<span className="text-sm w-32 truncate">{emp.name}</span>
|
||||
<Input
|
||||
type="number"
|
||||
step="1"
|
||||
min="0"
|
||||
max="100"
|
||||
placeholder="0"
|
||||
className="max-w-24"
|
||||
value={customPctMap[emp.id] ?? ''}
|
||||
onChange={e => setCustomPctMap(prev => ({ ...prev, [emp.id]: e.target.value }))}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">%</span>
|
||||
</div>
|
||||
))}
|
||||
<p className={`text-xs ${Math.abs(customPctTotal - 100) < 0.01 ? 'text-muted-foreground' : 'text-red-600'}`}>
|
||||
Summa: {customPctTotal}% {Math.abs(customPctTotal - 100) >= 0.01 && '(maste vara 100%)'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Distribution result */}
|
||||
{poolDistribution.length > 0 && poolTotal > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold mb-2">Fordelningsresultat</h4>
|
||||
<div className="rounded-xl border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Anstalld</TableHead>
|
||||
<TableHead className="text-right">Andel (kr)</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{poolDistribution.map(d => (
|
||||
<TableRow key={d.id}>
|
||||
<TableCell className="font-medium">{d.name}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{d.share.toLocaleString('sv-SE')} kr</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
<TableRow className="font-medium border-t-2">
|
||||
<TableCell>Totalt</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{poolDistribution.reduce((s, d) => s + d.share, 0).toLocaleString('sv-SE')} kr
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,965 +0,0 @@
|
||||
'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 MonthlyTrendTable from '@/components/extensions/shared/MonthlyTrendTable'
|
||||
import DataEntryForm from '@/components/extensions/shared/DataEntryForm'
|
||||
import SetupPrompt from '@/components/extensions/shared/SetupPrompt'
|
||||
import ExtensionLoadingSkeleton from '@/components/extensions/shared/ExtensionLoadingSkeleton'
|
||||
import ConfirmDeleteDialog from '@/components/extensions/shared/ConfirmDeleteDialog'
|
||||
import EditEntryDialog from '@/components/extensions/shared/EditEntryDialog'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
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, DialogTrigger, DialogFooter, DialogDescription,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Pencil, Plus, Trash2, Settings, ChevronLeft, ChevronRight, Archive, CheckCircle } from 'lucide-react'
|
||||
|
||||
// --- Types ---
|
||||
|
||||
type ProjectStatus = 'active' | 'completed' | 'archived'
|
||||
|
||||
interface TimeEntry {
|
||||
id: string
|
||||
date: string
|
||||
projectId: string
|
||||
projectName: string
|
||||
hours: number
|
||||
billable: boolean
|
||||
description: string
|
||||
}
|
||||
|
||||
interface Project {
|
||||
id: string
|
||||
name: string
|
||||
active: boolean
|
||||
client?: string
|
||||
hourlyRate?: number
|
||||
status?: ProjectStatus
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function getMonday(d: Date): Date {
|
||||
const copy = new Date(d)
|
||||
const day = copy.getDay()
|
||||
const diff = day === 0 ? -6 : 1 - day
|
||||
copy.setDate(copy.getDate() + diff)
|
||||
copy.setHours(0, 0, 0, 0)
|
||||
return copy
|
||||
}
|
||||
|
||||
function addDays(d: Date, n: number): Date {
|
||||
const copy = new Date(d)
|
||||
copy.setDate(copy.getDate() + n)
|
||||
return copy
|
||||
}
|
||||
|
||||
function formatDateStr(d: Date): string {
|
||||
return d.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
const DAY_LABELS = ['Man', 'Tis', 'Ons', 'Tor', 'Fre', 'Lor', 'Son']
|
||||
|
||||
function getProjectStatus(p: { active: boolean; status?: ProjectStatus }): ProjectStatus {
|
||||
return p.status ?? (p.active ? 'active' : 'completed')
|
||||
}
|
||||
|
||||
function getEffectiveRate(project: Project | undefined, globalRate: number): number {
|
||||
if (project?.hourlyRate && project.hourlyRate > 0) return project.hourlyRate
|
||||
return globalRate
|
||||
}
|
||||
|
||||
function statusLabel(status: ProjectStatus): string {
|
||||
switch (status) {
|
||||
case 'active': return 'Aktiv'
|
||||
case 'completed': return 'Avslutad'
|
||||
case 'archived': return 'Arkiverad'
|
||||
}
|
||||
}
|
||||
|
||||
function statusBadgeVariant(status: ProjectStatus): 'default' | 'secondary' | 'outline' {
|
||||
switch (status) {
|
||||
case 'active': return 'default'
|
||||
case 'completed': return 'secondary'
|
||||
case 'archived': return 'outline'
|
||||
}
|
||||
}
|
||||
|
||||
// --- Component ---
|
||||
|
||||
export default function BillableHoursWorkspace({}: WorkspaceComponentProps) {
|
||||
const { data, save, remove, refresh, isLoading } = useExtensionData('tech', 'billable-hours')
|
||||
const settings = data.find(d => d.key === 'settings')?.value as { hourlyRate?: number } | undefined
|
||||
const hourlyRate = settings?.hourlyRate ?? 0
|
||||
|
||||
// --- Derived data ---
|
||||
|
||||
const projects = useMemo(() =>
|
||||
data.filter(d => d.key.startsWith('project:'))
|
||||
.map(d => ({
|
||||
id: d.key.replace('project:', ''),
|
||||
...(d.value as Omit<Project, 'id'>),
|
||||
}))
|
||||
, [data])
|
||||
|
||||
const entries = useMemo(() =>
|
||||
data.filter(d => d.key.startsWith('entry:'))
|
||||
.map(d => ({ id: d.key.replace('entry:', ''), ...(d.value as Omit<TimeEntry, 'id'>) }))
|
||||
.sort((a, b) => b.date.localeCompare(a.date))
|
||||
, [data])
|
||||
|
||||
// --- Form state ---
|
||||
|
||||
const todayStr = useMemo(() => new Date().toISOString().slice(0, 10), [])
|
||||
const currentMonth = useMemo(() => new Date().toISOString().slice(0, 7), [])
|
||||
const [entryDate, setEntryDate] = useState(todayStr)
|
||||
const [selectedProjectId, setProjectId] = useState('')
|
||||
const activeProjects = projects.filter(p => getProjectStatus(p) === 'active')
|
||||
const projectId = selectedProjectId || (activeProjects.length > 0 ? activeProjects[0].id : '')
|
||||
const [hours, setHours] = useState('')
|
||||
const [billable, setBillable] = useState(true)
|
||||
const [description, setDescription] = useState('')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
// New project dialog
|
||||
const [newProjectName, setNewProjectName] = useState('')
|
||||
const [newProjectClient, setNewProjectClient] = useState('')
|
||||
const [newProjectRate, setNewProjectRate] = useState('')
|
||||
const [showNewProject, setShowNewProject] = useState(false)
|
||||
|
||||
// Edit entry dialog
|
||||
const [editEntry, setEditEntry] = useState<TimeEntry | null>(null)
|
||||
const [editDate, setEditDate] = useState('')
|
||||
const [editProjectId, setEditProjectId] = useState('')
|
||||
const [editHours, setEditHours] = useState('')
|
||||
const [editBillable, setEditBillable] = useState(true)
|
||||
const [editDescription, setEditDescription] = useState('')
|
||||
const [isSavingEdit, setIsSavingEdit] = useState(false)
|
||||
|
||||
// Delete confirm
|
||||
const [deleteEntryId, setDeleteEntryId] = useState<string | null>(null)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
|
||||
// Show archived toggle
|
||||
const [showArchived, setShowArchived] = useState(false)
|
||||
|
||||
// Settings dialog
|
||||
const [showSettings, setShowSettings] = useState(false)
|
||||
const [settingsRate, setSettingsRate] = useState('')
|
||||
|
||||
// Weekly view state
|
||||
const [weekStart, setWeekStart] = useState(() => getMonday(new Date()))
|
||||
const [weekCellProject, setWeekCellProject] = useState<string | null>(null)
|
||||
const [weekCellDay, setWeekCellDay] = useState<number | null>(null)
|
||||
const [weekCellHours, setWeekCellHours] = useState('')
|
||||
|
||||
// --- KPI calculations ---
|
||||
|
||||
const monthEntries = useMemo(() =>
|
||||
entries.filter(e => e.date.startsWith(currentMonth))
|
||||
, [entries, currentMonth])
|
||||
const totalHours = monthEntries.reduce((s, e) => s + e.hours, 0)
|
||||
const billableHours = monthEntries.filter(e => e.billable).reduce((s, e) => s + e.hours, 0)
|
||||
const utilization = totalHours > 0 ? Math.round((billableHours / totalHours) * 100) : 0
|
||||
const effectiveRate = totalHours > 0 ? Math.round((billableHours * hourlyRate) / totalHours) : 0
|
||||
|
||||
// Today's entries
|
||||
const todayEntries = entries.filter(e => e.date === todayStr)
|
||||
|
||||
// Monthly trend (utilization %)
|
||||
const monthlyTrend = useMemo(() => {
|
||||
const map = new Map<string, { total: number; billable: number }>()
|
||||
for (const e of entries) {
|
||||
const month = e.date.slice(0, 7)
|
||||
const existing = map.get(month) ?? { total: 0, billable: 0 }
|
||||
existing.total += e.hours
|
||||
if (e.billable) existing.billable += e.hours
|
||||
map.set(month, existing)
|
||||
}
|
||||
return Array.from(map.entries())
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([month, d]) => ({
|
||||
month,
|
||||
value: d.total > 0 ? Math.round((d.billable / d.total) * 100) : 0,
|
||||
}))
|
||||
}, [entries])
|
||||
|
||||
// Period summary: monthly totals for billable/non-billable + revenue
|
||||
const periodSummary = useMemo(() => {
|
||||
const map = new Map<string, { billable: number; nonBillable: number; revenue: number }>()
|
||||
for (const e of entries) {
|
||||
const month = e.date.slice(0, 7)
|
||||
const existing = map.get(month) ?? { billable: 0, nonBillable: 0, revenue: 0 }
|
||||
if (e.billable) {
|
||||
existing.billable += e.hours
|
||||
const proj = projects.find(p => p.id === e.projectId)
|
||||
const rate = getEffectiveRate(proj, hourlyRate)
|
||||
existing.revenue += Math.round(e.hours * rate * 100) / 100
|
||||
} else {
|
||||
existing.nonBillable += e.hours
|
||||
}
|
||||
map.set(month, existing)
|
||||
}
|
||||
return Array.from(map.entries())
|
||||
.sort(([a], [b]) => b.localeCompare(a))
|
||||
.map(([month, d]) => ({ month, ...d }))
|
||||
}, [entries, projects, hourlyRate])
|
||||
|
||||
// Per-project stats
|
||||
const projectStats = useMemo(() => {
|
||||
const cm = new Date().toISOString().slice(0, 7)
|
||||
const filtered = entries.filter(e => e.date.startsWith(cm))
|
||||
const map = new Map<string, { total: number; billable: number }>()
|
||||
for (const e of filtered) {
|
||||
const existing = map.get(e.projectId) ?? { total: 0, billable: 0 }
|
||||
existing.total += e.hours
|
||||
if (e.billable) existing.billable += e.hours
|
||||
map.set(e.projectId, existing)
|
||||
}
|
||||
return projects.map(p => {
|
||||
const stats = map.get(p.id) ?? { total: 0, billable: 0 }
|
||||
return {
|
||||
...p,
|
||||
...stats,
|
||||
effectiveStatus: getProjectStatus(p),
|
||||
utilization: stats.total > 0 ? Math.round((stats.billable / stats.total) * 100) : 0,
|
||||
}
|
||||
})
|
||||
}, [projects, entries])
|
||||
|
||||
// Weekly grid data
|
||||
const weekDays = useMemo(() =>
|
||||
Array.from({ length: 7 }, (_, i) => formatDateStr(addDays(weekStart, i)))
|
||||
, [weekStart])
|
||||
|
||||
const weekLabel = useMemo(() => {
|
||||
const end = addDays(weekStart, 6)
|
||||
const startStr = weekStart.toLocaleDateString('sv-SE', { day: 'numeric', month: 'short' })
|
||||
const endStr = end.toLocaleDateString('sv-SE', { day: 'numeric', month: 'short' })
|
||||
return `${startStr} - ${endStr}`
|
||||
}, [weekStart])
|
||||
|
||||
const weekGrid = useMemo(() => {
|
||||
const weekDateSet = new Set(weekDays)
|
||||
const weekEntries = entries.filter(e => weekDateSet.has(e.date))
|
||||
const grid = new Map<string, Map<number, number>>()
|
||||
for (const p of activeProjects) {
|
||||
const dayMap = new Map<number, number>()
|
||||
for (let i = 0; i < 7; i++) dayMap.set(i, 0)
|
||||
grid.set(p.id, dayMap)
|
||||
}
|
||||
for (const e of weekEntries) {
|
||||
const dayIndex = weekDays.indexOf(e.date)
|
||||
if (dayIndex < 0) continue
|
||||
const existing = grid.get(e.projectId)
|
||||
if (existing) {
|
||||
existing.set(dayIndex, (existing.get(dayIndex) ?? 0) + e.hours)
|
||||
}
|
||||
}
|
||||
return grid
|
||||
}, [activeProjects, entries, weekDays])
|
||||
|
||||
const weekDayTotals = useMemo(() => {
|
||||
const totals = Array(7).fill(0)
|
||||
for (const dayMap of weekGrid.values()) {
|
||||
for (let i = 0; i < 7; i++) {
|
||||
totals[i] += dayMap.get(i) ?? 0
|
||||
}
|
||||
}
|
||||
return totals as number[]
|
||||
}, [weekGrid])
|
||||
|
||||
const weekProjectTotals = useMemo(() => {
|
||||
const map = new Map<string, number>()
|
||||
for (const [projId, dayMap] of weekGrid.entries()) {
|
||||
let total = 0
|
||||
for (const h of dayMap.values()) total += h
|
||||
map.set(projId, total)
|
||||
}
|
||||
return map
|
||||
}, [weekGrid])
|
||||
|
||||
const weekGrandTotal = weekDayTotals.reduce((s, v) => s + v, 0)
|
||||
|
||||
// --- Handlers ---
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
const h = parseFloat(hours)
|
||||
if (isNaN(h) || h <= 0 || !projectId) return
|
||||
setIsSubmitting(true)
|
||||
const proj = projects.find(p => p.id === projectId)
|
||||
const id = crypto.randomUUID()
|
||||
await save(`entry:${id}`, {
|
||||
date: entryDate,
|
||||
projectId,
|
||||
projectName: proj?.name ?? '',
|
||||
hours: h,
|
||||
billable,
|
||||
description,
|
||||
})
|
||||
setHours('')
|
||||
setDescription('')
|
||||
setBillable(true)
|
||||
await refresh()
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
|
||||
const handleAddProject = async () => {
|
||||
if (!newProjectName.trim()) return
|
||||
const id = crypto.randomUUID()
|
||||
const rateVal = parseFloat(newProjectRate)
|
||||
await save(`project:${id}`, {
|
||||
name: newProjectName.trim(),
|
||||
active: true,
|
||||
status: 'active' as ProjectStatus,
|
||||
client: newProjectClient.trim() || undefined,
|
||||
hourlyRate: (!isNaN(rateVal) && rateVal > 0) ? rateVal : undefined,
|
||||
})
|
||||
setNewProjectName('')
|
||||
setNewProjectClient('')
|
||||
setNewProjectRate('')
|
||||
setShowNewProject(false)
|
||||
await refresh()
|
||||
}
|
||||
|
||||
const handleDeleteEntry = async () => {
|
||||
if (!deleteEntryId) return
|
||||
setIsDeleting(true)
|
||||
await remove(`entry:${deleteEntryId}`)
|
||||
await refresh()
|
||||
setIsDeleting(false)
|
||||
setDeleteEntryId(null)
|
||||
}
|
||||
|
||||
const openEditEntry = useCallback((entry: TimeEntry) => {
|
||||
setEditEntry(entry)
|
||||
setEditDate(entry.date)
|
||||
setEditProjectId(entry.projectId)
|
||||
setEditHours(String(entry.hours))
|
||||
setEditBillable(entry.billable)
|
||||
setEditDescription(entry.description)
|
||||
}, [])
|
||||
|
||||
const handleSaveEdit = async () => {
|
||||
if (!editEntry) return
|
||||
const h = parseFloat(editHours)
|
||||
if (isNaN(h) || h <= 0 || !editProjectId) return
|
||||
setIsSavingEdit(true)
|
||||
const proj = projects.find(p => p.id === editProjectId)
|
||||
await save(`entry:${editEntry.id}`, {
|
||||
date: editDate,
|
||||
projectId: editProjectId,
|
||||
projectName: proj?.name ?? editEntry.projectName,
|
||||
hours: h,
|
||||
billable: editBillable,
|
||||
description: editDescription,
|
||||
})
|
||||
await refresh()
|
||||
setIsSavingEdit(false)
|
||||
setEditEntry(null)
|
||||
}
|
||||
|
||||
const handleProjectStatusChange = async (projectId: string, newStatus: ProjectStatus) => {
|
||||
const proj = projects.find(p => p.id === projectId)
|
||||
if (!proj) return
|
||||
await save(`project:${projectId}`, {
|
||||
name: proj.name,
|
||||
active: newStatus === 'active',
|
||||
status: newStatus,
|
||||
client: proj.client,
|
||||
hourlyRate: proj.hourlyRate,
|
||||
})
|
||||
await refresh()
|
||||
}
|
||||
|
||||
const handleSetup = async (values: Record<string, string>) => {
|
||||
await save('settings', { hourlyRate: parseFloat(values.hourlyRate) || 0 })
|
||||
}
|
||||
|
||||
const handleSaveSettings = async () => {
|
||||
const rate = parseFloat(settingsRate)
|
||||
if (isNaN(rate) || rate <= 0) return
|
||||
await save('settings', { hourlyRate: rate })
|
||||
await refresh()
|
||||
setShowSettings(false)
|
||||
}
|
||||
|
||||
const handleWeekCellClick = (projId: string, dayIndex: number) => {
|
||||
setWeekCellProject(projId)
|
||||
setWeekCellDay(dayIndex)
|
||||
setWeekCellHours('')
|
||||
}
|
||||
|
||||
const handleWeekCellSubmit = async () => {
|
||||
if (weekCellProject === null || weekCellDay === null) return
|
||||
const h = parseFloat(weekCellHours)
|
||||
if (isNaN(h) || h <= 0) {
|
||||
setWeekCellProject(null)
|
||||
setWeekCellDay(null)
|
||||
return
|
||||
}
|
||||
const proj = projects.find(p => p.id === weekCellProject)
|
||||
const id = crypto.randomUUID()
|
||||
await save(`entry:${id}`, {
|
||||
date: weekDays[weekCellDay],
|
||||
projectId: weekCellProject,
|
||||
projectName: proj?.name ?? '',
|
||||
hours: h,
|
||||
billable: true,
|
||||
description: '',
|
||||
})
|
||||
await refresh()
|
||||
setWeekCellProject(null)
|
||||
setWeekCellDay(null)
|
||||
setWeekCellHours('')
|
||||
}
|
||||
|
||||
const handleWeekCellKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
handleWeekCellSubmit()
|
||||
} else if (e.key === 'Escape') {
|
||||
setWeekCellProject(null)
|
||||
setWeekCellDay(null)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Render ---
|
||||
|
||||
if (isLoading) return <ExtensionLoadingSkeleton />
|
||||
|
||||
if (!hourlyRate) {
|
||||
return (
|
||||
<SetupPrompt
|
||||
title="Konfigurera timpris"
|
||||
description="Ange ditt timpris for att borja spara debiterbar tid."
|
||||
fields={[{ key: 'hourlyRate', label: 'Timpris (kr/h)', type: 'number', placeholder: 'T.ex. 1000' }]}
|
||||
onSave={handleSetup}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const visibleProjectStats = showArchived
|
||||
? projectStats
|
||||
: projectStats.filter(p => p.effectiveStatus !== 'archived')
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Settings gear button */}
|
||||
<div className="flex justify-end">
|
||||
<Dialog open={showSettings} onOpenChange={(open) => {
|
||||
setShowSettings(open)
|
||||
if (open) setSettingsRate(String(hourlyRate))
|
||||
}}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Settings className="h-4 w-4 mr-1" />
|
||||
Installningar
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Installningar</DialogTitle>
|
||||
<DialogDescription>Andra ditt globala timpris.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Timpris (kr/h)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
value={settingsRate}
|
||||
onChange={e => setSettingsRate(e.target.value)}
|
||||
placeholder="T.ex. 1000"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowSettings(false)}>Avbryt</Button>
|
||||
<Button onClick={handleSaveSettings} disabled={!settingsRate || parseFloat(settingsRate) <= 0}>
|
||||
Spara
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="timesheet">
|
||||
<TabsList>
|
||||
<TabsTrigger value="timesheet">Tidrapport</TabsTrigger>
|
||||
<TabsTrigger value="weekly">Veckorapport</TabsTrigger>
|
||||
<TabsTrigger value="overview">Oversikt</TabsTrigger>
|
||||
<TabsTrigger value="projects">Projekt</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* --- Timesheet Tab --- */}
|
||||
<TabsContent value="timesheet" className="space-y-6 mt-4">
|
||||
{activeProjects.length === 0 ? (
|
||||
<div className="rounded-xl border p-6 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Lagg till projekt under fliken "Projekt" for att borja rapportera tid.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<DataEntryForm
|
||||
title="Registrera tid"
|
||||
onSubmit={handleSubmit}
|
||||
submitLabel="Registrera"
|
||||
isSubmitting={isSubmitting}
|
||||
>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Datum</Label>
|
||||
<Input type="date" value={entryDate} onChange={e => setEntryDate(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Projekt</Label>
|
||||
<Select value={projectId} onValueChange={setProjectId}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{activeProjects.map(p => <SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Timmar</Label>
|
||||
<Input type="number" step="0.25" min="0" placeholder="0" value={hours} onChange={e => setHours(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Beskrivning</Label>
|
||||
<Input placeholder="Vad jobbade du med?" value={description} onChange={e => setDescription(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch checked={billable} onCheckedChange={setBillable} id="billable-switch" />
|
||||
<Label htmlFor="billable-switch" className="text-sm">Debiterbar</Label>
|
||||
</div>
|
||||
</DataEntryForm>
|
||||
)}
|
||||
|
||||
{/* Today's entries */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-3">Idag ({todayStr})</h3>
|
||||
{todayEntries.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Ingen tid registrerad idag.</p>
|
||||
) : (
|
||||
<div className="rounded-xl border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Projekt</TableHead>
|
||||
<TableHead className="text-right">Timmar</TableHead>
|
||||
<TableHead>Typ</TableHead>
|
||||
<TableHead>Beskrivning</TableHead>
|
||||
<TableHead className="w-20"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{todayEntries.map(e => (
|
||||
<TableRow key={e.id}>
|
||||
<TableCell className="font-medium">{e.projectName}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{e.hours}h</TableCell>
|
||||
<TableCell>{e.billable ? 'Debiterbar' : 'Intern'}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{e.description}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="sm" onClick={() => openEditEntry(e)}>
|
||||
<Pencil className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setDeleteEntryId(e.id)}>
|
||||
<Trash2 className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* --- Weekly Tab --- */}
|
||||
<TabsContent value="weekly" className="space-y-6 mt-4">
|
||||
{activeProjects.length === 0 ? (
|
||||
<div className="rounded-xl border p-6 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Lagg till projekt under fliken "Projekt" for att borja rapportera tid.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setWeekStart(prev => addDays(prev, -7))}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||
Foregaende
|
||||
</Button>
|
||||
<span className="text-sm font-medium">{weekLabel}</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setWeekStart(prev => addDays(prev, 7))}
|
||||
>
|
||||
Nasta
|
||||
<ChevronRight className="h-4 w-4 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="min-w-[140px]">Projekt</TableHead>
|
||||
{DAY_LABELS.map((label, i) => (
|
||||
<TableHead key={i} className="text-center min-w-[80px]">
|
||||
<div className="text-xs">{label}</div>
|
||||
<div className="text-xs text-muted-foreground">{weekDays[i].slice(5)}</div>
|
||||
</TableHead>
|
||||
))}
|
||||
<TableHead className="text-right min-w-[70px]">Totalt</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{activeProjects.map(p => {
|
||||
const dayMap = weekGrid.get(p.id)
|
||||
const projTotal = weekProjectTotals.get(p.id) ?? 0
|
||||
return (
|
||||
<TableRow key={p.id}>
|
||||
<TableCell className="font-medium text-sm">{p.name}</TableCell>
|
||||
{Array.from({ length: 7 }, (_, i) => {
|
||||
const cellHours = dayMap?.get(i) ?? 0
|
||||
const isEditing = weekCellProject === p.id && weekCellDay === i
|
||||
return (
|
||||
<TableCell key={i} className="text-center p-1">
|
||||
{isEditing ? (
|
||||
<Input
|
||||
type="number"
|
||||
step="0.25"
|
||||
min="0"
|
||||
className="h-8 w-16 mx-auto text-center text-sm"
|
||||
value={weekCellHours}
|
||||
onChange={e => setWeekCellHours(e.target.value)}
|
||||
onBlur={handleWeekCellSubmit}
|
||||
onKeyDown={handleWeekCellKeyDown}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="w-full h-8 rounded text-sm tabular-nums hover:bg-muted transition-colors cursor-pointer"
|
||||
onClick={() => handleWeekCellClick(p.id, i)}
|
||||
title="Klicka for att registrera timmar"
|
||||
>
|
||||
{cellHours > 0 ? `${cellHours}h` : '-'}
|
||||
</button>
|
||||
)}
|
||||
</TableCell>
|
||||
)
|
||||
})}
|
||||
<TableCell className="text-right tabular-nums font-medium">
|
||||
{projTotal > 0 ? `${projTotal}h` : '-'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
{/* Totals row */}
|
||||
<TableRow className="border-t-2 font-semibold">
|
||||
<TableCell>Totalt</TableCell>
|
||||
{weekDayTotals.map((total, i) => (
|
||||
<TableCell key={i} className="text-center tabular-nums">
|
||||
{total > 0 ? `${total}h` : '-'}
|
||||
</TableCell>
|
||||
))}
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{weekGrandTotal > 0 ? `${weekGrandTotal}h` : '-'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* --- Overview Tab --- */}
|
||||
<TabsContent value="overview" className="space-y-6 mt-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-4 gap-4">
|
||||
<KPICard label="Belaggningsgrad" value={utilization} suffix="%" />
|
||||
<KPICard label="Debiterbara timmar" value={billableHours} suffix="h" />
|
||||
<KPICard label="Totala timmar" value={totalHours} suffix="h" />
|
||||
<KPICard label="Effektivt timpris" value={effectiveRate.toLocaleString('sv-SE')} suffix="kr/h" />
|
||||
</div>
|
||||
|
||||
{monthlyTrend.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-3">Belaggning per manad</h3>
|
||||
<MonthlyTrendTable rows={monthlyTrend} valueLabel="Belaggning" valueSuffix="%" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Period summary */}
|
||||
{periodSummary.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-3">Manatlig sammanstallning</h3>
|
||||
<div className="rounded-xl border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Period</TableHead>
|
||||
<TableHead className="text-right">Debiterbara</TableHead>
|
||||
<TableHead className="text-right">Icke-debiterbara</TableHead>
|
||||
<TableHead className="text-right">Totalt</TableHead>
|
||||
<TableHead className="text-right">Intakt</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{periodSummary.map(row => (
|
||||
<TableRow key={row.month}>
|
||||
<TableCell className="font-medium">{row.month}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{row.billable}h</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{row.nonBillable}h</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{Math.round((row.billable + row.nonBillable) * 100) / 100}h
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{Math.round(row.revenue).toLocaleString('sv-SE')} kr
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* --- Projects Tab --- */}
|
||||
<TabsContent value="projects" className="space-y-6 mt-4">
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<Dialog open={showNewProject} onOpenChange={setShowNewProject}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm" variant="outline">
|
||||
<Plus className="h-4 w-4 mr-1" /> Nytt projekt
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Nytt projekt</DialogTitle>
|
||||
<DialogDescription>Lagg till ett nytt projekt att rapportera tid pa.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Projektnamn</Label>
|
||||
<Input
|
||||
placeholder="T.ex. Kundprojekt A"
|
||||
value={newProjectName}
|
||||
onChange={e => setNewProjectName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Kund (valfritt)</Label>
|
||||
<Input
|
||||
placeholder="T.ex. Foretag AB"
|
||||
value={newProjectClient}
|
||||
onChange={e => setNewProjectClient(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Timpris (kr/h, valfritt)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder={`Standard: ${hourlyRate}`}
|
||||
value={newProjectRate}
|
||||
onChange={e => setNewProjectRate(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Om tomt anvands det globala timpriset ({hourlyRate} kr/h).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button onClick={handleAddProject} disabled={!newProjectName.trim()}>Skapa</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={showArchived}
|
||||
onCheckedChange={setShowArchived}
|
||||
id="show-archived-switch"
|
||||
/>
|
||||
<Label htmlFor="show-archived-switch" className="text-sm">Visa arkiverade</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{visibleProjectStats.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Inga projekt att visa.</p>
|
||||
) : (
|
||||
<div className="rounded-xl border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Projekt</TableHead>
|
||||
<TableHead>Kund</TableHead>
|
||||
<TableHead className="text-right">Timpris</TableHead>
|
||||
<TableHead className="text-right">Timmar (manad)</TableHead>
|
||||
<TableHead className="text-right">Debiterbara</TableHead>
|
||||
<TableHead className="text-right">Belaggning</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="w-28"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{visibleProjectStats.map(p => {
|
||||
const status = p.effectiveStatus
|
||||
return (
|
||||
<TableRow key={p.id} className={status === 'archived' ? 'opacity-60' : undefined}>
|
||||
<TableCell className="font-medium">{p.name}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{p.client || '-'}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{p.hourlyRate ? `${p.hourlyRate} kr/h` : `${hourlyRate} kr/h`}
|
||||
{p.hourlyRate ? (
|
||||
<span className="text-xs text-muted-foreground ml-1">(projekt)</span>
|
||||
) : null}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{p.total}h</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{p.billable}h</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{p.utilization}%</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={statusBadgeVariant(status)}>
|
||||
{statusLabel(status)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
{status === 'active' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
title="Markera som avslutad"
|
||||
onClick={() => handleProjectStatusChange(p.id, 'completed')}
|
||||
>
|
||||
<CheckCircle className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
)}
|
||||
{status === 'completed' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
title="Ateraktivera"
|
||||
onClick={() => handleProjectStatusChange(p.id, 'active')}
|
||||
>
|
||||
<CheckCircle className="h-3.5 w-3.5 text-green-600" />
|
||||
</Button>
|
||||
)}
|
||||
{status !== 'archived' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
title="Arkivera"
|
||||
onClick={() => handleProjectStatusChange(p.id, 'archived')}
|
||||
>
|
||||
<Archive className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
)}
|
||||
{status === 'archived' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
title="Ateraktivera"
|
||||
onClick={() => handleProjectStatusChange(p.id, 'active')}
|
||||
>
|
||||
<Archive className="h-3.5 w-3.5 text-green-600" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Edit Entry Dialog */}
|
||||
<EditEntryDialog
|
||||
open={editEntry !== null}
|
||||
onOpenChange={(open) => { if (!open) setEditEntry(null) }}
|
||||
title="Redigera tidpost"
|
||||
description="Andra uppgifterna for den registrerade tiden."
|
||||
onSave={handleSaveEdit}
|
||||
isSaving={isSavingEdit}
|
||||
>
|
||||
<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>Projekt</Label>
|
||||
<Select value={editProjectId} onValueChange={setEditProjectId}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{activeProjects.map(p => <SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Timmar</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.25"
|
||||
min="0"
|
||||
value={editHours}
|
||||
onChange={e => setEditHours(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Beskrivning</Label>
|
||||
<Input
|
||||
placeholder="Vad jobbade du med?"
|
||||
value={editDescription}
|
||||
onChange={e => setEditDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch checked={editBillable} onCheckedChange={setEditBillable} id="edit-billable-switch" />
|
||||
<Label htmlFor="edit-billable-switch" className="text-sm">Debiterbar</Label>
|
||||
</div>
|
||||
</EditEntryDialog>
|
||||
|
||||
{/* Confirm Delete Dialog */}
|
||||
<ConfirmDeleteDialog
|
||||
open={deleteEntryId !== null}
|
||||
onOpenChange={(open) => { if (!open) setDeleteEntryId(null) }}
|
||||
title="Ta bort tidpost"
|
||||
description="Ar du saker pa att du vill ta bort denna tidpost? Atgarden kan inte angras."
|
||||
onConfirm={handleDeleteEntry}
|
||||
isDeleting={isDeleting}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -32,7 +32,6 @@ export default function BankFileConfirmStep({
|
||||
isLoading,
|
||||
}: BankFileConfirmStepProps) {
|
||||
const [skipDuplicates, setSkipDuplicates] = useState(true)
|
||||
const [autoCategorize, setAutoCategorize] = useState(true)
|
||||
|
||||
const { transactions, stats, date_from, date_to, format_name } = parseResult
|
||||
const refsCount = transactions.filter((t) => t.reference).length
|
||||
@@ -118,30 +117,14 @@ export default function BankFileConfirmStep({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
<Checkbox
|
||||
id="auto-categorize"
|
||||
checked={autoCategorize}
|
||||
onCheckedChange={(checked) => setAutoCategorize(checked === true)}
|
||||
/>
|
||||
<div>
|
||||
<Label htmlFor="auto-categorize" className="text-sm font-medium cursor-pointer">
|
||||
Auto-bokför kända transaktioner
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Skapar automatiskt bokföringsposter för transaktioner med hög konfidens
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Warning note */}
|
||||
<div className="flex gap-3 p-3 bg-yellow-50 dark:bg-yellow-950/20 border border-yellow-200 dark:border-yellow-800 rounded-lg">
|
||||
<AlertTriangle className="h-4 w-4 text-yellow-600 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Importerade transaktioner som inte automatiskt bokförs visas som
|
||||
"obokförda" på transaktionssidan. Du kan bokföra dem manuellt
|
||||
efteråt.
|
||||
Importerade transaktioner visas som "obokförda" på
|
||||
transaktionssidan. Du kan bokföra dem manuellt efteråt.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -156,7 +139,7 @@ export default function BankFileConfirmStep({
|
||||
<Button
|
||||
onClick={() => onExecute({
|
||||
skip_duplicates: skipDuplicates,
|
||||
auto_categorize: autoCategorize,
|
||||
auto_categorize: false,
|
||||
})}
|
||||
disabled={isLoading}
|
||||
>
|
||||
|
||||
@@ -20,6 +20,7 @@ import DescribeTransactionDialog from './DescribeTransactionDialog'
|
||||
import { formatAccountWithName } from '@/lib/bookkeeping/client-account-names'
|
||||
import type { TransactionCategory, VatTreatment, BASAccount } from '@/types'
|
||||
import type { SuggestedCategory, SuggestedTemplate } from '@/lib/transactions/category-suggestions'
|
||||
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
|
||||
import type { TransactionWithInvoice, CategorizeHandler, MatchInvoiceHandler } from './transaction-types'
|
||||
import { EXPENSE_CATEGORIES, INCOME_CATEGORIES, VAT_TREATMENT_OPTIONS } from './transaction-types'
|
||||
|
||||
@@ -784,44 +785,18 @@ export default function SwipeCategorizationView({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fallback templates when no strong suggestion */}
|
||||
{(() => {
|
||||
const txSuggestions = suggestions?.[currentTransaction.id]
|
||||
const topConfidence = txSuggestions?.[0]?.confidence ?? 0
|
||||
const templates = templateSuggestions?.[currentTransaction.id]
|
||||
if (topConfidence < 0.55 && templates && templates.length > 0) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground text-center">Osaker? Prova dessa mallar:</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{templates.slice(0, 3).map((tmpl) => (
|
||||
<Button
|
||||
key={tmpl.template_id}
|
||||
variant="outline"
|
||||
className="h-auto py-2.5 px-3 text-left justify-start border-dashed"
|
||||
onClick={() => setShowDescribeDialog(true)}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
<span className="text-sm">{tmpl.name_sv}</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})()}
|
||||
|
||||
{/* Describe transaction button */}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => setShowDescribeDialog(true)}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
<MessageSquareText className="mr-2 h-4 w-4" />
|
||||
Beskriv transaktion...
|
||||
</Button>
|
||||
{/* Describe transaction button — only when AI categorization is enabled */}
|
||||
{ENABLED_EXTENSION_IDS.has('ai-categorization') && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => setShowDescribeDialog(true)}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
<MessageSquareText className="mr-2 h-4 w-4" />
|
||||
Beskriv transaktion...
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Categorization button */}
|
||||
<Button
|
||||
|
||||
@@ -55,8 +55,6 @@ export default function TransactionInboxCard({
|
||||
const topSuggestion = suggestions?.[0]
|
||||
const isUncategorized = transaction.is_business === null && !transaction.journal_entry_id
|
||||
const showCheckbox = isBatchMode && isUncategorized
|
||||
const hasWeakSuggestions = !topSuggestion || topSuggestion.confidence < 0.55
|
||||
const showTemplateFallback = hasWeakSuggestions && templateSuggestions && templateSuggestions.length > 0
|
||||
const hasDocumentMatch = !!transaction.matched_inbox_item
|
||||
|
||||
function handleSuggestionClick(suggestion: SuggestedCategory) {
|
||||
@@ -219,25 +217,6 @@ export default function TransactionInboxCard({
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Fallback templates when no strong suggestion */}
|
||||
{showTemplateFallback && !hasInvoiceMatch && (
|
||||
<>
|
||||
<span className="text-[10px] text-muted-foreground">Osaker? Prova:</span>
|
||||
{templateSuggestions!.slice(0, 3).map((tmpl) => (
|
||||
<Button
|
||||
key={tmpl.template_id}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 text-xs border-dashed"
|
||||
onClick={() => onOpenDescribe?.(transaction)}
|
||||
disabled={isProcessing || isDisabled}
|
||||
>
|
||||
{tmpl.name_sv}
|
||||
</Button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Describe transaction */}
|
||||
{onOpenDescribe && (
|
||||
<Button
|
||||
|
||||
Reference in New Issue
Block a user