Merge pull request #1 from erp-mafia/code-quality-improvements
Code quality improvements
This commit is contained in:
@@ -16,7 +16,12 @@
|
||||
"Bash(python3:*)",
|
||||
"Bash(git -C /Users/jakobwennberg/erp-base status --porcelain)",
|
||||
"Bash(__NEW_LINE_421a23f44da9923d__ echo '=== Checking which index exports are used ===' grep -r @/components/calendar /Users/jakobwennberg/erp-base/app --include=*.tsx --include=*.ts 2)",
|
||||
"Bash(/dev/null __NEW_LINE_421a23f44da9923d__ echo -e '\\\\n=== Checking which calendar deadline components are directly imported ===' grep -rE \"DeadlineList|DeadlineCard|DeadlineForm|DeadlineFilters|PaymentSummaryCard|TaxTodoWidget|UpcomingDeadlinesWidget\" /Users/jakobwennberg/erp-base --include=*.tsx --include=*.ts)"
|
||||
"Bash(/dev/null __NEW_LINE_421a23f44da9923d__ echo -e '\\\\n=== Checking which calendar deadline components are directly imported ===' grep -rE \"DeadlineList|DeadlineCard|DeadlineForm|DeadlineFilters|PaymentSummaryCard|TaxTodoWidget|UpcomingDeadlinesWidget\" /Users/jakobwennberg/erp-base --include=*.tsx --include=*.ts)",
|
||||
"Bash(npm run build:*)",
|
||||
"Bash(npx supabase:*)",
|
||||
"Bash(curl:*)",
|
||||
"Bash(npx tsc:*)",
|
||||
"Bash(findstr:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
nul
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
|
||||
@@ -298,7 +298,7 @@ All defined in `lib/events/types.ts`:
|
||||
|
||||
### Scope
|
||||
|
||||
Only test business logic in `lib/`. No component tests, no API route tests, no E2E tests.
|
||||
Test business logic in `lib/` and API routes in `app/api/`. No component tests, no E2E tests.
|
||||
|
||||
### Framework
|
||||
|
||||
@@ -334,6 +334,14 @@ mockResult({ data: makeTransaction(), error: null })
|
||||
- Test error paths (missing fiscal period, unbalanced entries)
|
||||
- Verify events are emitted correctly
|
||||
|
||||
### API Route Tests
|
||||
|
||||
- Colocated `__tests__/` directories alongside route files (e.g., `app/api/invoices/__tests__/route.test.ts`)
|
||||
- Mock `@/lib/supabase/server`, `@/lib/init`, and lib functions — do NOT re-test lib business logic
|
||||
- Use `createMockRequest()`, `parseJsonResponse()`, `createMockRouteParams()` from `tests/helpers.ts`
|
||||
- Use `createQueuedMockSupabase()` for routes with multiple sequential Supabase calls
|
||||
- Test: auth (401), validation (400), not found (404), errors (500), happy path, non-blocking journal entry failures
|
||||
|
||||
### Reference Tests
|
||||
|
||||
- `lib/bookkeeping/__tests__/engine.test.ts` — Balance validation
|
||||
@@ -498,3 +506,6 @@ NEXT_PUBLIC_APP_URL # App base URL
|
||||
NEXT_PUBLIC_VAPID_PUBLIC_KEY # Web push public key
|
||||
VAPID_PRIVATE_KEY # Web push private key
|
||||
```
|
||||
|
||||
## Other
|
||||
We should never create a nul file
|
||||
|
||||
@@ -1,35 +1,74 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createServerClient } from '@supabase/ssr'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
export async function GET(request: Request) {
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams, origin } = new URL(request.url)
|
||||
const code = searchParams.get('code')
|
||||
const token_hash = searchParams.get('token_hash')
|
||||
const type = searchParams.get('type')
|
||||
const next = searchParams.get('next') ?? '/'
|
||||
|
||||
if (code) {
|
||||
const supabase = await createClient()
|
||||
const { error } = await supabase.auth.exchangeCodeForSession(code)
|
||||
// Collect cookies that Supabase sets during auth so we can
|
||||
// explicitly forward them on the redirect response.
|
||||
const pendingCookies: { name: string; value: string; options: Record<string, unknown> }[] = []
|
||||
|
||||
if (!error) {
|
||||
// Check if user has completed onboarding
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (user) {
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('onboarding_complete')
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (!settings?.onboarding_complete) {
|
||||
return NextResponse.redirect(`${origin}/onboarding`)
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.redirect(`${origin}${next}`)
|
||||
const supabase = createServerClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||
{
|
||||
cookies: {
|
||||
getAll() {
|
||||
return request.cookies.getAll()
|
||||
},
|
||||
setAll(cookiesToSet) {
|
||||
pendingCookies.length = 0
|
||||
cookiesToSet.forEach((cookie) => pendingCookies.push(cookie))
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
let authenticated = false
|
||||
|
||||
// Handle PKCE flow (code exchange)
|
||||
if (code) {
|
||||
const { error } = await supabase.auth.exchangeCodeForSession(code)
|
||||
authenticated = !error
|
||||
}
|
||||
// Handle token hash flow (email verification / magic link)
|
||||
else if (token_hash && type) {
|
||||
const { error } = await supabase.auth.verifyOtp({
|
||||
token_hash,
|
||||
type: type as 'signup' | 'invite' | 'magiclink' | 'recovery' | 'email_change' | 'email',
|
||||
})
|
||||
authenticated = !error
|
||||
}
|
||||
|
||||
// Return the user to an error page with instructions
|
||||
return NextResponse.redirect(`${origin}/login?error=auth_error`)
|
||||
if (authenticated) {
|
||||
let redirectPath = next
|
||||
|
||||
// Check if user has completed onboarding
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (user) {
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('onboarding_complete')
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (!settings?.onboarding_complete) {
|
||||
redirectPath = '/onboarding'
|
||||
}
|
||||
}
|
||||
|
||||
// Create redirect and explicitly set auth cookies on the response
|
||||
const response = NextResponse.redirect(new URL(redirectPath, origin))
|
||||
for (const { name, value, options } of pendingCookies) {
|
||||
response.cookies.set({ name, value, ...options })
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
// Authentication failed — redirect to login with error
|
||||
return NextResponse.redirect(new URL('/login?error=auth_error', origin))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { redirect, notFound } from 'next/navigation'
|
||||
import { getExtensionDefinition } from '@/lib/extensions/sectors'
|
||||
import ExtensionWorkspaceLoader from '@/components/extensions/ExtensionWorkspaceLoader'
|
||||
|
||||
export default async function ExtensionWorkspacePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ sector: string; slug: string }>
|
||||
}) {
|
||||
const { sector, slug } = await params
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) redirect('/login')
|
||||
|
||||
const definition = getExtensionDefinition(sector, slug)
|
||||
if (!definition) notFound()
|
||||
|
||||
// Check toggle
|
||||
const { data: toggle } = await supabase
|
||||
.from('extension_toggles')
|
||||
.select('enabled')
|
||||
.eq('user_id', user.id)
|
||||
.eq('sector_slug', sector)
|
||||
.eq('extension_slug', slug)
|
||||
.single()
|
||||
|
||||
if (!toggle?.enabled) redirect('/extensions')
|
||||
|
||||
return (
|
||||
<ExtensionWorkspaceLoader
|
||||
sector={sector}
|
||||
slug={slug}
|
||||
definition={definition}
|
||||
userId={user.id}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { notFound } from 'next/navigation'
|
||||
import { getExtensionDefinition, getSector } from '@/lib/extensions/sectors'
|
||||
import { resolveIcon } from '@/lib/extensions/icon-resolver'
|
||||
import CategoryBadge from '@/components/extensions/CategoryBadge'
|
||||
import ExtensionToggleButton from '@/components/extensions/ExtensionToggleButton'
|
||||
import Link from 'next/link'
|
||||
|
||||
export default async function ExtensionDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ sector: string; extension: string }>
|
||||
}) {
|
||||
const { sector: sectorSlug, extension: extensionSlug } = await params
|
||||
|
||||
const definition = getExtensionDefinition(sectorSlug, extensionSlug)
|
||||
if (!definition) notFound()
|
||||
|
||||
const sector = getSector(sectorSlug as any)
|
||||
const Icon = resolveIcon(definition.icon)
|
||||
|
||||
const dataPatternLabels: Record<string, string> = {
|
||||
core: 'Använder bokföringsdata',
|
||||
manual: 'Manuell inmatning',
|
||||
both: 'Bokföringsdata + manuell inmatning',
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Breadcrumb */}
|
||||
<nav className="flex items-center gap-1.5 text-sm text-muted-foreground mb-6">
|
||||
<Link href="/extensions" className="hover:text-foreground transition-colors">
|
||||
Tillägg
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<Link
|
||||
href={`/extensions/${sectorSlug}`}
|
||||
className="hover:text-foreground transition-colors"
|
||||
>
|
||||
{sector?.name ?? sectorSlug}
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<span className="text-foreground">{definition.name}</span>
|
||||
</nav>
|
||||
|
||||
{/* Header with toggle */}
|
||||
<div className="flex items-start justify-between gap-4 mb-8">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-xl bg-primary/10 flex-shrink-0">
|
||||
<Icon className="h-7 w-7 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold tracking-tight">{definition.name}</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">{definition.description}</p>
|
||||
<div className="mt-2">
|
||||
<CategoryBadge category={definition.category} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ExtensionToggleButton sectorSlug={sectorSlug} extensionSlug={extensionSlug} />
|
||||
</div>
|
||||
|
||||
{/* Details */}
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold mb-2">Beskrivning</h2>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
{definition.longDescription}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold mb-2">Datakälla</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{dataPatternLabels[definition.dataPattern]}
|
||||
</p>
|
||||
{definition.readsCoreTables && definition.readsCoreTables.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Läser från: {definition.readsCoreTables.join(', ')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { notFound } from 'next/navigation'
|
||||
import { getSector } from '@/lib/extensions/sectors'
|
||||
import { resolveIcon } from '@/lib/extensions/icon-resolver'
|
||||
import ExtensionCard from '@/components/extensions/ExtensionCard'
|
||||
import Link from 'next/link'
|
||||
|
||||
export default async function SectorExtensionsPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ sector: string }>
|
||||
}) {
|
||||
const { sector: sectorSlug } = await params
|
||||
const sector = getSector(sectorSlug as any)
|
||||
|
||||
if (!sector) notFound()
|
||||
|
||||
const Icon = resolveIcon(sector.icon)
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Breadcrumb */}
|
||||
<nav className="flex items-center gap-1.5 text-sm text-muted-foreground mb-6">
|
||||
<Link href="/extensions" className="hover:text-foreground transition-colors">
|
||||
Tillägg
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<span className="text-foreground">{sector.name}</span>
|
||||
</nav>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-start gap-4 mb-8">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary/10 flex-shrink-0">
|
||||
<Icon className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold tracking-tight">{sector.name}</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">{sector.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Extensions grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{sector.extensions.map(ext => (
|
||||
<ExtensionCard key={ext.slug} extension={ext} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { SECTORS } from '@/lib/extensions/sectors'
|
||||
import ExtensionCard from '@/components/extensions/ExtensionCard'
|
||||
import SectorCard from '@/components/extensions/SectorCard'
|
||||
|
||||
export default function ExtensionsPage() {
|
||||
const generalSector = SECTORS.find(s => s.slug === 'general')
|
||||
const industrySectors = SECTORS.filter(s => s.slug !== 'general')
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-8">
|
||||
<h1 className="text-xl font-semibold tracking-tight">Tillägg</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Utöka ditt bokföringssystem med verktyg och branschspecifika funktioner.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* General extensions */}
|
||||
{generalSector && (
|
||||
<section className="mb-10">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider mb-4">
|
||||
{generalSector.name}
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{generalSector.extensions.map(ext => (
|
||||
<ExtensionCard key={ext.slug} extension={ext} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Industry sectors */}
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wider mb-4">
|
||||
Branschverktyg
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-3">
|
||||
{industrySectors.map(sector => (
|
||||
<SectorCard key={sector.slug} sector={sector} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import { Label } from '@/components/ui/label'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { formatCurrency, formatDate } from '@/lib/utils'
|
||||
import { getVatTreatmentLabel } from '@/lib/invoice/vat-rules'
|
||||
import { getVatTreatmentLabel } from '@/lib/invoices/vat-rules'
|
||||
import { Loader2, ArrowLeft, AlertTriangle } from 'lucide-react'
|
||||
import type { Invoice, InvoiceItem, Customer } from '@/types'
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Badge } from '@/components/ui/badge'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { formatCurrency, formatDate } from '@/lib/utils'
|
||||
import { getVatTreatmentLabel } from '@/lib/invoice/vat-rules'
|
||||
import { getVatTreatmentLabel } from '@/lib/invoices/vat-rules'
|
||||
import {
|
||||
Loader2,
|
||||
ArrowLeft,
|
||||
|
||||
@@ -16,7 +16,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
import { getVatRules, getVatTreatmentLabel, getAvailableVatRates } from '@/lib/invoice/vat-rules'
|
||||
import { getVatRules, getVatTreatmentLabel, getAvailableVatRates } from '@/lib/invoices/vat-rules'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'
|
||||
import { Loader2, Plus, Trash2, ArrowLeft, Send, Eye } from 'lucide-react'
|
||||
import { ConfirmationDialog } from '@/components/ui/confirmation-dialog'
|
||||
|
||||
@@ -29,6 +29,12 @@ export default async function DashboardLayout({
|
||||
|
||||
const entityType = (settings.entity_type as EntityType) || 'enskild_firma'
|
||||
|
||||
const { data: enabledToggles } = await supabase
|
||||
.from('extension_toggles')
|
||||
.select('sector_slug, extension_slug')
|
||||
.eq('user_id', user.id)
|
||||
.eq('enabled', true)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* Skip to content link for keyboard/screen reader users */}
|
||||
@@ -41,6 +47,7 @@ export default async function DashboardLayout({
|
||||
<DashboardNav
|
||||
companyName={settings.company_name || 'Min verksamhet'}
|
||||
entityType={entityType}
|
||||
enabledExtensions={enabledToggles || []}
|
||||
/>
|
||||
<main id="main-content" className="pb-20 md:pb-0 md:pl-[232px]" role="main">
|
||||
<div className="max-w-5xl mx-auto px-5 py-8 md:px-8 md:py-10">
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { default } from '@/extensions/receipt-ocr/pages/ReceiptsPage'
|
||||
export { default } from '@/extensions/general/receipt-ocr/pages/ReceiptsPage'
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { default } from '@/extensions/receipt-ocr/pages/scan/ScanReceiptPage'
|
||||
export { default } from '@/extensions/general/receipt-ocr/pages/scan/ScanReceiptPage'
|
||||
|
||||
@@ -23,9 +23,14 @@ import {
|
||||
LogOut,
|
||||
Bell,
|
||||
Calendar,
|
||||
Sun,
|
||||
Moon,
|
||||
Monitor,
|
||||
Palette,
|
||||
} from 'lucide-react'
|
||||
import { useTheme } from 'next-themes'
|
||||
import type { CompanySettings, BankConnection } from '@/types'
|
||||
import { NotificationSettings } from '@/extensions/push-notifications/NotificationSettings'
|
||||
import { NotificationSettings } from '@/extensions/general/push-notifications/NotificationSettings'
|
||||
import { CalendarFeedSettings } from '@/components/settings/CalendarFeedSettings'
|
||||
|
||||
export default function SettingsPage() {
|
||||
@@ -41,6 +46,12 @@ export default function SettingsPage() {
|
||||
const [isSyncing, setIsSyncing] = useState(false)
|
||||
const [isConnecting, setIsConnecting] = useState(false)
|
||||
const [hasBankingExtension, setHasBankingExtension] = useState(false)
|
||||
const { theme, setTheme } = useTheme()
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
@@ -311,6 +322,10 @@ export default function SettingsPage() {
|
||||
<Calendar className="mr-2 h-4 w-4" />
|
||||
Kalender
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="appearance">
|
||||
<Palette className="mr-2 h-4 w-4" />
|
||||
Utseende
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="account">
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
Konto
|
||||
@@ -625,6 +640,103 @@ export default function SettingsPage() {
|
||||
<CalendarFeedSettings />
|
||||
</TabsContent>
|
||||
|
||||
{/* Appearance settings */}
|
||||
<TabsContent value="appearance">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Utseende</CardTitle>
|
||||
<CardDescription>
|
||||
Välj hur applikationen ska se ut
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{mounted && (
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{/* Light */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTheme('light')}
|
||||
className={`group relative rounded-lg border-2 p-4 text-left transition-colors ${
|
||||
theme === 'light'
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border hover:border-primary/40'
|
||||
}`}
|
||||
>
|
||||
<div className="mb-3 flex h-20 items-end gap-1.5 rounded-md border bg-white p-2">
|
||||
<div className="h-full w-3 rounded-sm bg-[hsl(222,47%,35%)]" />
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
<div className="h-2 w-3/4 rounded-sm bg-[hsl(220,14%,96%)]" />
|
||||
<div className="h-2 w-1/2 rounded-sm bg-[hsl(220,14%,96%)]" />
|
||||
<div className="h-2 w-2/3 rounded-sm bg-[hsl(220,14%,96%)]" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Sun className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Ljust</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Dark */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTheme('dark')}
|
||||
className={`group relative rounded-lg border-2 p-4 text-left transition-colors ${
|
||||
theme === 'dark'
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border hover:border-primary/40'
|
||||
}`}
|
||||
>
|
||||
<div className="mb-3 flex h-20 items-end gap-1.5 rounded-md border bg-[hsl(222,16%,10%)] p-2">
|
||||
<div className="h-full w-3 rounded-sm bg-[hsl(222,50%,55%)]" />
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
<div className="h-2 w-3/4 rounded-sm bg-[hsl(220,12%,20%)]" />
|
||||
<div className="h-2 w-1/2 rounded-sm bg-[hsl(220,12%,20%)]" />
|
||||
<div className="h-2 w-2/3 rounded-sm bg-[hsl(220,12%,20%)]" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Moon className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Mörkt</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* System */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTheme('system')}
|
||||
className={`group relative rounded-lg border-2 p-4 text-left transition-colors ${
|
||||
theme === 'system'
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border hover:border-primary/40'
|
||||
}`}
|
||||
>
|
||||
<div className="mb-3 flex h-20 overflow-hidden rounded-md border">
|
||||
<div className="flex flex-1 items-end gap-1 bg-white p-2">
|
||||
<div className="h-full w-2 rounded-sm bg-[hsl(222,47%,35%)]" />
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
<div className="h-2 w-3/4 rounded-sm bg-[hsl(220,14%,96%)]" />
|
||||
<div className="h-2 w-1/2 rounded-sm bg-[hsl(220,14%,96%)]" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-1 items-end gap-1 bg-[hsl(222,16%,10%)] p-2">
|
||||
<div className="h-full w-2 rounded-sm bg-[hsl(222,50%,55%)]" />
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
<div className="h-2 w-3/4 rounded-sm bg-[hsl(220,12%,20%)]" />
|
||||
<div className="h-2 w-1/2 rounded-sm bg-[hsl(220,12%,20%)]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Monitor className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">System</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Account settings */}
|
||||
<TabsContent value="account">
|
||||
<Card>
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useMemo, Suspense } from 'react'
|
||||
import { useState, useEffect, Suspense } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import type { CompanySettings, EntityType, MomsPeriod } from '@/types'
|
||||
@@ -13,13 +12,18 @@ import Step1EntityType from '@/components/onboarding/Step1EntityType'
|
||||
import Step2CompanyDetails from '@/components/onboarding/Step2CompanyDetails'
|
||||
import Step3TaxRegistration from '@/components/onboarding/Step3TaxRegistration'
|
||||
import Step4PreliminaryTax from '@/components/onboarding/Step4PreliminaryTax'
|
||||
import Step6ConnectBank from '@/components/onboarding/Step6ConnectBank'
|
||||
import Step5ConnectBank from '@/components/onboarding/Step6ConnectBank'
|
||||
import Step6SectorSelection from '@/components/onboarding/Step2SectorSelection'
|
||||
import Step7ExtensionSuggestions from '@/components/onboarding/Step3ExtensionSuggestions'
|
||||
|
||||
const STEP_TITLES = [
|
||||
'Verksamhetsform',
|
||||
'Företagsuppgifter',
|
||||
'Skatteregistrering',
|
||||
'F-skatt',
|
||||
'Anslut bank',
|
||||
'Bransch',
|
||||
'Tillägg',
|
||||
]
|
||||
|
||||
export default function OnboardingPage() {
|
||||
@@ -40,8 +44,9 @@ function OnboardingPageContent() {
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [currentStep, setCurrentStep] = useState(1)
|
||||
const [settings, setSettings] = useState<Partial<CompanySettings>>({})
|
||||
const [sectorSlug, setSectorSlug] = useState<string | null>(null)
|
||||
|
||||
const totalSteps = 5
|
||||
const totalSteps = 7
|
||||
const stepTitles = STEP_TITLES
|
||||
|
||||
// Load existing settings on mount
|
||||
@@ -63,6 +68,9 @@ function OnboardingPageContent() {
|
||||
if (data) {
|
||||
setSettings(data)
|
||||
setCurrentStep(data.onboarding_step || 1)
|
||||
if ((data as Record<string, unknown>).sector_slug) {
|
||||
setSectorSlug((data as Record<string, unknown>).sector_slug as string)
|
||||
}
|
||||
}
|
||||
|
||||
setIsLoading(false)
|
||||
@@ -74,13 +82,13 @@ function OnboardingPageContent() {
|
||||
// Handle bank_connected callback from PSD2 flow
|
||||
useEffect(() => {
|
||||
if (searchParams.get('bank_connected') === 'true') {
|
||||
saveSettings({ onboarding_complete: true }).then((success) => {
|
||||
toast({
|
||||
title: 'Bank ansluten!',
|
||||
description: 'Din bank har kopplats.',
|
||||
})
|
||||
saveSettings({}, 6).then((success) => {
|
||||
if (success) {
|
||||
toast({
|
||||
title: 'Bank ansluten!',
|
||||
description: 'Din bank är kopplad och profilen är redo.',
|
||||
})
|
||||
router.push('/')
|
||||
setCurrentStep(6)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -249,18 +257,6 @@ function OnboardingPageContent() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleComplete = async () => {
|
||||
const success = await saveSettings({ onboarding_complete: true })
|
||||
|
||||
if (success) {
|
||||
toast({
|
||||
title: 'Välkommen!',
|
||||
description: 'Din profil är nu redo.',
|
||||
})
|
||||
router.push('/')
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
@@ -271,6 +267,41 @@ function OnboardingPageContent() {
|
||||
|
||||
const progressPercent = ((currentStep - 1) / (totalSteps - 1)) * 100
|
||||
|
||||
const handleSectorNext = async (data: { sector_slug: string | null }) => {
|
||||
setSectorSlug(data.sector_slug)
|
||||
const nextStep = currentStep + 1
|
||||
const settingsUpdate: Partial<CompanySettings> = {}
|
||||
if (data.sector_slug) {
|
||||
;(settingsUpdate as Record<string, unknown>).sector_slug = data.sector_slug
|
||||
}
|
||||
const success = await saveSettings(settingsUpdate, nextStep)
|
||||
if (success) {
|
||||
setCurrentStep(nextStep)
|
||||
}
|
||||
}
|
||||
|
||||
const handleExtensionsNext = async (data: { enabled_extensions: { sector_slug: string; extension_slug: string }[] }) => {
|
||||
// Insert extension toggles if any were selected
|
||||
if (data.enabled_extensions.length > 0) {
|
||||
try {
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (user) {
|
||||
const rows = data.enabled_extensions.map(ext => ({
|
||||
user_id: user.id,
|
||||
sector_slug: ext.sector_slug,
|
||||
extension_slug: ext.extension_slug,
|
||||
enabled: true,
|
||||
}))
|
||||
await supabase.from('extension_toggles').upsert(rows, {
|
||||
onConflict: 'user_id,sector_slug,extension_slug',
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to save extension toggles:', err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const renderSteps = () => (
|
||||
<>
|
||||
{currentStep === 1 && (
|
||||
@@ -327,7 +358,7 @@ function OnboardingPageContent() {
|
||||
)}
|
||||
|
||||
{currentStep === 5 && (
|
||||
<Step6ConnectBank
|
||||
<Step5ConnectBank
|
||||
initialData={{
|
||||
bank_name: settings.bank_name ?? undefined,
|
||||
clearing_number: settings.clearing_number ?? undefined,
|
||||
@@ -337,10 +368,30 @@ function OnboardingPageContent() {
|
||||
}}
|
||||
onComplete={async (data) => {
|
||||
if (data) {
|
||||
await saveSettings({ ...data, onboarding_complete: true })
|
||||
} else {
|
||||
await saveSettings({ onboarding_complete: true })
|
||||
await saveSettings(data, 6)
|
||||
}
|
||||
setCurrentStep(6)
|
||||
}}
|
||||
onBack={handleBack}
|
||||
onSkip={() => setCurrentStep(6)}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentStep === 6 && (
|
||||
<Step6SectorSelection
|
||||
onNext={handleSectorNext}
|
||||
onBack={handleBack}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentStep === 7 && (
|
||||
<Step7ExtensionSuggestions
|
||||
sectorSlug={sectorSlug}
|
||||
onNext={async (data) => {
|
||||
await handleExtensionsNext(data)
|
||||
await saveSettings({ onboarding_complete: true }, totalSteps)
|
||||
toast({
|
||||
title: 'Välkommen!',
|
||||
description: 'Din profil är nu redo.',
|
||||
@@ -348,7 +399,6 @@ function OnboardingPageContent() {
|
||||
router.push('/')
|
||||
}}
|
||||
onBack={handleBack}
|
||||
onSkip={handleComplete}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const from = searchParams.get('from')
|
||||
const to = searchParams.get('to')
|
||||
const dateFrom = searchParams.get('date_from')
|
||||
const dateTo = searchParams.get('date_to')
|
||||
const groupBy = searchParams.get('group_by')
|
||||
|
||||
if (!from || !to) {
|
||||
return NextResponse.json(
|
||||
{ error: 'from and to account numbers are required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Get posted journal entries within date range
|
||||
let entriesQuery = supabase
|
||||
.from('journal_entries')
|
||||
.select('id, entry_date')
|
||||
.eq('user_id', user.id)
|
||||
.eq('status', 'posted')
|
||||
|
||||
if (dateFrom) {
|
||||
entriesQuery = entriesQuery.gte('entry_date', dateFrom)
|
||||
}
|
||||
if (dateTo) {
|
||||
entriesQuery = entriesQuery.lte('entry_date', dateTo)
|
||||
}
|
||||
|
||||
const { data: entries, error: entriesError } = await entriesQuery
|
||||
|
||||
if (entriesError) {
|
||||
return NextResponse.json({ error: entriesError.message }, { status: 500 })
|
||||
}
|
||||
|
||||
if (!entries || entries.length === 0) {
|
||||
return NextResponse.json({ totals: [], monthly: groupBy === 'month' ? [] : undefined })
|
||||
}
|
||||
|
||||
const entryIds = entries.map((e) => e.id)
|
||||
const entryDateMap = new Map(entries.map((e) => [e.id, e.entry_date]))
|
||||
|
||||
// Fetch lines in batches to avoid URL length limits
|
||||
const batchSize = 200
|
||||
const allLines: Array<{
|
||||
journal_entry_id: string
|
||||
account_number: string
|
||||
debit_amount: number
|
||||
credit_amount: number
|
||||
}> = []
|
||||
|
||||
for (let i = 0; i < entryIds.length; i += batchSize) {
|
||||
const batch = entryIds.slice(i, i + batchSize)
|
||||
const { data: lines, error: linesError } = await supabase
|
||||
.from('journal_entry_lines')
|
||||
.select('journal_entry_id, account_number, debit_amount, credit_amount')
|
||||
.in('journal_entry_id', batch)
|
||||
.gte('account_number', from)
|
||||
.lte('account_number', to)
|
||||
|
||||
if (linesError) {
|
||||
return NextResponse.json({ error: linesError.message }, { status: 500 })
|
||||
}
|
||||
if (lines) {
|
||||
allLines.push(...lines)
|
||||
}
|
||||
}
|
||||
|
||||
// Aggregate by account
|
||||
const accountTotals = new Map<string, { debit: number; credit: number }>()
|
||||
|
||||
for (const line of allLines) {
|
||||
const existing = accountTotals.get(line.account_number) || { debit: 0, credit: 0 }
|
||||
existing.debit += Number(line.debit_amount) || 0
|
||||
existing.credit += Number(line.credit_amount) || 0
|
||||
accountTotals.set(line.account_number, existing)
|
||||
}
|
||||
|
||||
const totals = Array.from(accountTotals.entries())
|
||||
.map(([account_number, bal]) => ({
|
||||
account_number,
|
||||
debit: Math.round(bal.debit * 100) / 100,
|
||||
credit: Math.round(bal.credit * 100) / 100,
|
||||
net: Math.round((bal.debit - bal.credit) * 100) / 100,
|
||||
}))
|
||||
.sort((a, b) => a.account_number.localeCompare(b.account_number))
|
||||
|
||||
// Monthly grouping
|
||||
if (groupBy === 'month') {
|
||||
const monthlyMap = new Map<string, Map<string, { debit: number; credit: number }>>()
|
||||
|
||||
for (const line of allLines) {
|
||||
const entryDate = entryDateMap.get(line.journal_entry_id)
|
||||
if (!entryDate) continue
|
||||
const month = entryDate.slice(0, 7)
|
||||
if (!monthlyMap.has(month)) {
|
||||
monthlyMap.set(month, new Map())
|
||||
}
|
||||
const monthAccounts = monthlyMap.get(month)!
|
||||
const existing = monthAccounts.get(line.account_number) || { debit: 0, credit: 0 }
|
||||
existing.debit += Number(line.debit_amount) || 0
|
||||
existing.credit += Number(line.credit_amount) || 0
|
||||
monthAccounts.set(line.account_number, existing)
|
||||
}
|
||||
|
||||
const monthlyFlat = Array.from(monthlyMap.entries())
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.flatMap(([month, accounts]) =>
|
||||
Array.from(accounts.entries()).map(([account_number, bal]) => ({
|
||||
month,
|
||||
account_number,
|
||||
debit: Math.round(bal.debit * 100) / 100,
|
||||
credit: Math.round(bal.credit * 100) / 100,
|
||||
net: Math.round((bal.debit - bal.credit) * 100) / 100,
|
||||
}))
|
||||
)
|
||||
|
||||
return NextResponse.json({ totals, monthly: monthlyFlat })
|
||||
}
|
||||
|
||||
return NextResponse.json({ totals })
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import {
|
||||
createMockRequest,
|
||||
parseJsonResponse,
|
||||
createMockRouteParams,
|
||||
makeJournalEntry,
|
||||
} from '@/tests/helpers'
|
||||
|
||||
// Mock dependencies before imports
|
||||
const mockCreateClient = vi.fn()
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: () => mockCreateClient(),
|
||||
}))
|
||||
|
||||
const mockReverseEntry = vi.fn()
|
||||
vi.mock('@/lib/bookkeeping/engine', () => ({
|
||||
reverseEntry: (...args: unknown[]) => mockReverseEntry(...args),
|
||||
}))
|
||||
|
||||
import { POST } from '../route'
|
||||
|
||||
describe('POST /api/bookkeeping/journal-entries/[id]/reverse', () => {
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockCreateClient.mockResolvedValue({
|
||||
auth: { getUser: vi.fn().mockResolvedValue({ data: { user: mockUser } }) },
|
||||
})
|
||||
})
|
||||
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
mockCreateClient.mockResolvedValue({
|
||||
auth: { getUser: vi.fn().mockResolvedValue({ data: { user: null } }) },
|
||||
})
|
||||
|
||||
const request = createMockRequest('/api/bookkeeping/journal-entries/entry-1/reverse', {
|
||||
method: 'POST',
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'entry-1' }))
|
||||
const { status, body } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(401)
|
||||
expect(body).toEqual({ error: 'Unauthorized' })
|
||||
})
|
||||
|
||||
it('returns reversed entry on success', async () => {
|
||||
const reversalEntry = makeJournalEntry({
|
||||
id: 'reversal-1',
|
||||
reverses_id: 'entry-1',
|
||||
source_type: 'storno',
|
||||
})
|
||||
mockReverseEntry.mockResolvedValue(reversalEntry)
|
||||
|
||||
const request = createMockRequest('/api/bookkeeping/journal-entries/entry-1/reverse', {
|
||||
method: 'POST',
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'entry-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ data: unknown }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data).toEqual(reversalEntry)
|
||||
expect(mockReverseEntry).toHaveBeenCalledWith('user-1', 'entry-1')
|
||||
})
|
||||
|
||||
it('returns 400 when engine throws', async () => {
|
||||
mockReverseEntry.mockRejectedValue(new Error('Entry already reversed'))
|
||||
|
||||
const request = createMockRequest('/api/bookkeeping/journal-entries/entry-1/reverse', {
|
||||
method: 'POST',
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'entry-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error).toBe('Entry already reversed')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,156 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import {
|
||||
createMockRequest,
|
||||
parseJsonResponse,
|
||||
createQueuedMockSupabase,
|
||||
makeJournalEntry,
|
||||
} from '@/tests/helpers'
|
||||
|
||||
// Mock dependencies
|
||||
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: () => Promise.resolve(mockSupabase),
|
||||
}))
|
||||
|
||||
const mockCreateJournalEntry = vi.fn()
|
||||
vi.mock('@/lib/bookkeeping/engine', () => ({
|
||||
createJournalEntry: (...args: unknown[]) => mockCreateJournalEntry(...args),
|
||||
}))
|
||||
|
||||
import { GET, POST } from '../route'
|
||||
|
||||
describe('GET /api/bookkeeping/journal-entries', () => {
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
|
||||
})
|
||||
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
|
||||
|
||||
const request = createMockRequest('/api/bookkeeping/journal-entries')
|
||||
const response = await GET(request)
|
||||
const { status, body } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(401)
|
||||
expect(body).toEqual({ error: 'Unauthorized' })
|
||||
})
|
||||
|
||||
it('returns entries list', async () => {
|
||||
const entries = [makeJournalEntry(), makeJournalEntry()]
|
||||
enqueue({ data: entries, error: null, count: 2 })
|
||||
|
||||
const request = createMockRequest('/api/bookkeeping/journal-entries')
|
||||
const response = await GET(request)
|
||||
const { status, body } = await parseJsonResponse<{ data: unknown[]; count: number }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data).toEqual(entries)
|
||||
expect(body.count).toBe(2)
|
||||
})
|
||||
|
||||
it('passes filters to query', async () => {
|
||||
enqueue({ data: [], error: null, count: 0 })
|
||||
|
||||
const request = createMockRequest('/api/bookkeeping/journal-entries', {
|
||||
searchParams: {
|
||||
period_id: 'period-1',
|
||||
status: 'posted',
|
||||
date_from: '2024-01-01',
|
||||
date_to: '2024-12-31',
|
||||
limit: '10',
|
||||
offset: '5',
|
||||
},
|
||||
})
|
||||
const response = await GET(request)
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(mockSupabase.from).toHaveBeenCalledWith('journal_entries')
|
||||
})
|
||||
|
||||
it('returns 500 on database error', async () => {
|
||||
enqueue({ data: null, error: { message: 'DB error' } })
|
||||
|
||||
const request = createMockRequest('/api/bookkeeping/journal-entries')
|
||||
const response = await GET(request)
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(500)
|
||||
expect(body.error).toBe('DB error')
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/bookkeeping/journal-entries', () => {
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
|
||||
})
|
||||
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
|
||||
|
||||
const request = createMockRequest('/api/bookkeeping/journal-entries', {
|
||||
method: 'POST',
|
||||
body: {},
|
||||
})
|
||||
const response = await POST(request)
|
||||
const { status, body } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(401)
|
||||
expect(body).toEqual({ error: 'Unauthorized' })
|
||||
})
|
||||
|
||||
it('creates journal entry and returns it', async () => {
|
||||
const entry = makeJournalEntry()
|
||||
mockCreateJournalEntry.mockResolvedValue(entry)
|
||||
|
||||
const input = {
|
||||
fiscal_period_id: 'period-1',
|
||||
entry_date: '2024-06-15',
|
||||
description: 'Test entry',
|
||||
source_type: 'manual',
|
||||
lines: [
|
||||
{ account_number: '1930', debit_amount: 1000, credit_amount: 0 },
|
||||
{ account_number: '3001', debit_amount: 0, credit_amount: 1000 },
|
||||
],
|
||||
}
|
||||
|
||||
const request = createMockRequest('/api/bookkeeping/journal-entries', {
|
||||
method: 'POST',
|
||||
body: input,
|
||||
})
|
||||
const response = await POST(request)
|
||||
const { status, body } = await parseJsonResponse<{ data: unknown }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data).toEqual(entry)
|
||||
expect(mockCreateJournalEntry).toHaveBeenCalledWith('user-1', input)
|
||||
})
|
||||
|
||||
it('returns 400 when engine throws', async () => {
|
||||
mockCreateJournalEntry.mockRejectedValue(new Error('Unbalanced entry'))
|
||||
|
||||
const request = createMockRequest('/api/bookkeeping/journal-entries', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
fiscal_period_id: 'period-1',
|
||||
entry_date: '2024-06-15',
|
||||
description: 'Bad entry',
|
||||
source_type: 'manual',
|
||||
lines: [{ account_number: '1930', debit_amount: 1000, credit_amount: 0 }],
|
||||
},
|
||||
})
|
||||
const response = await POST(request)
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error).toBe('Unbalanced entry')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,119 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ sector: string; slug: string }> }
|
||||
) {
|
||||
const { sector, slug } = await params
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const extensionId = `${sector}/${slug}`
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const key = searchParams.get('key')
|
||||
|
||||
let query = supabase
|
||||
.from('extension_data')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.eq('extension_id', extensionId)
|
||||
|
||||
const prefix = searchParams.get('prefix')
|
||||
|
||||
if (key) {
|
||||
query = query.eq('key', key)
|
||||
} else if (prefix) {
|
||||
query = query.ilike('key', `${prefix}%`)
|
||||
}
|
||||
|
||||
const { data, error } = await query
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ sector: string; slug: string }> }
|
||||
) {
|
||||
const { sector, slug } = await params
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { key, value } = body
|
||||
|
||||
if (!key) {
|
||||
return NextResponse.json({ error: 'key is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const extensionId = `${sector}/${slug}`
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('extension_data')
|
||||
.upsert(
|
||||
{
|
||||
user_id: user.id,
|
||||
extension_id: extensionId,
|
||||
key,
|
||||
value,
|
||||
},
|
||||
{ onConflict: 'user_id,extension_id,key' }
|
||||
)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ sector: string; slug: string }> }
|
||||
) {
|
||||
const { sector, slug } = await params
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const key = searchParams.get('key')
|
||||
|
||||
if (!key) {
|
||||
return NextResponse.json({ error: 'key query parameter is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const extensionId = `${sector}/${slug}`
|
||||
|
||||
const { error } = await supabase
|
||||
.from('extension_data')
|
||||
.delete()
|
||||
.eq('user_id', user.id)
|
||||
.eq('extension_id', extensionId)
|
||||
.eq('key', key)
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ sector: string; slug: string }> }
|
||||
) {
|
||||
const { sector, slug } = await params
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const extensionId = `${sector}/${slug}`
|
||||
|
||||
const { data } = await supabase
|
||||
.from('extension_data')
|
||||
.select('value')
|
||||
.eq('user_id', user.id)
|
||||
.eq('extension_id', extensionId)
|
||||
.eq('key', 'settings')
|
||||
.single()
|
||||
|
||||
return NextResponse.json({ data: data?.value ?? {} })
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ sector: string; slug: string }> }
|
||||
) {
|
||||
const { sector, slug } = await params
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const extensionId = `${sector}/${slug}`
|
||||
|
||||
// Get existing settings and merge
|
||||
const { data: existing } = await supabase
|
||||
.from('extension_data')
|
||||
.select('value')
|
||||
.eq('user_id', user.id)
|
||||
.eq('extension_id', extensionId)
|
||||
.eq('key', 'settings')
|
||||
.single()
|
||||
|
||||
const mergedSettings = { ...(existing?.value ?? {}), ...body }
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('extension_data')
|
||||
.upsert(
|
||||
{
|
||||
user_id: user.id,
|
||||
extension_id: extensionId,
|
||||
key: 'settings',
|
||||
value: mergedSettings,
|
||||
},
|
||||
{ onConflict: 'user_id,extension_id,key' }
|
||||
)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: data.value })
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getSettings, saveSettings } from '@/extensions/ai-categorization'
|
||||
import { getSettings, saveSettings } from '@/extensions/general/ai-categorization'
|
||||
|
||||
/**
|
||||
* GET /api/extensions/ai-categorization/settings
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { categorizeTransactions } from '@/extensions/ai-categorization'
|
||||
import type { CategorizationSuggestion } from '@/extensions/ai-categorization/categorizer'
|
||||
import { categorizeTransactions } from '@/extensions/general/ai-categorization'
|
||||
import type { CategorizationSuggestion } from '@/extensions/general/ai-categorization/categorizer'
|
||||
|
||||
/**
|
||||
* GET /api/extensions/ai-categorization/suggestions?transaction_ids=id1,id2,...
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { generateChatResponse } from '@/extensions/ai-chat/chatbot/chain'
|
||||
import { CHATBOT_CONFIG } from '@/extensions/ai-chat/chatbot/config'
|
||||
import { generateChatResponse } from '@/extensions/general/ai-chat/chatbot/chain'
|
||||
import { CHATBOT_CONFIG } from '@/extensions/general/ai-chat/chatbot/config'
|
||||
import type { ChatMessage, ChatRequest } from '@/types/chat'
|
||||
|
||||
// Simple in-memory rate limiting (per user)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { streamChatResponse } from '@/extensions/ai-chat/chatbot/chain'
|
||||
import { CHATBOT_CONFIG } from '@/extensions/ai-chat/chatbot/config'
|
||||
import { streamChatResponse } from '@/extensions/general/ai-chat/chatbot/chain'
|
||||
import { CHATBOT_CONFIG } from '@/extensions/general/ai-chat/chatbot/config'
|
||||
import type { ChatMessage, ChatRequest, SourceReference } from '@/types/chat'
|
||||
|
||||
// Simple in-memory rate limiting (per user)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createServiceClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createSession, getAccountBalance, type AccountInfo } from '@/extensions/enable-banking/lib/api-client'
|
||||
import type { StoredAccount } from '@/extensions/enable-banking/types'
|
||||
import { createSession, getAccountBalance, type AccountInfo } from '@/extensions/general/enable-banking/lib/api-client'
|
||||
import type { StoredAccount } from '@/extensions/general/enable-banking/types'
|
||||
|
||||
/**
|
||||
* GET /api/extensions/enable-banking/callback
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { syncAccountTransactions } from '@/extensions/enable-banking/lib/sync'
|
||||
import { isConsentExpiringSoon, getDaysUntilExpiry } from '@/extensions/enable-banking/lib/api-client'
|
||||
import type { StoredAccount } from '@/extensions/enable-banking/types'
|
||||
import { syncAccountTransactions } from '@/extensions/general/enable-banking/lib/sync'
|
||||
import { isConsentExpiringSoon, getDaysUntilExpiry } from '@/extensions/general/enable-banking/lib/api-client'
|
||||
import type { StoredAccount } from '@/extensions/general/enable-banking/types'
|
||||
|
||||
/**
|
||||
* GET /api/extensions/enable-banking/sync/cron
|
||||
|
||||
@@ -4,7 +4,7 @@ import { loadExtensions } from '@/lib/extensions/loader'
|
||||
import {
|
||||
sendTaxDeadlineNotifications,
|
||||
sendInvoiceNotifications,
|
||||
} from '@/extensions/push-notifications/notification-scheduler'
|
||||
} from '@/extensions/general/push-notifications/notification-scheduler'
|
||||
|
||||
/**
|
||||
* GET /api/extensions/push-notifications/cron
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getSettings, saveSettings } from '@/extensions/push-notifications'
|
||||
import { getSettings, saveSettings } from '@/extensions/general/push-notifications'
|
||||
|
||||
/**
|
||||
* GET /api/extensions/push-notifications/settings
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getVapidPublicKey } from '@/extensions/push-notifications/notification-sender'
|
||||
import { getVapidPublicKey } from '@/extensions/general/push-notifications/notification-sender'
|
||||
|
||||
/**
|
||||
* GET /api/extensions/push-notifications/subscribe
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { findTransactionMatches } from '@/extensions/receipt-ocr/lib/receipt-matcher'
|
||||
import { findTransactionMatches } from '@/extensions/general/receipt-ocr/lib/receipt-matcher'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import type { Receipt, Transaction } from '@/types'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getSettings, saveSettings } from '@/extensions/receipt-ocr'
|
||||
import { getSettings, saveSettings } from '@/extensions/general/receipt-ocr'
|
||||
|
||||
/**
|
||||
* GET /api/extensions/receipt-ocr/settings
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { analyzeReceipt } from '@/extensions/receipt-ocr/lib/receipt-analyzer'
|
||||
import { processLineItems } from '@/extensions/receipt-ocr/lib/receipt-categorizer'
|
||||
import { analyzeReceipt } from '@/extensions/general/receipt-ocr/lib/receipt-analyzer'
|
||||
import { processLineItems } from '@/extensions/general/receipt-ocr/lib/receipt-categorizer'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { uploadDocument } from '@/lib/core/documents/document-service'
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ sector: string; slug: string }> }
|
||||
) {
|
||||
const { sector, slug } = await params
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { data } = await supabase
|
||||
.from('extension_toggles')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.eq('sector_slug', sector)
|
||||
.eq('extension_slug', slug)
|
||||
.single()
|
||||
|
||||
return NextResponse.json({ data: data ?? { enabled: false } })
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ sector: string; slug: string }> }
|
||||
) {
|
||||
const { sector, slug } = await params
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from('extension_toggles')
|
||||
.delete()
|
||||
.eq('user_id', user.id)
|
||||
.eq('sector_slug', sector)
|
||||
.eq('extension_slug', slug)
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import {
|
||||
createMockRequest,
|
||||
parseJsonResponse,
|
||||
createQueuedMockSupabase,
|
||||
makeExtensionToggle,
|
||||
} from '@/tests/helpers'
|
||||
|
||||
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: () => Promise.resolve(mockSupabase),
|
||||
}))
|
||||
|
||||
import { GET, POST } from '../route'
|
||||
|
||||
describe('GET /api/extensions/toggles', () => {
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
|
||||
})
|
||||
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
|
||||
|
||||
const response = await GET()
|
||||
const { status, body } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(401)
|
||||
expect(body).toEqual({ error: 'Unauthorized' })
|
||||
})
|
||||
|
||||
it('returns enabled toggles for user', async () => {
|
||||
const toggles = [
|
||||
makeExtensionToggle({ extension_slug: 'receipt-ocr' }),
|
||||
makeExtensionToggle({ extension_slug: 'ai-chat' }),
|
||||
]
|
||||
enqueue({ data: toggles, error: null })
|
||||
|
||||
const response = await GET()
|
||||
const { status, body } = await parseJsonResponse<{ data: unknown[] }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data).toEqual(toggles)
|
||||
expect(mockSupabase.from).toHaveBeenCalledWith('extension_toggles')
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/extensions/toggles', () => {
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
|
||||
})
|
||||
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
|
||||
|
||||
const request = createMockRequest('/api/extensions/toggles', {
|
||||
method: 'POST',
|
||||
body: { sector_slug: 'general', extension_slug: 'receipt-ocr', enabled: true },
|
||||
})
|
||||
const response = await POST(request)
|
||||
const { status, body } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(401)
|
||||
expect(body).toEqual({ error: 'Unauthorized' })
|
||||
})
|
||||
|
||||
it('returns 400 when missing fields', async () => {
|
||||
const request = createMockRequest('/api/extensions/toggles', {
|
||||
method: 'POST',
|
||||
body: { sector_slug: 'general' },
|
||||
})
|
||||
const response = await POST(request)
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error).toBe('sector_slug, extension_slug, and enabled are required')
|
||||
})
|
||||
|
||||
it('upserts toggle and returns data', async () => {
|
||||
const toggle = makeExtensionToggle({
|
||||
sector_slug: 'general',
|
||||
extension_slug: 'receipt-ocr',
|
||||
enabled: true,
|
||||
})
|
||||
enqueue({ data: toggle, error: null })
|
||||
|
||||
const request = createMockRequest('/api/extensions/toggles', {
|
||||
method: 'POST',
|
||||
body: { sector_slug: 'general', extension_slug: 'receipt-ocr', enabled: true },
|
||||
})
|
||||
const response = await POST(request)
|
||||
const { status, body } = await parseJsonResponse<{ data: unknown }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data).toEqual(toggle)
|
||||
expect(mockSupabase.from).toHaveBeenCalledWith('extension_toggles')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
export async function GET() {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('extension_toggles')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.eq('enabled', true)
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { sector_slug, extension_slug, enabled } = body
|
||||
|
||||
if (!sector_slug || !extension_slug || typeof enabled !== 'boolean') {
|
||||
return NextResponse.json(
|
||||
{ error: 'sector_slug, extension_slug, and enabled are required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('extension_toggles')
|
||||
.upsert(
|
||||
{
|
||||
user_id: user.id,
|
||||
sector_slug,
|
||||
extension_slug,
|
||||
enabled,
|
||||
},
|
||||
{ onConflict: 'user_id,sector_slug,extension_slug' }
|
||||
)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import {
|
||||
createMockRequest,
|
||||
parseJsonResponse,
|
||||
createMockRouteParams,
|
||||
createQueuedMockSupabase,
|
||||
makeInvoice,
|
||||
makeCustomer,
|
||||
} from '@/tests/helpers'
|
||||
|
||||
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: () => Promise.resolve(mockSupabase),
|
||||
}))
|
||||
|
||||
const mockCreateInvoicePaymentJournalEntry = vi.fn()
|
||||
const mockCreateInvoiceCashEntry = vi.fn()
|
||||
vi.mock('@/lib/bookkeeping/invoice-entries', () => ({
|
||||
createInvoicePaymentJournalEntry: (...args: unknown[]) =>
|
||||
mockCreateInvoicePaymentJournalEntry(...args),
|
||||
createInvoiceCashEntry: (...args: unknown[]) =>
|
||||
mockCreateInvoiceCashEntry(...args),
|
||||
}))
|
||||
|
||||
import { POST } from '../route'
|
||||
|
||||
describe('POST /api/invoices/[id]/mark-paid', () => {
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
|
||||
})
|
||||
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
|
||||
|
||||
const request = createMockRequest('/api/invoices/inv-1/mark-paid', { method: 'POST' })
|
||||
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
||||
const { status, body } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(401)
|
||||
expect(body).toEqual({ error: 'Unauthorized' })
|
||||
})
|
||||
|
||||
it('returns 404 when invoice not found', async () => {
|
||||
enqueue({ data: null, error: { message: 'Not found' } })
|
||||
|
||||
const request = createMockRequest('/api/invoices/inv-1/mark-paid', { method: 'POST' })
|
||||
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(404)
|
||||
expect(body.error).toBe('Fakturan hittades inte')
|
||||
})
|
||||
|
||||
it('returns 400 when invoice is in draft status', async () => {
|
||||
const invoice = makeInvoice({ status: 'draft' })
|
||||
enqueue({ data: invoice, error: null })
|
||||
|
||||
const request = createMockRequest('/api/invoices/inv-1/mark-paid', { method: 'POST' })
|
||||
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error).toBe('Fakturan kan inte markeras som betald i nuvarande status')
|
||||
})
|
||||
|
||||
it('returns 400 when invoice is already paid', async () => {
|
||||
const invoice = makeInvoice({ status: 'paid' })
|
||||
enqueue({ data: invoice, error: null })
|
||||
|
||||
const request = createMockRequest('/api/invoices/inv-1/mark-paid', { method: 'POST' })
|
||||
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error).toBe('Fakturan kan inte markeras som betald i nuvarande status')
|
||||
})
|
||||
|
||||
it('returns 400 when invoice is credited', async () => {
|
||||
const invoice = makeInvoice({ status: 'credited' })
|
||||
enqueue({ data: invoice, error: null })
|
||||
|
||||
const request = createMockRequest('/api/invoices/inv-1/mark-paid', { method: 'POST' })
|
||||
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
})
|
||||
|
||||
it('marks sent invoice as paid with accrual method', async () => {
|
||||
const customer = makeCustomer()
|
||||
const invoice = makeInvoice({
|
||||
id: 'inv-1',
|
||||
status: 'sent',
|
||||
total: 12500,
|
||||
customer,
|
||||
})
|
||||
|
||||
// Fetch invoice
|
||||
enqueue({ data: invoice, error: null })
|
||||
// Update invoice status
|
||||
enqueue({ data: null, error: null })
|
||||
// Fetch company settings
|
||||
enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null })
|
||||
|
||||
mockCreateInvoicePaymentJournalEntry.mockResolvedValue({ id: 'je-1' })
|
||||
|
||||
const request = createMockRequest('/api/invoices/inv-1/mark-paid', { method: 'POST' })
|
||||
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
success: boolean
|
||||
status: string
|
||||
paid_amount: number
|
||||
journal_entry_id: string | null
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.success).toBe(true)
|
||||
expect(body.status).toBe('paid')
|
||||
expect(body.paid_amount).toBe(12500)
|
||||
expect(body.journal_entry_id).toBe('je-1')
|
||||
expect(mockCreateInvoicePaymentJournalEntry).toHaveBeenCalledWith(
|
||||
'user-1',
|
||||
expect.objectContaining({ id: 'inv-1' }),
|
||||
expect.any(String)
|
||||
)
|
||||
})
|
||||
|
||||
it('marks overdue invoice as paid with cash method', async () => {
|
||||
const customer = makeCustomer()
|
||||
const invoice = makeInvoice({
|
||||
id: 'inv-1',
|
||||
status: 'overdue',
|
||||
total: 12500,
|
||||
customer,
|
||||
})
|
||||
|
||||
enqueue({ data: invoice, error: null })
|
||||
enqueue({ data: null, error: null })
|
||||
enqueue({ data: { accounting_method: 'cash', entity_type: 'enskild_firma' }, error: null })
|
||||
|
||||
mockCreateInvoiceCashEntry.mockResolvedValue({ id: 'je-2' })
|
||||
|
||||
const request = createMockRequest('/api/invoices/inv-1/mark-paid', { method: 'POST' })
|
||||
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
success: boolean
|
||||
journal_entry_id: string | null
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.success).toBe(true)
|
||||
expect(body.journal_entry_id).toBe('je-2')
|
||||
expect(mockCreateInvoiceCashEntry).toHaveBeenCalledWith(
|
||||
'user-1',
|
||||
expect.objectContaining({ id: 'inv-1' }),
|
||||
expect.any(String),
|
||||
'enskild_firma'
|
||||
)
|
||||
})
|
||||
|
||||
it('returns success with null journal_entry_id when journal entry creation fails', async () => {
|
||||
const invoice = makeInvoice({ id: 'inv-1', status: 'sent', total: 12500 })
|
||||
|
||||
enqueue({ data: invoice, error: null })
|
||||
enqueue({ data: null, error: null })
|
||||
enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null })
|
||||
|
||||
mockCreateInvoicePaymentJournalEntry.mockRejectedValue(new Error('Period locked'))
|
||||
|
||||
const request = createMockRequest('/api/invoices/inv-1/mark-paid', { method: 'POST' })
|
||||
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
success: boolean
|
||||
journal_entry_id: string | null
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.success).toBe(true)
|
||||
expect(body.journal_entry_id).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { renderToBuffer } from '@react-pdf/renderer'
|
||||
import { InvoicePDF } from '@/lib/invoice/pdf-template'
|
||||
import { InvoicePDF } from '@/lib/invoices/pdf-template'
|
||||
import type { Invoice, InvoiceItem, Customer, CompanySettings } from '@/types'
|
||||
|
||||
export async function GET(
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import {
|
||||
createMockRequest,
|
||||
parseJsonResponse,
|
||||
createMockRouteParams,
|
||||
createQueuedMockSupabase,
|
||||
makeInvoice,
|
||||
makeCustomer,
|
||||
makeCompanySettings,
|
||||
} from '@/tests/helpers'
|
||||
import { eventBus } from '@/lib/events'
|
||||
|
||||
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: () => Promise.resolve(mockSupabase),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/init', () => ({
|
||||
ensureInitialized: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockRenderToBuffer = vi.fn()
|
||||
vi.mock('@react-pdf/renderer', () => ({
|
||||
renderToBuffer: (...args: unknown[]) => mockRenderToBuffer(...args),
|
||||
Document: vi.fn(),
|
||||
Page: vi.fn(),
|
||||
Text: vi.fn(),
|
||||
View: vi.fn(),
|
||||
StyleSheet: { create: (s: unknown) => s },
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/invoice/pdf-template', () => ({
|
||||
InvoicePDF: vi.fn().mockReturnValue('mock-pdf-element'),
|
||||
}))
|
||||
|
||||
const mockSendEmail = vi.fn()
|
||||
const mockIsResendConfigured = vi.fn()
|
||||
vi.mock('@/lib/email/resend', () => ({
|
||||
sendEmail: (...args: unknown[]) => mockSendEmail(...args),
|
||||
isResendConfigured: () => mockIsResendConfigured(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/email/invoice-templates', () => ({
|
||||
generateInvoiceEmailHtml: vi.fn().mockReturnValue('<html>Invoice</html>'),
|
||||
generateInvoiceEmailText: vi.fn().mockReturnValue('Invoice text'),
|
||||
generateInvoiceEmailSubject: vi.fn().mockReturnValue('Faktura F-2024001'),
|
||||
}))
|
||||
|
||||
const mockCreateInvoiceJournalEntry = vi.fn()
|
||||
vi.mock('@/lib/bookkeeping/invoice-entries', () => ({
|
||||
createInvoiceJournalEntry: (...args: unknown[]) =>
|
||||
mockCreateInvoiceJournalEntry(...args),
|
||||
}))
|
||||
|
||||
import { POST } from '../route'
|
||||
|
||||
describe('POST /api/invoices/[id]/send', () => {
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
const customer = makeCustomer({ id: 'cust-1', email: 'kund@test.se' })
|
||||
const company = makeCompanySettings({ accounting_method: 'accrual' })
|
||||
const invoice = makeInvoice({
|
||||
id: 'inv-1',
|
||||
status: 'draft',
|
||||
customer,
|
||||
items: [
|
||||
{
|
||||
id: 'item-1',
|
||||
invoice_id: 'inv-1',
|
||||
sort_order: 0,
|
||||
description: 'Consulting',
|
||||
quantity: 10,
|
||||
unit: 'tim',
|
||||
unit_price: 1000,
|
||||
line_total: 10000,
|
||||
created_at: '2024-06-15T14:30:00Z',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
eventBus.clear()
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
|
||||
mockIsResendConfigured.mockReturnValue(true)
|
||||
mockRenderToBuffer.mockResolvedValue(Buffer.from('fake-pdf'))
|
||||
})
|
||||
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
|
||||
|
||||
const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' })
|
||||
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
||||
const { status, body } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(401)
|
||||
expect(body).toEqual({ error: 'Unauthorized' })
|
||||
})
|
||||
|
||||
it('returns 503 when email service is not configured', async () => {
|
||||
mockIsResendConfigured.mockReturnValue(false)
|
||||
|
||||
const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' })
|
||||
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(503)
|
||||
})
|
||||
|
||||
it('returns 404 when invoice not found', async () => {
|
||||
enqueue({ data: null, error: { message: 'Not found' } })
|
||||
|
||||
const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' })
|
||||
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(404)
|
||||
expect(body.error).toBe('Fakturan hittades inte')
|
||||
})
|
||||
|
||||
it('returns 400 when customer has no email', async () => {
|
||||
const noEmailInvoice = makeInvoice({
|
||||
id: 'inv-1',
|
||||
customer: makeCustomer({ email: null }),
|
||||
items: [],
|
||||
})
|
||||
enqueue({ data: noEmailInvoice, error: null })
|
||||
|
||||
const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' })
|
||||
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error).toContain('e-postadress')
|
||||
})
|
||||
|
||||
it('returns 404 when company settings not found', async () => {
|
||||
enqueue({ data: invoice, error: null })
|
||||
enqueue({ data: null, error: { message: 'Not found' } })
|
||||
|
||||
const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' })
|
||||
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(404)
|
||||
expect(body.error).toBe('Företagsinställningar saknas')
|
||||
})
|
||||
|
||||
it('sends invoice email, updates status, creates journal entry for accrual', async () => {
|
||||
// Fetch invoice
|
||||
enqueue({ data: invoice, error: null })
|
||||
// Fetch company settings
|
||||
enqueue({ data: company, error: null })
|
||||
|
||||
mockSendEmail.mockResolvedValue({ success: true, messageId: 'msg-1' })
|
||||
mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-1' })
|
||||
|
||||
// Update invoice status to 'sent'
|
||||
enqueue({ data: null, error: null })
|
||||
// Update invoice with journal_entry_id
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
const emitSpy = vi.spyOn(eventBus, 'emit')
|
||||
|
||||
const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' })
|
||||
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
success: boolean
|
||||
messageId: string
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.success).toBe(true)
|
||||
expect(body.messageId).toBe('msg-1')
|
||||
expect(mockSendEmail).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
to: 'kund@test.se',
|
||||
subject: 'Faktura F-2024001',
|
||||
})
|
||||
)
|
||||
expect(mockCreateInvoiceJournalEntry).toHaveBeenCalledWith(
|
||||
'user-1',
|
||||
expect.objectContaining({ id: 'inv-1' }),
|
||||
'enskild_firma'
|
||||
)
|
||||
expect(emitSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'invoice.sent' })
|
||||
)
|
||||
})
|
||||
|
||||
it('skips journal entry for cash method', async () => {
|
||||
const cashCompany = makeCompanySettings({ accounting_method: 'cash' })
|
||||
enqueue({ data: invoice, error: null })
|
||||
enqueue({ data: cashCompany, error: null })
|
||||
|
||||
mockSendEmail.mockResolvedValue({ success: true, messageId: 'msg-2' })
|
||||
|
||||
// Update invoice status
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' })
|
||||
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ success: boolean }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.success).toBe(true)
|
||||
expect(mockCreateInvoiceJournalEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not fail when journal entry creation fails (non-blocking)', async () => {
|
||||
enqueue({ data: invoice, error: null })
|
||||
enqueue({ data: company, error: null })
|
||||
|
||||
mockSendEmail.mockResolvedValue({ success: true, messageId: 'msg-3' })
|
||||
mockCreateInvoiceJournalEntry.mockRejectedValue(new Error('Period locked'))
|
||||
|
||||
// Update invoice status
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' })
|
||||
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ success: boolean }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.success).toBe(true)
|
||||
})
|
||||
|
||||
it('returns 500 when email sending fails', async () => {
|
||||
enqueue({ data: invoice, error: null })
|
||||
enqueue({ data: company, error: null })
|
||||
|
||||
mockSendEmail.mockResolvedValue({ success: false, error: 'SMTP error' })
|
||||
|
||||
const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' })
|
||||
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(500)
|
||||
expect(body.error).toContain('SMTP error')
|
||||
})
|
||||
})
|
||||
@@ -3,7 +3,7 @@ import { NextResponse } from 'next/server'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { renderToBuffer } from '@react-pdf/renderer'
|
||||
import { InvoicePDF } from '@/lib/invoice/pdf-template'
|
||||
import { InvoicePDF } from '@/lib/invoices/pdf-template'
|
||||
import { sendEmail, isResendConfigured } from '@/lib/email/resend'
|
||||
import {
|
||||
generateInvoiceEmailHtml,
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import {
|
||||
createMockRequest,
|
||||
parseJsonResponse,
|
||||
createQueuedMockSupabase,
|
||||
makeInvoice,
|
||||
makeCustomer,
|
||||
} from '@/tests/helpers'
|
||||
import { eventBus } from '@/lib/events'
|
||||
|
||||
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: () => Promise.resolve(mockSupabase),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/init', () => ({
|
||||
ensureInitialized: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockGetVatRules = vi.fn()
|
||||
const mockCalculateVat = vi.fn()
|
||||
vi.mock('@/lib/invoice/vat-rules', () => ({
|
||||
getVatRules: (...args: unknown[]) => mockGetVatRules(...args),
|
||||
calculateVat: (...args: unknown[]) => mockCalculateVat(...args),
|
||||
calculateTotal: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/currency/riksbanken', () => ({
|
||||
fetchExchangeRate: vi.fn().mockResolvedValue(null),
|
||||
convertToSEK: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockCreateCreditNoteJournalEntry = vi.fn()
|
||||
vi.mock('@/lib/bookkeeping/invoice-entries', () => ({
|
||||
createCreditNoteJournalEntry: (...args: unknown[]) =>
|
||||
mockCreateCreditNoteJournalEntry(...args),
|
||||
}))
|
||||
|
||||
import { GET, POST } from '../route'
|
||||
|
||||
describe('GET /api/invoices', () => {
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
eventBus.clear()
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
|
||||
})
|
||||
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
|
||||
|
||||
const request = createMockRequest('/api/invoices')
|
||||
const response = await GET(request)
|
||||
const { status, body } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(401)
|
||||
expect(body).toEqual({ error: 'Unauthorized' })
|
||||
})
|
||||
|
||||
it('returns invoices list', async () => {
|
||||
const invoices = [makeInvoice(), makeInvoice()]
|
||||
enqueue({ data: invoices, error: null, count: 2 })
|
||||
|
||||
const request = createMockRequest('/api/invoices')
|
||||
const response = await GET(request)
|
||||
const { status, body } = await parseJsonResponse<{ data: unknown[]; count: number }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data).toEqual(invoices)
|
||||
expect(body.count).toBe(2)
|
||||
})
|
||||
|
||||
it('applies status filter', async () => {
|
||||
enqueue({ data: [], error: null, count: 0 })
|
||||
|
||||
const request = createMockRequest('/api/invoices', {
|
||||
searchParams: { status: 'sent' },
|
||||
})
|
||||
const response = await GET(request)
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(mockSupabase.from).toHaveBeenCalledWith('invoices')
|
||||
})
|
||||
|
||||
it('applies pagination', async () => {
|
||||
enqueue({ data: [], error: null, count: 0 })
|
||||
|
||||
const request = createMockRequest('/api/invoices', {
|
||||
searchParams: { limit: '10', offset: '20' },
|
||||
})
|
||||
const response = await GET(request)
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
})
|
||||
|
||||
it('returns 500 on database error', async () => {
|
||||
enqueue({ data: null, error: { message: 'DB error' } })
|
||||
|
||||
const request = createMockRequest('/api/invoices')
|
||||
const response = await GET(request)
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(500)
|
||||
expect(body.error).toBe('DB error')
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/invoices (create invoice)', () => {
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
eventBus.clear()
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
|
||||
})
|
||||
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
|
||||
|
||||
const request = createMockRequest('/api/invoices', {
|
||||
method: 'POST',
|
||||
body: { customer_id: 'cust-1', items: [] },
|
||||
})
|
||||
const response = await POST(request)
|
||||
const { status, body } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(401)
|
||||
expect(body).toEqual({ error: 'Unauthorized' })
|
||||
})
|
||||
|
||||
it('returns 404 when customer not found', async () => {
|
||||
enqueue({ data: null, error: { message: 'Not found' } })
|
||||
|
||||
const request = createMockRequest('/api/invoices', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
customer_id: 'cust-999',
|
||||
invoice_date: '2024-06-15',
|
||||
due_date: '2024-07-15',
|
||||
currency: 'SEK',
|
||||
items: [{ description: 'Test', quantity: 1, unit: 'st', unit_price: 1000 }],
|
||||
},
|
||||
})
|
||||
const response = await POST(request)
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(404)
|
||||
expect(body.error).toBe('Customer not found')
|
||||
})
|
||||
|
||||
it('creates invoice with items and emits event', async () => {
|
||||
const customer = makeCustomer({ id: 'cust-1' })
|
||||
const createdInvoice = makeInvoice({ id: 'inv-1' })
|
||||
|
||||
mockGetVatRules.mockReturnValue({
|
||||
treatment: 'standard_25',
|
||||
rate: 25,
|
||||
momsRuta: '10',
|
||||
reverseChargeText: null,
|
||||
})
|
||||
mockCalculateVat.mockReturnValue(2500)
|
||||
|
||||
// Fetch customer
|
||||
enqueue({ data: customer, error: null })
|
||||
// RPC generate_invoice_number
|
||||
enqueue({ data: 'F-2024001' })
|
||||
// Insert invoice
|
||||
enqueue({ data: createdInvoice, error: null })
|
||||
// Insert items
|
||||
enqueue({ data: null, error: null })
|
||||
// Fetch complete invoice
|
||||
enqueue({ data: { ...createdInvoice, customer, items: [] }, error: null })
|
||||
|
||||
const emitSpy = vi.spyOn(eventBus, 'emit')
|
||||
|
||||
const request = createMockRequest('/api/invoices', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
customer_id: 'cust-1',
|
||||
invoice_date: '2024-06-15',
|
||||
due_date: '2024-07-15',
|
||||
currency: 'SEK',
|
||||
items: [{ description: 'Consulting', quantity: 10, unit: 'tim', unit_price: 1000 }],
|
||||
},
|
||||
})
|
||||
const response = await POST(request)
|
||||
const { status, body } = await parseJsonResponse<{ data: unknown }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data).toBeTruthy()
|
||||
expect(emitSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'invoice.created' })
|
||||
)
|
||||
})
|
||||
|
||||
it('rolls back invoice when items insertion fails', async () => {
|
||||
const customer = makeCustomer({ id: 'cust-1' })
|
||||
const createdInvoice = makeInvoice({ id: 'inv-1' })
|
||||
|
||||
mockGetVatRules.mockReturnValue({
|
||||
treatment: 'standard_25',
|
||||
rate: 25,
|
||||
momsRuta: '10',
|
||||
reverseChargeText: null,
|
||||
})
|
||||
mockCalculateVat.mockReturnValue(2500)
|
||||
|
||||
enqueue({ data: customer, error: null })
|
||||
enqueue({ data: 'F-2024001' })
|
||||
enqueue({ data: createdInvoice, error: null })
|
||||
// Items insertion fails
|
||||
enqueue({ data: null, error: { message: 'Items insert failed' } })
|
||||
// Rollback delete
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
const request = createMockRequest('/api/invoices', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
customer_id: 'cust-1',
|
||||
invoice_date: '2024-06-15',
|
||||
due_date: '2024-07-15',
|
||||
currency: 'SEK',
|
||||
items: [{ description: 'Test', quantity: 1, unit: 'st', unit_price: 1000 }],
|
||||
},
|
||||
})
|
||||
const response = await POST(request)
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(500)
|
||||
expect(body.error).toBe('Items insert failed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/invoices (create credit note)', () => {
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
eventBus.clear()
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
|
||||
})
|
||||
|
||||
it('returns 404 when original invoice not found', async () => {
|
||||
enqueue({ data: null, error: { message: 'Not found' } })
|
||||
|
||||
const request = createMockRequest('/api/invoices', {
|
||||
method: 'POST',
|
||||
body: { credited_invoice_id: 'inv-999' },
|
||||
})
|
||||
const response = await POST(request)
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(404)
|
||||
expect(body.error).toBe('Original invoice not found')
|
||||
})
|
||||
|
||||
it('returns 400 when invoice is already credited', async () => {
|
||||
const original = makeInvoice({ id: 'inv-1', status: 'credited' })
|
||||
enqueue({ data: original, error: null })
|
||||
|
||||
const request = createMockRequest('/api/invoices', {
|
||||
method: 'POST',
|
||||
body: { credited_invoice_id: 'inv-1' },
|
||||
})
|
||||
const response = await POST(request)
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error).toBe('Invoice has already been credited')
|
||||
})
|
||||
|
||||
it('returns 400 when invoice is in draft status', async () => {
|
||||
const original = makeInvoice({ id: 'inv-1', status: 'draft' })
|
||||
enqueue({ data: original, error: null })
|
||||
|
||||
const request = createMockRequest('/api/invoices', {
|
||||
method: 'POST',
|
||||
body: { credited_invoice_id: 'inv-1' },
|
||||
})
|
||||
const response = await POST(request)
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error).toBe('Only sent, paid, or overdue invoices can be credited')
|
||||
})
|
||||
|
||||
it('creates credit note with negated amounts and emits event', async () => {
|
||||
const items = [
|
||||
{
|
||||
id: 'item-1',
|
||||
invoice_id: 'inv-1',
|
||||
sort_order: 0,
|
||||
description: 'Consulting',
|
||||
quantity: 10,
|
||||
unit: 'tim',
|
||||
unit_price: 1000,
|
||||
line_total: 10000,
|
||||
created_at: '2024-06-15T14:30:00Z',
|
||||
},
|
||||
]
|
||||
const original = makeInvoice({
|
||||
id: 'inv-1',
|
||||
status: 'sent',
|
||||
subtotal: 10000,
|
||||
vat_amount: 2500,
|
||||
total: 12500,
|
||||
items,
|
||||
})
|
||||
const creditNote = makeInvoice({
|
||||
id: 'cn-1',
|
||||
credited_invoice_id: 'inv-1',
|
||||
subtotal: -10000,
|
||||
vat_amount: -2500,
|
||||
total: -12500,
|
||||
status: 'sent',
|
||||
})
|
||||
|
||||
// Fetch original invoice
|
||||
enqueue({ data: original, error: null })
|
||||
// Insert credit note
|
||||
enqueue({ data: creditNote, error: null })
|
||||
// Insert credit note items
|
||||
enqueue({ data: null, error: null })
|
||||
// Update original status to 'credited'
|
||||
enqueue({ data: null, error: null })
|
||||
// Fetch complete credit note
|
||||
enqueue({ data: { ...creditNote, items: [] }, error: null })
|
||||
// Fetch company settings for entity type
|
||||
enqueue({ data: { entity_type: 'enskild_firma' }, error: null })
|
||||
|
||||
mockCreateCreditNoteJournalEntry.mockResolvedValue({ id: 'je-1' })
|
||||
// Update credit note with journal_entry_id
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
const emitSpy = vi.spyOn(eventBus, 'emit')
|
||||
|
||||
const request = createMockRequest('/api/invoices', {
|
||||
method: 'POST',
|
||||
body: { credited_invoice_id: 'inv-1' },
|
||||
})
|
||||
const response = await POST(request)
|
||||
const { status, body } = await parseJsonResponse<{ data: unknown }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data).toBeTruthy()
|
||||
expect(emitSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'credit_note.created' })
|
||||
)
|
||||
})
|
||||
|
||||
it('rolls back credit note when items insertion fails', async () => {
|
||||
const original = makeInvoice({
|
||||
id: 'inv-1',
|
||||
status: 'sent',
|
||||
items: [
|
||||
{
|
||||
id: 'item-1',
|
||||
invoice_id: 'inv-1',
|
||||
sort_order: 0,
|
||||
description: 'Test',
|
||||
quantity: 1,
|
||||
unit: 'st',
|
||||
unit_price: 1000,
|
||||
line_total: 1000,
|
||||
created_at: '2024-06-15T14:30:00Z',
|
||||
},
|
||||
],
|
||||
})
|
||||
const creditNote = makeInvoice({ id: 'cn-1' })
|
||||
|
||||
enqueue({ data: original, error: null })
|
||||
enqueue({ data: creditNote, error: null })
|
||||
// Items fail
|
||||
enqueue({ data: null, error: { message: 'Items insert failed' } })
|
||||
// Rollback delete
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
const request = createMockRequest('/api/invoices', {
|
||||
method: 'POST',
|
||||
body: { credited_invoice_id: 'inv-1' },
|
||||
})
|
||||
const response = await POST(request)
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(500)
|
||||
expect(body.error).toBe('Items insert failed')
|
||||
})
|
||||
})
|
||||
@@ -3,7 +3,7 @@ import { NextResponse } from 'next/server'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import type { CreateInvoiceInput, EntityType, AccountingMethod, Invoice, CreditNote, InvoiceDocumentType } from '@/types'
|
||||
import { getVatRules, calculateVat, calculateTotal, getAvailableVatRates, getVatTreatmentForRate } from '@/lib/invoice/vat-rules'
|
||||
import { getVatRules, calculateVat, calculateTotal, getAvailableVatRates, getVatTreatmentForRate } from '@/lib/invoices/vat-rules'
|
||||
import { fetchExchangeRate, convertToSEK } from '@/lib/currency/riksbanken'
|
||||
import {
|
||||
createCreditNoteJournalEntry,
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import {
|
||||
createMockRequest,
|
||||
parseJsonResponse,
|
||||
createMockRouteParams,
|
||||
createQueuedMockSupabase,
|
||||
makeSupplierInvoice,
|
||||
} from '@/tests/helpers'
|
||||
|
||||
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: () => Promise.resolve(mockSupabase),
|
||||
}))
|
||||
|
||||
import { POST } from '../route'
|
||||
|
||||
describe('POST /api/supplier-invoices/[id]/approve', () => {
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
|
||||
})
|
||||
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
|
||||
|
||||
const request = createMockRequest('/api/supplier-invoices/inv-1/approve', { method: 'POST' })
|
||||
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
||||
const { status, body } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(401)
|
||||
expect(body).toEqual({ error: 'Unauthorized' })
|
||||
})
|
||||
|
||||
it('returns 404 when invoice not found', async () => {
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
const request = createMockRequest('/api/supplier-invoices/inv-1/approve', { method: 'POST' })
|
||||
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(404)
|
||||
expect(body.error).toBe('Not found')
|
||||
})
|
||||
|
||||
it('returns 400 when invoice is not in registered status', async () => {
|
||||
enqueue({ data: { status: 'approved' }, error: null })
|
||||
|
||||
const request = createMockRequest('/api/supplier-invoices/inv-1/approve', { method: 'POST' })
|
||||
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error).toBe('Kan bara godkänna registrerade fakturor')
|
||||
})
|
||||
|
||||
it('approves registered invoice', async () => {
|
||||
const invoice = makeSupplierInvoice({ id: 'inv-1', status: 'registered' })
|
||||
const approvedInvoice = { ...invoice, status: 'approved' }
|
||||
|
||||
// First call: fetch invoice status
|
||||
enqueue({ data: { status: 'registered' }, error: null })
|
||||
// Second call: update + select
|
||||
enqueue({ data: approvedInvoice, error: null })
|
||||
|
||||
const request = createMockRequest('/api/supplier-invoices/inv-1/approve', { method: 'POST' })
|
||||
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ data: unknown }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data).toEqual(approvedInvoice)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,255 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import {
|
||||
createMockRequest,
|
||||
parseJsonResponse,
|
||||
createMockRouteParams,
|
||||
createQueuedMockSupabase,
|
||||
makeSupplierInvoice,
|
||||
makeSupplier,
|
||||
} from '@/tests/helpers'
|
||||
|
||||
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: () => Promise.resolve(mockSupabase),
|
||||
}))
|
||||
|
||||
const mockCreateSupplierInvoicePaymentEntry = vi.fn()
|
||||
const mockCreateSupplierInvoiceCashEntry = vi.fn()
|
||||
vi.mock('@/lib/bookkeeping/supplier-invoice-entries', () => ({
|
||||
createSupplierInvoicePaymentEntry: (...args: unknown[]) =>
|
||||
mockCreateSupplierInvoicePaymentEntry(...args),
|
||||
createSupplierInvoiceCashEntry: (...args: unknown[]) =>
|
||||
mockCreateSupplierInvoiceCashEntry(...args),
|
||||
}))
|
||||
|
||||
import { POST } from '../route'
|
||||
|
||||
describe('POST /api/supplier-invoices/[id]/mark-paid', () => {
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
|
||||
})
|
||||
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
|
||||
|
||||
const request = createMockRequest('/api/supplier-invoices/si-1/mark-paid', {
|
||||
method: 'POST',
|
||||
body: {},
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'si-1' }))
|
||||
const { status, body } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(401)
|
||||
expect(body).toEqual({ error: 'Unauthorized' })
|
||||
})
|
||||
|
||||
it('returns 404 when invoice not found', async () => {
|
||||
enqueue({ data: null, error: { message: 'Not found' } })
|
||||
|
||||
const request = createMockRequest('/api/supplier-invoices/si-999/mark-paid', {
|
||||
method: 'POST',
|
||||
body: {},
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'si-999' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(404)
|
||||
expect(body.error).toBe('Not found')
|
||||
})
|
||||
|
||||
it('returns 400 when invoice is in wrong status', async () => {
|
||||
const invoice = makeSupplierInvoice({
|
||||
id: 'si-1',
|
||||
status: 'paid',
|
||||
supplier: makeSupplier(),
|
||||
items: [],
|
||||
})
|
||||
enqueue({ data: invoice, error: null })
|
||||
|
||||
const request = createMockRequest('/api/supplier-invoices/si-1/mark-paid', {
|
||||
method: 'POST',
|
||||
body: {},
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'si-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error).toBe('Fakturan kan inte markeras som betald i nuvarande status')
|
||||
})
|
||||
|
||||
it('marks as fully paid with accrual method', async () => {
|
||||
const supplier = makeSupplier()
|
||||
const invoice = makeSupplierInvoice({
|
||||
id: 'si-1',
|
||||
status: 'approved',
|
||||
total: 10000,
|
||||
remaining_amount: 10000,
|
||||
paid_amount: 0,
|
||||
supplier,
|
||||
items: [],
|
||||
})
|
||||
|
||||
// Fetch invoice
|
||||
enqueue({ data: invoice, error: null })
|
||||
// Fetch company settings
|
||||
enqueue({ data: { accounting_method: 'accrual' }, error: null })
|
||||
|
||||
mockCreateSupplierInvoicePaymentEntry.mockResolvedValue({ id: 'je-1' })
|
||||
|
||||
// Update invoice
|
||||
enqueue({ data: null, error: null })
|
||||
// Record payment
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
const request = createMockRequest('/api/supplier-invoices/si-1/mark-paid', {
|
||||
method: 'POST',
|
||||
body: {},
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'si-1' }))
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
success: boolean
|
||||
status: string
|
||||
paid_amount: number
|
||||
remaining_amount: number
|
||||
journal_entry_id: string
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.success).toBe(true)
|
||||
expect(body.status).toBe('paid')
|
||||
expect(body.paid_amount).toBe(10000)
|
||||
expect(body.remaining_amount).toBe(0)
|
||||
expect(body.journal_entry_id).toBe('je-1')
|
||||
expect(mockCreateSupplierInvoicePaymentEntry).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('marks as partially paid', async () => {
|
||||
const supplier = makeSupplier()
|
||||
const invoice = makeSupplierInvoice({
|
||||
id: 'si-1',
|
||||
status: 'approved',
|
||||
total: 10000,
|
||||
remaining_amount: 10000,
|
||||
paid_amount: 0,
|
||||
supplier,
|
||||
items: [],
|
||||
})
|
||||
|
||||
enqueue({ data: invoice, error: null })
|
||||
enqueue({ data: { accounting_method: 'accrual' }, error: null })
|
||||
|
||||
mockCreateSupplierInvoicePaymentEntry.mockResolvedValue({ id: 'je-2' })
|
||||
|
||||
enqueue({ data: null, error: null })
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
const request = createMockRequest('/api/supplier-invoices/si-1/mark-paid', {
|
||||
method: 'POST',
|
||||
body: { amount: 5000 },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'si-1' }))
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
success: boolean
|
||||
status: string
|
||||
paid_amount: number
|
||||
remaining_amount: number
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.success).toBe(true)
|
||||
expect(body.status).toBe('partially_paid')
|
||||
expect(body.paid_amount).toBe(5000)
|
||||
expect(body.remaining_amount).toBe(5000)
|
||||
})
|
||||
|
||||
it('uses cash method journal entry when configured', async () => {
|
||||
const supplier = makeSupplier()
|
||||
const invoice = makeSupplierInvoice({
|
||||
id: 'si-1',
|
||||
status: 'approved',
|
||||
total: 10000,
|
||||
remaining_amount: 10000,
|
||||
paid_amount: 0,
|
||||
supplier,
|
||||
items: [
|
||||
{
|
||||
id: 'item-1',
|
||||
supplier_invoice_id: 'si-1',
|
||||
sort_order: 0,
|
||||
description: 'Material',
|
||||
quantity: 10,
|
||||
unit: 'st',
|
||||
unit_price: 800,
|
||||
line_total: 8000,
|
||||
account_number: '4010',
|
||||
vat_code: null,
|
||||
vat_rate: 0.25,
|
||||
vat_amount: 2000,
|
||||
created_at: '2024-06-01T00:00:00Z',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
enqueue({ data: invoice, error: null })
|
||||
enqueue({ data: { accounting_method: 'cash' }, error: null })
|
||||
|
||||
mockCreateSupplierInvoiceCashEntry.mockResolvedValue({ id: 'je-3' })
|
||||
|
||||
enqueue({ data: null, error: null })
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
const request = createMockRequest('/api/supplier-invoices/si-1/mark-paid', {
|
||||
method: 'POST',
|
||||
body: {},
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'si-1' }))
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
success: boolean
|
||||
journal_entry_id: string
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.journal_entry_id).toBe('je-3')
|
||||
expect(mockCreateSupplierInvoiceCashEntry).toHaveBeenCalled()
|
||||
expect(mockCreateSupplierInvoicePaymentEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns success when journal entry creation fails (non-blocking)', async () => {
|
||||
const supplier = makeSupplier()
|
||||
const invoice = makeSupplierInvoice({
|
||||
id: 'si-1',
|
||||
status: 'approved',
|
||||
total: 10000,
|
||||
remaining_amount: 10000,
|
||||
paid_amount: 0,
|
||||
supplier,
|
||||
items: [],
|
||||
})
|
||||
|
||||
enqueue({ data: invoice, error: null })
|
||||
enqueue({ data: { accounting_method: 'accrual' }, error: null })
|
||||
|
||||
mockCreateSupplierInvoicePaymentEntry.mockRejectedValue(new Error('Period locked'))
|
||||
|
||||
enqueue({ data: null, error: null })
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
const request = createMockRequest('/api/supplier-invoices/si-1/mark-paid', {
|
||||
method: 'POST',
|
||||
body: {},
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'si-1' }))
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
success: boolean
|
||||
journal_entry_id: null
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.success).toBe(true)
|
||||
expect(body.journal_entry_id).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,246 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import {
|
||||
createMockRequest,
|
||||
parseJsonResponse,
|
||||
createQueuedMockSupabase,
|
||||
makeSupplierInvoice,
|
||||
makeSupplier,
|
||||
} from '@/tests/helpers'
|
||||
|
||||
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: () => Promise.resolve(mockSupabase),
|
||||
}))
|
||||
|
||||
const mockFindFiscalPeriod = vi.fn()
|
||||
vi.mock('@/lib/bookkeeping/engine', () => ({
|
||||
findFiscalPeriod: (...args: unknown[]) => mockFindFiscalPeriod(...args),
|
||||
}))
|
||||
|
||||
const mockCreateSupplierInvoiceRegistrationEntry = vi.fn()
|
||||
vi.mock('@/lib/bookkeeping/supplier-invoice-entries', () => ({
|
||||
createSupplierInvoiceRegistrationEntry: (...args: unknown[]) =>
|
||||
mockCreateSupplierInvoiceRegistrationEntry(...args),
|
||||
}))
|
||||
|
||||
import { GET, POST } from '../route'
|
||||
|
||||
describe('GET /api/supplier-invoices', () => {
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
|
||||
})
|
||||
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
|
||||
|
||||
const request = createMockRequest('/api/supplier-invoices')
|
||||
const response = await GET(request)
|
||||
const { status, body } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(401)
|
||||
expect(body).toEqual({ error: 'Unauthorized' })
|
||||
})
|
||||
|
||||
it('returns supplier invoices list', async () => {
|
||||
const invoices = [makeSupplierInvoice(), makeSupplierInvoice()]
|
||||
enqueue({ data: invoices, error: null })
|
||||
|
||||
const request = createMockRequest('/api/supplier-invoices')
|
||||
const response = await GET(request)
|
||||
const { status, body } = await parseJsonResponse<{ data: unknown[] }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data).toEqual(invoices)
|
||||
})
|
||||
|
||||
it('applies status filter', async () => {
|
||||
enqueue({ data: [], error: null })
|
||||
|
||||
const request = createMockRequest('/api/supplier-invoices', {
|
||||
searchParams: { status: 'registered' },
|
||||
})
|
||||
const response = await GET(request)
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(mockSupabase.from).toHaveBeenCalledWith('supplier_invoices')
|
||||
})
|
||||
|
||||
it('handles to_pay virtual status', async () => {
|
||||
enqueue({ data: [], error: null })
|
||||
|
||||
const request = createMockRequest('/api/supplier-invoices', {
|
||||
searchParams: { status: 'to_pay' },
|
||||
})
|
||||
const response = await GET(request)
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
})
|
||||
|
||||
it('returns 500 on database error', async () => {
|
||||
enqueue({ data: null, error: { message: 'DB error' } })
|
||||
|
||||
const request = createMockRequest('/api/supplier-invoices')
|
||||
const response = await GET(request)
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(500)
|
||||
expect(body.error).toBe('DB error')
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/supplier-invoices', () => {
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
|
||||
})
|
||||
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
|
||||
|
||||
const request = createMockRequest('/api/supplier-invoices', {
|
||||
method: 'POST',
|
||||
body: { supplier_id: 'sup-1', items: [] },
|
||||
})
|
||||
const response = await POST(request)
|
||||
const { status, body } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(401)
|
||||
expect(body).toEqual({ error: 'Unauthorized' })
|
||||
})
|
||||
|
||||
it('returns 404 when supplier not found', async () => {
|
||||
enqueue({ data: null, error: { message: 'Not found' } })
|
||||
|
||||
const request = createMockRequest('/api/supplier-invoices', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
supplier_id: 'sup-999',
|
||||
supplier_invoice_number: 'LF-001',
|
||||
invoice_date: '2024-06-01',
|
||||
due_date: '2024-07-01',
|
||||
items: [{ description: 'Material', quantity: 1, unit_price: 8000, account_number: '4010' }],
|
||||
},
|
||||
})
|
||||
const response = await POST(request)
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(404)
|
||||
expect(body.error).toBe('Supplier not found')
|
||||
})
|
||||
|
||||
it('creates supplier invoice with items and arrival number', async () => {
|
||||
const supplier = makeSupplier({ id: 'sup-1' })
|
||||
const createdInvoice = makeSupplierInvoice({ id: 'si-1' })
|
||||
|
||||
// Fetch supplier
|
||||
enqueue({ data: supplier, error: null })
|
||||
// RPC get_next_arrival_number
|
||||
enqueue({ data: 5 })
|
||||
// Insert invoice
|
||||
enqueue({ data: createdInvoice, error: null })
|
||||
// Insert items
|
||||
enqueue({ data: null, error: null })
|
||||
// Fetch company settings
|
||||
enqueue({ data: { accounting_method: 'accrual' }, error: null })
|
||||
|
||||
mockCreateSupplierInvoiceRegistrationEntry.mockResolvedValue({ id: 'je-1' })
|
||||
// Update invoice with registration_journal_entry_id
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
const request = createMockRequest('/api/supplier-invoices', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
supplier_id: 'sup-1',
|
||||
supplier_invoice_number: 'LF-001',
|
||||
invoice_date: '2024-06-01',
|
||||
due_date: '2024-07-01',
|
||||
items: [
|
||||
{
|
||||
description: 'Material',
|
||||
quantity: 10,
|
||||
unit_price: 800,
|
||||
account_number: '4010',
|
||||
vat_rate: 0.25,
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
const response = await POST(request)
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
data: { registration_journal_entry_id: string }
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data).toBeTruthy()
|
||||
expect(body.data.registration_journal_entry_id).toBe('je-1')
|
||||
expect(mockCreateSupplierInvoiceRegistrationEntry).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips registration entry for cash method', async () => {
|
||||
const supplier = makeSupplier({ id: 'sup-1' })
|
||||
const createdInvoice = makeSupplierInvoice({ id: 'si-1' })
|
||||
|
||||
enqueue({ data: supplier, error: null })
|
||||
enqueue({ data: 6 })
|
||||
enqueue({ data: createdInvoice, error: null })
|
||||
enqueue({ data: null, error: null })
|
||||
enqueue({ data: { accounting_method: 'cash' }, error: null })
|
||||
|
||||
const request = createMockRequest('/api/supplier-invoices', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
supplier_id: 'sup-1',
|
||||
supplier_invoice_number: 'LF-002',
|
||||
invoice_date: '2024-06-01',
|
||||
due_date: '2024-07-01',
|
||||
items: [{ description: 'Service', quantity: 1, unit_price: 5000, account_number: '6200' }],
|
||||
},
|
||||
})
|
||||
const response = await POST(request)
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
data: { registration_journal_entry_id: null }
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.registration_journal_entry_id).toBeNull()
|
||||
expect(mockCreateSupplierInvoiceRegistrationEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rolls back on items insertion failure', async () => {
|
||||
const supplier = makeSupplier({ id: 'sup-1' })
|
||||
const createdInvoice = makeSupplierInvoice({ id: 'si-1' })
|
||||
|
||||
enqueue({ data: supplier, error: null })
|
||||
enqueue({ data: 7 })
|
||||
enqueue({ data: createdInvoice, error: null })
|
||||
// Items fail
|
||||
enqueue({ data: null, error: { message: 'Items insert failed' } })
|
||||
// Rollback delete
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
const request = createMockRequest('/api/supplier-invoices', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
supplier_id: 'sup-1',
|
||||
supplier_invoice_number: 'LF-003',
|
||||
invoice_date: '2024-06-01',
|
||||
due_date: '2024-07-01',
|
||||
items: [{ description: 'Test', quantity: 1, unit_price: 1000, account_number: '4010' }],
|
||||
},
|
||||
})
|
||||
const response = await POST(request)
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(500)
|
||||
expect(body.error).toBe('Items insert failed')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,262 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import {
|
||||
createMockRequest,
|
||||
parseJsonResponse,
|
||||
createMockRouteParams,
|
||||
createQueuedMockSupabase,
|
||||
makeTransaction,
|
||||
} from '@/tests/helpers'
|
||||
import { eventBus } from '@/lib/events'
|
||||
|
||||
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: () => Promise.resolve(mockSupabase),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/init', () => ({
|
||||
ensureInitialized: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockBuildMappingResultFromCategory = vi.fn()
|
||||
vi.mock('@/lib/bookkeeping/category-mapping', () => ({
|
||||
buildMappingResultFromCategory: (...args: unknown[]) =>
|
||||
mockBuildMappingResultFromCategory(...args),
|
||||
}))
|
||||
|
||||
const mockCreateTransactionJournalEntry = vi.fn()
|
||||
vi.mock('@/lib/bookkeeping/transaction-entries', () => ({
|
||||
createTransactionJournalEntry: (...args: unknown[]) =>
|
||||
mockCreateTransactionJournalEntry(...args),
|
||||
}))
|
||||
|
||||
const mockSaveUserMappingRule = vi.fn()
|
||||
vi.mock('@/lib/bookkeeping/mapping-engine', () => ({
|
||||
saveUserMappingRule: (...args: unknown[]) => mockSaveUserMappingRule(...args),
|
||||
}))
|
||||
|
||||
import { POST } from '../route'
|
||||
|
||||
describe('POST /api/transactions/[id]/categorize', () => {
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
const defaultMappingResult = {
|
||||
rule: null,
|
||||
debit_account: '6200',
|
||||
credit_account: '1930',
|
||||
risk_level: 'NONE',
|
||||
confidence: 1,
|
||||
requires_review: false,
|
||||
default_private: false,
|
||||
vat_lines: [{ account_number: '2641', debit_amount: 62.5, credit_amount: 0, description: 'Ingående moms' }],
|
||||
description: 'Test expense',
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
eventBus.clear()
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
|
||||
mockBuildMappingResultFromCategory.mockReturnValue(defaultMappingResult)
|
||||
})
|
||||
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-1/categorize', {
|
||||
method: 'POST',
|
||||
body: { is_business: true, category: 'expense_software' },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status, body } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(401)
|
||||
expect(body).toEqual({ error: 'Unauthorized' })
|
||||
})
|
||||
|
||||
it('returns 404 when transaction not found', async () => {
|
||||
enqueue({ data: null, error: { message: 'Not found' } })
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-999/categorize', {
|
||||
method: 'POST',
|
||||
body: { is_business: true, category: 'expense_software' },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-999' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(404)
|
||||
expect(body.error).toBe('Transaction not found')
|
||||
})
|
||||
|
||||
it('updates category only when transaction already has journal entry', async () => {
|
||||
const tx = makeTransaction({
|
||||
id: 'tx-1',
|
||||
journal_entry_id: 'je-existing',
|
||||
category: 'uncategorized',
|
||||
})
|
||||
// Fetch transaction
|
||||
enqueue({ data: tx, error: null })
|
||||
// Update transaction
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-1/categorize', {
|
||||
method: 'POST',
|
||||
body: { is_business: true, category: 'expense_software' },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
success: boolean
|
||||
already_had_journal_entry: boolean
|
||||
journal_entry_id: string
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.success).toBe(true)
|
||||
expect(body.already_had_journal_entry).toBe(true)
|
||||
expect(body.journal_entry_id).toBe('je-existing')
|
||||
expect(mockCreateTransactionJournalEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('creates journal entry for business expense', async () => {
|
||||
const tx = makeTransaction({
|
||||
id: 'tx-1',
|
||||
amount: -500,
|
||||
merchant_name: 'GitHub',
|
||||
journal_entry_id: null,
|
||||
})
|
||||
|
||||
// Fetch transaction
|
||||
enqueue({ data: tx, error: null })
|
||||
// Fetch company settings
|
||||
enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null })
|
||||
// ensureFiscalPeriod: check existing
|
||||
enqueue({ data: { id: 'period-1' }, error: null })
|
||||
|
||||
mockCreateTransactionJournalEntry.mockResolvedValue({ id: 'je-1' })
|
||||
mockSaveUserMappingRule.mockResolvedValue(undefined)
|
||||
|
||||
// Update transaction
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
const emitSpy = vi.spyOn(eventBus, 'emit')
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-1/categorize', {
|
||||
method: 'POST',
|
||||
body: { is_business: true, category: 'expense_software' },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
success: boolean
|
||||
journal_entry_created: boolean
|
||||
journal_entry_id: string
|
||||
category: string
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.success).toBe(true)
|
||||
expect(body.journal_entry_created).toBe(true)
|
||||
expect(body.journal_entry_id).toBe('je-1')
|
||||
expect(body.category).toBe('expense_software')
|
||||
expect(mockSaveUserMappingRule).toHaveBeenCalledWith(
|
||||
'user-1',
|
||||
'GitHub',
|
||||
'6200',
|
||||
'1930',
|
||||
false
|
||||
)
|
||||
expect(emitSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'transaction.categorized' })
|
||||
)
|
||||
})
|
||||
|
||||
it('returns success with error when journal entry creation fails (non-blocking)', async () => {
|
||||
const tx = makeTransaction({
|
||||
id: 'tx-1',
|
||||
amount: -500,
|
||||
merchant_name: 'Test',
|
||||
journal_entry_id: null,
|
||||
})
|
||||
|
||||
enqueue({ data: tx, error: null })
|
||||
enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null })
|
||||
enqueue({ data: { id: 'period-1' }, error: null })
|
||||
|
||||
mockCreateTransactionJournalEntry.mockRejectedValue(new Error('Period locked'))
|
||||
|
||||
// Update transaction
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-1/categorize', {
|
||||
method: 'POST',
|
||||
body: { is_business: true, category: 'expense_software' },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
success: boolean
|
||||
journal_entry_created: boolean
|
||||
journal_entry_error: string
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.success).toBe(true)
|
||||
expect(body.journal_entry_created).toBe(false)
|
||||
expect(body.journal_entry_error).toBe('Period locked')
|
||||
})
|
||||
|
||||
it('returns 500 when transaction update fails', async () => {
|
||||
const tx = makeTransaction({
|
||||
id: 'tx-1',
|
||||
journal_entry_id: null,
|
||||
merchant_name: null,
|
||||
})
|
||||
|
||||
enqueue({ data: tx, error: null })
|
||||
enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null })
|
||||
enqueue({ data: { id: 'period-1' }, error: null })
|
||||
|
||||
mockCreateTransactionJournalEntry.mockResolvedValue({ id: 'je-1' })
|
||||
|
||||
// Transaction update fails
|
||||
enqueue({ data: null, error: { message: 'Update failed' } })
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-1/categorize', {
|
||||
method: 'POST',
|
||||
body: { is_business: true, category: 'expense_software' },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(500)
|
||||
expect(body.error).toBe('Failed to update transaction')
|
||||
})
|
||||
|
||||
it('categorizes as private when is_business is false', async () => {
|
||||
const tx = makeTransaction({
|
||||
id: 'tx-1',
|
||||
journal_entry_id: null,
|
||||
merchant_name: null,
|
||||
})
|
||||
|
||||
enqueue({ data: tx, error: null })
|
||||
enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null })
|
||||
enqueue({ data: { id: 'period-1' }, error: null })
|
||||
|
||||
mockCreateTransactionJournalEntry.mockResolvedValue({ id: 'je-1' })
|
||||
|
||||
// Update transaction
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-1/categorize', {
|
||||
method: 'POST',
|
||||
body: { is_business: false },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
success: boolean
|
||||
category: string
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.category).toBe('private')
|
||||
// Should NOT save mapping rule for private transactions
|
||||
expect(mockSaveUserMappingRule).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,233 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import {
|
||||
createMockRequest,
|
||||
parseJsonResponse,
|
||||
createMockRouteParams,
|
||||
createQueuedMockSupabase,
|
||||
makeTransaction,
|
||||
makeInvoice,
|
||||
makeCustomer,
|
||||
} from '@/tests/helpers'
|
||||
|
||||
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: () => Promise.resolve(mockSupabase),
|
||||
}))
|
||||
|
||||
const mockCreateJournalEntry = vi.fn()
|
||||
const mockFindFiscalPeriod = vi.fn()
|
||||
vi.mock('@/lib/bookkeeping/engine', () => ({
|
||||
createJournalEntry: (...args: unknown[]) => mockCreateJournalEntry(...args),
|
||||
findFiscalPeriod: (...args: unknown[]) => mockFindFiscalPeriod(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/bookkeeping/invoice-entries', () => ({
|
||||
getRevenueAccount: vi.fn().mockReturnValue('3001'),
|
||||
getOutputVatAccount: vi.fn().mockReturnValue('2611'),
|
||||
}))
|
||||
|
||||
import { POST } from '../route'
|
||||
|
||||
describe('POST /api/transactions/[id]/match-invoice', () => {
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
|
||||
mockFindFiscalPeriod.mockResolvedValue('period-1')
|
||||
})
|
||||
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-1/match-invoice', {
|
||||
method: 'POST',
|
||||
body: { invoice_id: 'inv-1' },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status, body } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(401)
|
||||
expect(body).toEqual({ error: 'Unauthorized' })
|
||||
})
|
||||
|
||||
it('returns 400 when invoice_id is missing', async () => {
|
||||
const request = createMockRequest('/api/transactions/tx-1/match-invoice', {
|
||||
method: 'POST',
|
||||
body: {},
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error).toBe('invoice_id is required')
|
||||
})
|
||||
|
||||
it('returns 404 when transaction not found', async () => {
|
||||
enqueue({ data: null, error: { message: 'Not found' } })
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-999/match-invoice', {
|
||||
method: 'POST',
|
||||
body: { invoice_id: 'inv-1' },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-999' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(404)
|
||||
expect(body.error).toBe('Transaction not found')
|
||||
})
|
||||
|
||||
it('returns 400 when transaction is an expense (amount <= 0)', async () => {
|
||||
const tx = makeTransaction({ id: 'tx-1', amount: -500 })
|
||||
enqueue({ data: tx, error: null })
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-1/match-invoice', {
|
||||
method: 'POST',
|
||||
body: { invoice_id: 'inv-1' },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error).toBe('Only income transactions can be matched to invoices')
|
||||
})
|
||||
|
||||
it('returns 400 when transaction is already linked to an invoice', async () => {
|
||||
const tx = makeTransaction({ id: 'tx-1', amount: 12500, invoice_id: 'inv-other' })
|
||||
enqueue({ data: tx, error: null })
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-1/match-invoice', {
|
||||
method: 'POST',
|
||||
body: { invoice_id: 'inv-1' },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error).toBe('Transaction is already linked to an invoice')
|
||||
})
|
||||
|
||||
it('returns 404 when invoice not found', async () => {
|
||||
const tx = makeTransaction({ id: 'tx-1', amount: 12500, invoice_id: null })
|
||||
enqueue({ data: tx, error: null })
|
||||
enqueue({ data: null, error: { message: 'Not found' } })
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-1/match-invoice', {
|
||||
method: 'POST',
|
||||
body: { invoice_id: 'inv-999' },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(404)
|
||||
expect(body.error).toBe('Invoice not found')
|
||||
})
|
||||
|
||||
it('returns 400 when invoice is not in unpaid state', async () => {
|
||||
const tx = makeTransaction({ id: 'tx-1', amount: 12500, invoice_id: null })
|
||||
const invoice = makeInvoice({ id: 'inv-1', status: 'paid' })
|
||||
enqueue({ data: tx, error: null })
|
||||
enqueue({ data: invoice, error: null })
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-1/match-invoice', {
|
||||
method: 'POST',
|
||||
body: { invoice_id: 'inv-1' },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error).toBe('Invoice is not in an unpaid state')
|
||||
})
|
||||
|
||||
it('matches transaction to invoice with accrual method', async () => {
|
||||
const tx = makeTransaction({ id: 'tx-1', amount: 12500, invoice_id: null, date: '2024-06-15' })
|
||||
const customer = makeCustomer()
|
||||
const invoice = makeInvoice({
|
||||
id: 'inv-1',
|
||||
status: 'sent',
|
||||
total: 12500,
|
||||
subtotal: 10000,
|
||||
vat_amount: 2500,
|
||||
invoice_number: 'F-2024001',
|
||||
customer,
|
||||
})
|
||||
|
||||
// Fetch transaction
|
||||
enqueue({ data: tx, error: null })
|
||||
// Fetch invoice
|
||||
enqueue({ data: invoice, error: null })
|
||||
// Fetch company settings
|
||||
enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null })
|
||||
|
||||
mockCreateJournalEntry.mockResolvedValue({ id: 'je-1' })
|
||||
|
||||
// Update invoice to paid
|
||||
enqueue({ data: null, error: null })
|
||||
// Update transaction
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-1/match-invoice', {
|
||||
method: 'POST',
|
||||
body: { invoice_id: 'inv-1' },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
success: boolean
|
||||
invoice_status: string
|
||||
paid_amount: number
|
||||
journal_entry_id: string
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.success).toBe(true)
|
||||
expect(body.invoice_status).toBe('paid')
|
||||
expect(body.paid_amount).toBe(12500)
|
||||
expect(body.journal_entry_id).toBe('je-1')
|
||||
|
||||
// Verify accrual journal entry: debit 1930, credit 1510
|
||||
expect(mockCreateJournalEntry).toHaveBeenCalledWith(
|
||||
'user-1',
|
||||
expect.objectContaining({
|
||||
source_type: 'invoice_paid',
|
||||
lines: expect.arrayContaining([
|
||||
expect.objectContaining({ account_number: '1930', debit_amount: 12500 }),
|
||||
expect.objectContaining({ account_number: '1510', credit_amount: 12500 }),
|
||||
]),
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('returns success with journal_entry_error when journal entry fails (non-blocking)', async () => {
|
||||
const tx = makeTransaction({ id: 'tx-1', amount: 12500, invoice_id: null, date: '2024-06-15' })
|
||||
const invoice = makeInvoice({ id: 'inv-1', status: 'sent', total: 12500 })
|
||||
|
||||
enqueue({ data: tx, error: null })
|
||||
enqueue({ data: invoice, error: null })
|
||||
enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null })
|
||||
|
||||
mockCreateJournalEntry.mockRejectedValue(new Error('Period locked'))
|
||||
|
||||
// Update invoice
|
||||
enqueue({ data: null, error: null })
|
||||
// Update transaction
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-1/match-invoice', {
|
||||
method: 'POST',
|
||||
body: { invoice_id: 'inv-1' },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
success: boolean
|
||||
journal_entry_id: null
|
||||
journal_entry_error: string
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.success).toBe(true)
|
||||
expect(body.journal_entry_id).toBeNull()
|
||||
expect(body.journal_entry_error).toBe('Period locked')
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getBestInvoiceMatch } from '@/lib/invoice/invoice-matching'
|
||||
import { getBestInvoiceMatch } from '@/lib/invoices/invoice-matching'
|
||||
import type { Transaction } from '@/types'
|
||||
|
||||
/**
|
||||
|
||||
+49
-29
@@ -1,36 +1,9 @@
|
||||
@import "tailwindcss";
|
||||
@plugin "@tailwindcss/typography";
|
||||
|
||||
/* Force light mode */
|
||||
html, :root {
|
||||
color-scheme: light only !important;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: 220 14% 96% !important;
|
||||
--foreground: 224 12% 13% !important;
|
||||
--card: 0 0% 100% !important;
|
||||
--card-foreground: 224 12% 13% !important;
|
||||
--popover: 0 0% 100% !important;
|
||||
--popover-foreground: 224 12% 13% !important;
|
||||
--primary: 222 47% 35% !important;
|
||||
--primary-foreground: 0 0% 100% !important;
|
||||
--secondary: 220 13% 93% !important;
|
||||
--secondary-foreground: 224 12% 13% !important;
|
||||
--muted: 218 12% 91% !important;
|
||||
--muted-foreground: 218 8% 46% !important;
|
||||
--accent: 213 72% 50% !important;
|
||||
--accent-foreground: 0 0% 100% !important;
|
||||
--destructive: 0 68% 50% !important;
|
||||
--destructive-foreground: 0 0% 100% !important;
|
||||
--border: 218 14% 89% !important;
|
||||
--input: 218 14% 89% !important;
|
||||
--ring: 222 47% 35% !important;
|
||||
}
|
||||
}
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
/* Clean Slate — Modern ERP Palette */
|
||||
|
||||
--background: 220 14% 96%; /* Cool off-white #F2F4F7 */
|
||||
@@ -95,6 +68,48 @@ html, :root {
|
||||
--duration-slow: 500ms;
|
||||
}
|
||||
|
||||
.dark {
|
||||
color-scheme: dark;
|
||||
|
||||
--background: 222 16% 10%;
|
||||
--foreground: 216 12% 90%;
|
||||
|
||||
--card: 222 14% 13%;
|
||||
--card-foreground: 216 12% 90%;
|
||||
|
||||
--popover: 222 14% 13%;
|
||||
--popover-foreground: 216 12% 90%;
|
||||
|
||||
--primary: 222 50% 55%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
|
||||
--secondary: 220 14% 18%;
|
||||
--secondary-foreground: 216 12% 90%;
|
||||
|
||||
--muted: 220 12% 20%;
|
||||
--muted-foreground: 218 8% 55%;
|
||||
|
||||
--accent: 213 72% 58%;
|
||||
--accent-foreground: 0 0% 100%;
|
||||
|
||||
--destructive: 0 62% 55%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
|
||||
--border: 220 12% 22%;
|
||||
--input: 220 12% 22%;
|
||||
--ring: 222 50% 55%;
|
||||
|
||||
--success: 152 44% 45%;
|
||||
--success-foreground: 0 0% 100%;
|
||||
|
||||
--warning: 38 85% 55%;
|
||||
--warning-foreground: 224 12% 13%;
|
||||
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.25);
|
||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.35);
|
||||
--shadow-lg: 0 12px 40px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: hsl(var(--background));
|
||||
--color-foreground: hsl(var(--foreground));
|
||||
@@ -292,3 +307,8 @@ h1, h2, h3 {
|
||||
background: hsl(var(--primary) / 0.15);
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.dark ::selection {
|
||||
background: hsl(var(--primary) / 0.3);
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
+11
-4
@@ -3,6 +3,7 @@ import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { Fraunces } from "next/font/google";
|
||||
import Script from "next/script";
|
||||
import { Toaster } from "@/components/ui/toaster";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
@@ -44,16 +45,22 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="sv">
|
||||
<html lang="sv" suppressHydrationWarning>
|
||||
<head>
|
||||
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
|
||||
</head>
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} ${fraunces.variable} antialiased`}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
{children}
|
||||
<Toaster />
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
{children}
|
||||
<Toaster />
|
||||
</ThemeProvider>
|
||||
<Script src="/sw-register.js" strategy="afterInteractive" />
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useState, useCallback } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
@@ -23,12 +23,16 @@ import {
|
||||
ChevronDown,
|
||||
Building2,
|
||||
FileInput,
|
||||
Store,
|
||||
} from 'lucide-react'
|
||||
import { getExtensionDefinition } from '@/lib/extensions/sectors'
|
||||
import { resolveIcon } from '@/lib/extensions/icon-resolver'
|
||||
import type { EntityType } from '@/types'
|
||||
|
||||
interface DashboardNavProps {
|
||||
companyName: string
|
||||
entityType: EntityType
|
||||
enabledExtensions?: { sector_slug: string; extension_slug: string }[]
|
||||
}
|
||||
|
||||
interface NavItem {
|
||||
@@ -61,7 +65,7 @@ const groupLabels: Record<string, string> = {
|
||||
övrigt: 'Övrigt',
|
||||
}
|
||||
|
||||
export default function DashboardNav({ companyName, entityType }: DashboardNavProps) {
|
||||
export default function DashboardNav({ companyName, entityType, enabledExtensions }: DashboardNavProps) {
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
const supabase = createClient()
|
||||
@@ -70,6 +74,31 @@ export default function DashboardNav({ companyName, entityType }: DashboardNavPr
|
||||
const isOnOvrigtPage = ['/import', '/help', '/settings'].some(p => pathname.startsWith(p))
|
||||
const [manualOvrigtExpanded, setManualOvrigtExpanded] = useState(false)
|
||||
const isOvrigtExpanded = isOnOvrigtPage || manualOvrigtExpanded
|
||||
const [isTillaggExpanded, setIsTillaggExpanded] = useState(false)
|
||||
const [liveExtensions, setLiveExtensions] = useState(enabledExtensions ?? [])
|
||||
|
||||
const fetchExtensions = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/extensions/toggles')
|
||||
if (res.ok) {
|
||||
const { data } = await res.json()
|
||||
if (data) setLiveExtensions(data)
|
||||
}
|
||||
} catch {
|
||||
// keep current state on error
|
||||
}
|
||||
}, [])
|
||||
|
||||
const toggleTillagg = () => {
|
||||
const next = !isTillaggExpanded
|
||||
setIsTillaggExpanded(next)
|
||||
if (next) fetchExtensions()
|
||||
}
|
||||
|
||||
const openMobileMenu = () => {
|
||||
setIsMobileMenuOpen(true)
|
||||
fetchExtensions()
|
||||
}
|
||||
|
||||
const handleLogout = async () => {
|
||||
await supabase.auth.signOut()
|
||||
@@ -177,6 +206,74 @@ export default function DashboardNav({ companyName, entityType }: DashboardNavPr
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Marketplace - standalone link */}
|
||||
<div className="mb-4">
|
||||
<div className="space-y-px">
|
||||
<Link
|
||||
href="/extensions"
|
||||
className={cn(
|
||||
'group flex items-center px-3 py-[7px] text-[13px] transition-colors duration-150 rounded-lg',
|
||||
isActive('/extensions')
|
||||
? 'bg-primary/8 text-foreground font-medium'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-muted/40'
|
||||
)}
|
||||
>
|
||||
<Store className={cn(
|
||||
"mr-2.5 h-[15px] w-[15px] flex-shrink-0",
|
||||
isActive('/extensions') ? "text-primary" : "text-muted-foreground/70 group-hover:text-muted-foreground"
|
||||
)} />
|
||||
Marketplace
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tillägg - collapsible */}
|
||||
<div className="mb-4">
|
||||
<button
|
||||
onClick={toggleTillagg}
|
||||
className="w-full flex items-center justify-between px-3 mb-1.5 text-[10px] font-semibold text-muted-foreground/70 uppercase tracking-[0.08em] hover:text-muted-foreground transition-colors"
|
||||
>
|
||||
<span>Tillägg</span>
|
||||
<ChevronDown className={cn(
|
||||
"h-3 w-3 transition-transform duration-200",
|
||||
isTillaggExpanded && "rotate-180"
|
||||
)} />
|
||||
</button>
|
||||
{isTillaggExpanded && (
|
||||
<div className="space-y-px animate-fade-in">
|
||||
{liveExtensions.length > 0 ? liveExtensions.map((toggle) => {
|
||||
const def = getExtensionDefinition(toggle.sector_slug, toggle.extension_slug)
|
||||
if (!def) return null
|
||||
const ExtIcon = resolveIcon(def.icon)
|
||||
const href = `/e/${toggle.sector_slug}/${toggle.extension_slug}`
|
||||
const active = isActive(href)
|
||||
return (
|
||||
<Link
|
||||
key={`${toggle.sector_slug}/${toggle.extension_slug}`}
|
||||
href={href}
|
||||
className={cn(
|
||||
'group flex items-center px-3 py-[7px] text-[13px] transition-colors duration-150 rounded-lg',
|
||||
active
|
||||
? 'bg-primary/8 text-foreground font-medium'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-muted/40'
|
||||
)}
|
||||
>
|
||||
<ExtIcon className={cn(
|
||||
"mr-2.5 h-[15px] w-[15px] flex-shrink-0",
|
||||
active ? "text-primary" : "text-muted-foreground/70 group-hover:text-muted-foreground"
|
||||
)} />
|
||||
{def.name}
|
||||
</Link>
|
||||
)
|
||||
}) : (
|
||||
<p className="px-3 py-2 text-[12px] text-muted-foreground/60">
|
||||
Inga tillägg aktiverade
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Övrigt group - collapsible */}
|
||||
<div className="mb-4">
|
||||
<button
|
||||
@@ -264,7 +361,7 @@ export default function DashboardNav({ companyName, entityType }: DashboardNavPr
|
||||
})}
|
||||
{/* Menu button */}
|
||||
<button
|
||||
onClick={() => setIsMobileMenuOpen(true)}
|
||||
onClick={openMobileMenu}
|
||||
aria-label="Öppna meny"
|
||||
className="flex flex-col items-center justify-center flex-1 h-full text-xs text-muted-foreground transition-colors duration-200"
|
||||
>
|
||||
@@ -360,6 +457,57 @@ export default function DashboardNav({ companyName, entityType }: DashboardNavPr
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Marketplace */}
|
||||
<div className="mb-4">
|
||||
<Link
|
||||
href="/extensions"
|
||||
onClick={closeMobileMenu}
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors',
|
||||
isActive('/extensions')
|
||||
? 'bg-primary/10 text-primary font-medium'
|
||||
: 'text-muted-foreground hover:bg-secondary/50 hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<Store className="h-5 w-5" />
|
||||
Marketplace
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Tillägg */}
|
||||
<div className="mb-4">
|
||||
<p className="px-3 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Tillägg
|
||||
</p>
|
||||
{liveExtensions.length > 0 ? liveExtensions.map((toggle) => {
|
||||
const def = getExtensionDefinition(toggle.sector_slug, toggle.extension_slug)
|
||||
if (!def) return null
|
||||
const ExtIcon = resolveIcon(def.icon)
|
||||
const href = `/e/${toggle.sector_slug}/${toggle.extension_slug}`
|
||||
const active = isActive(href)
|
||||
return (
|
||||
<Link
|
||||
key={`${toggle.sector_slug}/${toggle.extension_slug}`}
|
||||
href={href}
|
||||
onClick={closeMobileMenu}
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors',
|
||||
active
|
||||
? 'bg-primary/10 text-primary font-medium'
|
||||
: 'text-muted-foreground hover:bg-secondary/50 hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<ExtIcon className="h-5 w-5" />
|
||||
{def.name}
|
||||
</Link>
|
||||
)
|
||||
}) : (
|
||||
<p className="px-3 py-2 text-xs text-muted-foreground/60">
|
||||
Inga tillägg aktiverade
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Other section */}
|
||||
<div className="mb-4">
|
||||
<p className="px-3 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
'use client'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { ExtensionCategory } from '@/lib/extensions/types'
|
||||
|
||||
const CATEGORY_CONFIG: Record<ExtensionCategory, { label: string; className: string }> = {
|
||||
accounting: { label: 'Bokföring & Skatt', className: 'bg-rose-100 text-rose-700 border-rose-200 dark:bg-rose-950/30 dark:text-rose-400 dark:border-rose-800' },
|
||||
reports: { label: 'Branschrapporter', className: 'bg-blue-100 text-blue-700 border-blue-200 dark:bg-blue-950/30 dark:text-blue-400 dark:border-blue-800' },
|
||||
import: { label: 'Smart Import', className: 'bg-emerald-100 text-emerald-700 border-emerald-200 dark:bg-emerald-950/30 dark:text-emerald-400 dark:border-emerald-800' },
|
||||
operations: { label: 'Verktyg', className: 'bg-slate-100 text-slate-700 border-slate-200 dark:bg-slate-800/30 dark:text-slate-400 dark:border-slate-700' },
|
||||
}
|
||||
|
||||
export default function CategoryBadge({ category }: { category: ExtensionCategory }) {
|
||||
const config = CATEGORY_CONFIG[category]
|
||||
return (
|
||||
<Badge variant="outline" className={cn('text-[10px] font-medium', config.className)}>
|
||||
{config.label}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
'use client'
|
||||
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { resolveIcon } from '@/lib/extensions/icon-resolver'
|
||||
import type { ExtensionDefinition } from '@/lib/extensions/types'
|
||||
import CategoryBadge from './CategoryBadge'
|
||||
import ExtensionToggleButton from './ExtensionToggleButton'
|
||||
import Link from 'next/link'
|
||||
|
||||
export default function ExtensionCard({ extension }: { extension: ExtensionDefinition }) {
|
||||
const Icon = resolveIcon(extension.icon)
|
||||
|
||||
return (
|
||||
<Card className="group relative">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-start gap-3 min-w-0">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10 flex-shrink-0">
|
||||
<Icon className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<Link
|
||||
href={`/extensions/${extension.sector}/${extension.slug}`}
|
||||
className="text-sm font-medium hover:underline"
|
||||
>
|
||||
{extension.name}
|
||||
</Link>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">
|
||||
{extension.description}
|
||||
</p>
|
||||
<div className="mt-2">
|
||||
<CategoryBadge category={extension.category} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ExtensionToggleButton
|
||||
sectorSlug={extension.sector}
|
||||
extensionSlug={extension.slug}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
'use client'
|
||||
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { useExtensionToggle } from '@/lib/extensions/hooks'
|
||||
|
||||
export default function ExtensionToggleButton({
|
||||
sectorSlug,
|
||||
extensionSlug,
|
||||
}: {
|
||||
sectorSlug: string
|
||||
extensionSlug: string
|
||||
}) {
|
||||
const { enabled, isLoading, toggle } = useExtensionToggle(sectorSlug, extensionSlug)
|
||||
|
||||
return (
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onCheckedChange={toggle}
|
||||
disabled={isLoading}
|
||||
aria-label={enabled ? 'Inaktivera tillägg' : 'Aktivera tillägg'}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
'use client'
|
||||
|
||||
import type { ExtensionDefinition } from '@/lib/extensions/types'
|
||||
import { getWorkspaceComponent } from '@/lib/extensions/workspace-registry'
|
||||
import ExtensionWorkspaceShell from './ExtensionWorkspaceShell'
|
||||
import EmptyExtensionState from './shared/EmptyExtensionState'
|
||||
|
||||
export default function ExtensionWorkspaceLoader({
|
||||
sector,
|
||||
slug,
|
||||
definition,
|
||||
userId,
|
||||
}: {
|
||||
sector: string
|
||||
slug: string
|
||||
definition: ExtensionDefinition
|
||||
userId: string
|
||||
}) {
|
||||
const WorkspaceComponent = getWorkspaceComponent(sector, slug)
|
||||
|
||||
return (
|
||||
<ExtensionWorkspaceShell definition={definition}>
|
||||
{WorkspaceComponent ? (
|
||||
<WorkspaceComponent userId={userId} />
|
||||
) : (
|
||||
<EmptyExtensionState
|
||||
title="Kommer snart"
|
||||
description={`${definition.name} \u00e4r under utveckling och kommer snart att vara tillg\u00e4ngligt.`}
|
||||
/>
|
||||
)}
|
||||
</ExtensionWorkspaceShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
'use client'
|
||||
|
||||
import type { ExtensionDefinition } from '@/lib/extensions/types'
|
||||
import { getSector } from '@/lib/extensions/sectors'
|
||||
import { resolveIcon } from '@/lib/extensions/icon-resolver'
|
||||
import Link from 'next/link'
|
||||
|
||||
export default function ExtensionWorkspaceShell({
|
||||
definition,
|
||||
children,
|
||||
}: {
|
||||
definition: ExtensionDefinition
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const Icon = resolveIcon(definition.icon)
|
||||
const sector = getSector(definition.sector)
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Breadcrumb */}
|
||||
<nav className="flex items-center gap-1.5 text-sm text-muted-foreground mb-6">
|
||||
<Link href="/extensions" className="hover:text-foreground transition-colors">
|
||||
Till\u00e4gg
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<Link
|
||||
href={`/extensions/${definition.sector}`}
|
||||
className="hover:text-foreground transition-colors"
|
||||
>
|
||||
{sector?.name ?? definition.sector}
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<span className="text-foreground">{definition.name}</span>
|
||||
</nav>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-start gap-4 mb-8">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary/10 flex-shrink-0">
|
||||
<Icon className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold tracking-tight">{definition.name}</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">{definition.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Extension content */}
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import Link from 'next/link'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { resolveIcon } from '@/lib/extensions/icon-resolver'
|
||||
import type { Sector } from '@/lib/extensions/types'
|
||||
|
||||
export default function SectorCard({ sector }: { sector: Sector }) {
|
||||
const Icon = resolveIcon(sector.icon)
|
||||
|
||||
return (
|
||||
<Link href={`/extensions/${sector.slug}`}>
|
||||
<Card className="group hover:border-primary/30 transition-colors cursor-pointer">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10 flex-shrink-0">
|
||||
<Icon className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-medium group-hover:text-primary transition-colors">
|
||||
{sector.name}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{sector.description}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1.5">
|
||||
{sector.extensions.length} tillägg
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,864 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,613 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,989 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,875 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
'use client'
|
||||
|
||||
import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
|
||||
import EmptyExtensionState from '@/components/extensions/shared/EmptyExtensionState'
|
||||
import { Sparkles } from 'lucide-react'
|
||||
|
||||
export default function AiCategorizationWorkspace({ userId }: WorkspaceComponentProps) {
|
||||
return (
|
||||
<EmptyExtensionState
|
||||
title="AI-kategorisering"
|
||||
description="AI-kategorisering k\u00f6rs automatiskt n\u00e4r nya transaktioner synkas. G\u00e5 till Transaktioner f\u00f6r att se f\u00f6rslag."
|
||||
icon={<Sparkles className="h-12 w-12 text-muted-foreground/40 mb-4" />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
'use client'
|
||||
|
||||
import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
|
||||
import EmptyExtensionState from '@/components/extensions/shared/EmptyExtensionState'
|
||||
import { MessageSquare } from 'lucide-react'
|
||||
|
||||
export default function AiChatWorkspace({ userId }: WorkspaceComponentProps) {
|
||||
return (
|
||||
<EmptyExtensionState
|
||||
title="AI-assistent"
|
||||
description="Anv\u00e4nd chattwidgeten i nedre h\u00f6gra h\u00f6rnet f\u00f6r att st\u00e4lla fr\u00e5gor om bokf\u00f6ring och skatt."
|
||||
icon={<MessageSquare className="h-12 w-12 text-muted-foreground/40 mb-4" />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
'use client'
|
||||
|
||||
import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
|
||||
import EmptyExtensionState from '@/components/extensions/shared/EmptyExtensionState'
|
||||
import { Landmark } from 'lucide-react'
|
||||
|
||||
export default function EnableBankingWorkspace({ userId }: WorkspaceComponentProps) {
|
||||
return (
|
||||
<EmptyExtensionState
|
||||
title="Bankintegration (PSD2)"
|
||||
description="Koppla ditt bankkonto under Inst\u00e4llningar f\u00f6r att synka transaktioner automatiskt."
|
||||
icon={<Landmark className="h-12 w-12 text-muted-foreground/40 mb-4" />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
'use client'
|
||||
|
||||
import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
|
||||
import EmptyExtensionState from '@/components/extensions/shared/EmptyExtensionState'
|
||||
import { Bell } from 'lucide-react'
|
||||
|
||||
export default function PushNotificationsWorkspace({ userId }: WorkspaceComponentProps) {
|
||||
return (
|
||||
<EmptyExtensionState
|
||||
title="Push-notiser"
|
||||
description="Konfigurera push-notiser under Inst\u00e4llningar. Notiser skickas automatiskt vid viktiga h\u00e4ndelser."
|
||||
icon={<Bell className="h-12 w-12 text-muted-foreground/40 mb-4" />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
'use client'
|
||||
|
||||
import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
|
||||
import EmptyExtensionState from '@/components/extensions/shared/EmptyExtensionState'
|
||||
import { Camera } from 'lucide-react'
|
||||
|
||||
export default function ReceiptOcrWorkspace({ userId }: WorkspaceComponentProps) {
|
||||
return (
|
||||
<EmptyExtensionState
|
||||
title="Kvittoscanning"
|
||||
description="Ladda upp och skanna kvitton direkt fr\u00e5n till\u00e4ggets arbetsyta. G\u00e5 till Kvitton i sidomenyn f\u00f6r att komma ig\u00e5ng."
|
||||
icon={<Camera className="h-12 w-12 text-muted-foreground/40 mb-4" />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,578 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,588 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,817 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,552 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,696 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,844 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Loader2, AlertTriangle } from 'lucide-react'
|
||||
|
||||
interface ConfirmDeleteDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
title?: string
|
||||
description?: string
|
||||
onConfirm: () => void | Promise<void>
|
||||
isDeleting?: boolean
|
||||
}
|
||||
|
||||
export default function ConfirmDeleteDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
title = 'Bekrafta borttagning',
|
||||
description = 'Ar du saker pa att du vill ta bort detta? Atgarden kan inte angras.',
|
||||
onConfirm,
|
||||
isDeleting = false,
|
||||
}: ConfirmDeleteDialogProps) {
|
||||
const handleConfirm = async () => {
|
||||
await onConfirm()
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-red-100 dark:bg-red-950/30">
|
||||
<AlertTriangle className="h-5 w-5 text-red-600 dark:text-red-400" />
|
||||
</div>
|
||||
<div>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isDeleting}>
|
||||
Avbryt
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleConfirm} disabled={isDeleting}>
|
||||
{isDeleting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Tar bort...
|
||||
</>
|
||||
) : (
|
||||
'Ta bort'
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback } from 'react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Upload, FileText, Check } from 'lucide-react'
|
||||
|
||||
interface CsvImportWizardProps {
|
||||
targetFields: { key: string; label: string; required?: boolean }[]
|
||||
defaultMappings?: Record<string, string>
|
||||
onImport: (rows: Record<string, string>[]) => Promise<void>
|
||||
className?: string
|
||||
}
|
||||
|
||||
function parseCsv(text: string): { headers: string[]; rows: string[][] } {
|
||||
const lines = text.split(/\r?\n/).filter(line => line.trim())
|
||||
if (lines.length === 0) return { headers: [], rows: [] }
|
||||
|
||||
const separator = lines[0].includes(';') ? ';' : ','
|
||||
const headers = lines[0].split(separator).map(h => h.trim().replace(/^"(.*)"$/, '$1'))
|
||||
const rows = lines.slice(1).map(line =>
|
||||
line.split(separator).map(cell => cell.trim().replace(/^"(.*)"$/, '$1'))
|
||||
)
|
||||
return { headers, rows }
|
||||
}
|
||||
|
||||
export default function CsvImportWizard({
|
||||
targetFields,
|
||||
defaultMappings,
|
||||
onImport,
|
||||
className,
|
||||
}: CsvImportWizardProps) {
|
||||
const [step, setStep] = useState<1 | 2 | 3>(1)
|
||||
const [headers, setHeaders] = useState<string[]>([])
|
||||
const [rows, setRows] = useState<string[][]>([])
|
||||
const [mappings, setMappings] = useState<Record<string, string>>({})
|
||||
const [isImporting, setIsImporting] = useState(false)
|
||||
const [importCount, setImportCount] = useState(0)
|
||||
const [fileName, setFileName] = useState('')
|
||||
|
||||
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
setFileName(file.name)
|
||||
|
||||
const reader = new FileReader()
|
||||
reader.onload = (ev) => {
|
||||
const text = ev.target?.result as string
|
||||
const parsed = parseCsv(text)
|
||||
setHeaders(parsed.headers)
|
||||
setRows(parsed.rows)
|
||||
|
||||
// Auto-map using defaults
|
||||
const autoMappings: Record<string, string> = {}
|
||||
for (const field of targetFields) {
|
||||
const defaultCsv = defaultMappings?.[field.key]
|
||||
if (defaultCsv && parsed.headers.includes(defaultCsv)) {
|
||||
autoMappings[field.key] = defaultCsv
|
||||
} else {
|
||||
const match = parsed.headers.find(
|
||||
h => h.toLowerCase() === field.key.toLowerCase() ||
|
||||
h.toLowerCase() === field.label.toLowerCase()
|
||||
)
|
||||
if (match) autoMappings[field.key] = match
|
||||
}
|
||||
}
|
||||
setMappings(autoMappings)
|
||||
setStep(2)
|
||||
}
|
||||
reader.readAsText(file)
|
||||
}, [targetFields, defaultMappings])
|
||||
|
||||
const handleImport = async () => {
|
||||
setIsImporting(true)
|
||||
try {
|
||||
const mappedRows = rows.map(row => {
|
||||
const obj: Record<string, string> = {}
|
||||
for (const [fieldKey, csvCol] of Object.entries(mappings)) {
|
||||
const colIdx = headers.indexOf(csvCol)
|
||||
if (colIdx >= 0 && row[colIdx]) {
|
||||
obj[fieldKey] = row[colIdx]
|
||||
}
|
||||
}
|
||||
return obj
|
||||
}).filter(row => Object.keys(row).length > 0)
|
||||
|
||||
await onImport(mappedRows)
|
||||
setImportCount(mappedRows.length)
|
||||
setStep(3)
|
||||
} finally {
|
||||
setIsImporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
setStep(1)
|
||||
setHeaders([])
|
||||
setRows([])
|
||||
setMappings({})
|
||||
setFileName('')
|
||||
setImportCount(0)
|
||||
}
|
||||
|
||||
const requiredFieldsMapped = targetFields
|
||||
.filter(f => f.required)
|
||||
.every(f => mappings[f.key])
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
{step === 1 && <><Upload className="h-4 w-4" /> Steg 1: Valj fil</>}
|
||||
{step === 2 && <><FileText className="h-4 w-4" /> Steg 2: Kolumnmappning</>}
|
||||
{step === 3 && <><Check className="h-4 w-4" /> Import klar</>}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{step === 1 && (
|
||||
<div className="space-y-4">
|
||||
<div className="border-2 border-dashed rounded-lg p-8 text-center">
|
||||
<Upload className="h-8 w-8 mx-auto mb-2 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
Valj en CSV-fil att importera
|
||||
</p>
|
||||
<Label htmlFor="csv-upload" className="cursor-pointer">
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<span>Valj fil</span>
|
||||
</Button>
|
||||
</Label>
|
||||
<input
|
||||
id="csv-upload"
|
||||
type="file"
|
||||
accept=".csv,.txt"
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{fileName} - {rows.length} rader hittades. Mappa kolumner:
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
{targetFields.map(field => (
|
||||
<div key={field.key} className="flex items-center gap-3">
|
||||
<Label className="w-36 text-sm shrink-0">
|
||||
{field.label}{field.required && ' *'}
|
||||
</Label>
|
||||
<Select
|
||||
value={mappings[field.key] ?? ''}
|
||||
onValueChange={(val) => setMappings(prev => ({ ...prev, [field.key]: val }))}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Valj kolumn..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{headers.map(h => (
|
||||
<SelectItem key={h} value={h}>{h}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{rows.length > 0 && (
|
||||
<div className="rounded-lg border overflow-auto max-h-48">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{headers.map(h => (
|
||||
<TableHead key={h} className="text-xs whitespace-nowrap">{h}</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.slice(0, 5).map((row, i) => (
|
||||
<TableRow key={i}>
|
||||
{row.map((cell, j) => (
|
||||
<TableCell key={j} className="text-xs whitespace-nowrap">{cell}</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={reset}>
|
||||
Tillbaka
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleImport}
|
||||
disabled={!requiredFieldsMapped || isImporting}
|
||||
>
|
||||
{isImporting ? 'Importerar...' : `Importera ${rows.length} rader`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<div className="text-center py-4">
|
||||
<div className="inline-flex items-center justify-center w-12 h-12 rounded-full bg-green-100 mb-3">
|
||||
<Check className="h-6 w-6 text-green-600" />
|
||||
</div>
|
||||
<p className="font-medium">{importCount} rader importerades</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Fran {fileName}
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={reset} className="mt-4">
|
||||
Importera fler
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
'use client'
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface DataEntryFormProps {
|
||||
title: string
|
||||
onSubmit: (e: React.FormEvent) => void
|
||||
submitLabel?: string
|
||||
isSubmitting?: boolean
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function DataEntryForm({
|
||||
title,
|
||||
onSubmit,
|
||||
submitLabel = 'Spara',
|
||||
isSubmitting = false,
|
||||
children,
|
||||
className,
|
||||
}: DataEntryFormProps) {
|
||||
return (
|
||||
<Card className={cn('', className)}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
{children}
|
||||
<Button type="submit" disabled={isSubmitting} size="sm">
|
||||
{isSubmitting ? 'Sparar...' : submitLabel}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type Period = 'month' | 'quarter' | 'year' | 'custom'
|
||||
|
||||
interface DateRangeFilterProps {
|
||||
onRangeChange: (start: string, end: string) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function DateRangeFilter({ onRangeChange, className }: DateRangeFilterProps) {
|
||||
const [activePeriod, setActivePeriod] = useState<Period>('month')
|
||||
|
||||
const now = new Date()
|
||||
|
||||
const handlePeriod = (period: Period) => {
|
||||
setActivePeriod(period)
|
||||
const year = now.getFullYear()
|
||||
const month = now.getMonth()
|
||||
|
||||
switch (period) {
|
||||
case 'month': {
|
||||
const start = new Date(year, month, 1).toISOString().slice(0, 10)
|
||||
const end = new Date(year, month + 1, 0).toISOString().slice(0, 10)
|
||||
onRangeChange(start, end)
|
||||
break
|
||||
}
|
||||
case 'quarter': {
|
||||
const qStart = Math.floor(month / 3) * 3
|
||||
const start = new Date(year, qStart, 1).toISOString().slice(0, 10)
|
||||
const end = new Date(year, qStart + 3, 0).toISOString().slice(0, 10)
|
||||
onRangeChange(start, end)
|
||||
break
|
||||
}
|
||||
case 'year': {
|
||||
onRangeChange(`${year}-01-01`, `${year}-12-31`)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const periods: { key: Period; label: string }[] = [
|
||||
{ key: 'month', label: 'M\u00e5nad' },
|
||||
{ key: 'quarter', label: 'Kvartal' },
|
||||
{ key: 'year', label: '\u00c5r' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center gap-1', className)}>
|
||||
{periods.map(({ key, label }) => (
|
||||
<Button
|
||||
key={key}
|
||||
variant={activePeriod === key ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => handlePeriod(key)}
|
||||
className="text-xs"
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
'use client'
|
||||
|
||||
import { ReactNode } from 'react'
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
|
||||
interface EditEntryDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
title: string
|
||||
description?: string
|
||||
onSave: () => void | Promise<void>
|
||||
isSaving?: boolean
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export default function EditEntryDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
onSave,
|
||||
isSaving = false,
|
||||
children,
|
||||
}: EditEntryDialogProps) {
|
||||
const handleSave = async () => {
|
||||
await onSave()
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
{description && <DialogDescription>{description}</DialogDescription>}
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
{children}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSaving}>
|
||||
Avbryt
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={isSaving}>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Sparar...
|
||||
</>
|
||||
) : (
|
||||
'Spara'
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Puzzle } from 'lucide-react'
|
||||
|
||||
interface EmptyExtensionStateProps {
|
||||
title?: string
|
||||
description?: string
|
||||
icon?: React.ReactNode
|
||||
}
|
||||
|
||||
export default function EmptyExtensionState({
|
||||
title = 'Ingen data \u00e4nnu',
|
||||
description = 'Data kommer att visas h\u00e4r n\u00e4r det finns tillg\u00e4ngligt.',
|
||||
icon,
|
||||
}: EmptyExtensionStateProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
{icon ?? <Puzzle className="h-12 w-12 text-muted-foreground/40 mb-4" />}
|
||||
<h3 className="text-lg font-medium text-foreground">{title}</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1 max-w-md">{description}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
export default function ExtensionLoadingSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* KPI cards skeleton */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
{[1, 2, 3].map(i => (
|
||||
<div key={i} className="rounded-xl border p-6 space-y-2">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-8 w-16" />
|
||||
<Skeleton className="h-3 w-32" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Content skeleton */}
|
||||
<div className="rounded-xl border p-6 space-y-4">
|
||||
<Skeleton className="h-6 w-48" />
|
||||
<Skeleton className="h-40 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
'use client'
|
||||
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface KPICardProps {
|
||||
label: string
|
||||
value: string | number
|
||||
suffix?: string
|
||||
trend?: { value: number; label: string }
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function KPICard({ label, value, suffix, trend, className }: KPICardProps) {
|
||||
return (
|
||||
<Card className={cn('', className)}>
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-sm text-muted-foreground">{label}</p>
|
||||
<div className="flex items-baseline gap-1 mt-1">
|
||||
<span className="text-2xl font-semibold tracking-tight">{value}</span>
|
||||
{suffix && <span className="text-sm text-muted-foreground">{suffix}</span>}
|
||||
</div>
|
||||
{trend && (
|
||||
<p className={cn(
|
||||
'text-xs mt-1',
|
||||
trend.value > 0 ? 'text-green-600' : trend.value < 0 ? 'text-red-600' : 'text-muted-foreground'
|
||||
)}>
|
||||
{trend.value > 0 ? '+' : ''}{trend.value}% {trend.label}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface MonthlyRow {
|
||||
month: string
|
||||
value: number
|
||||
label?: string
|
||||
}
|
||||
|
||||
interface MonthlyTrendTableProps {
|
||||
rows: MonthlyRow[]
|
||||
valueLabel?: string
|
||||
valueSuffix?: string
|
||||
formatValue?: (v: number) => string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function MonthlyTrendTable({
|
||||
rows,
|
||||
valueLabel = 'Belopp',
|
||||
valueSuffix = 'kr',
|
||||
formatValue,
|
||||
className,
|
||||
}: MonthlyTrendTableProps) {
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<div className={cn('rounded-xl border p-6 text-center text-sm text-muted-foreground', className)}>
|
||||
Ingen data att visa
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const maxValue = Math.max(...rows.map(r => Math.abs(r.value)), 1)
|
||||
const fmt = formatValue ?? ((v: number) => v.toLocaleString('sv-SE'))
|
||||
|
||||
return (
|
||||
<div className={cn('rounded-xl border', className)}>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Period</TableHead>
|
||||
<TableHead className="text-right">{valueLabel}</TableHead>
|
||||
<TableHead className="w-[40%]"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((row) => {
|
||||
const barWidth = Math.round((Math.abs(row.value) / maxValue) * 100)
|
||||
return (
|
||||
<TableRow key={row.month}>
|
||||
<TableCell className="font-medium">{row.label ?? row.month}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{fmt(row.value)} {valueSuffix}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="h-4 w-full rounded-sm bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-sm bg-primary/60 transition-all"
|
||||
style={{ width: `${barWidth}%` }}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
'use client'
|
||||
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Settings } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
|
||||
interface SetupField {
|
||||
key: string
|
||||
label: string
|
||||
type?: 'number' | 'text'
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
interface SetupPromptProps {
|
||||
title: string
|
||||
description: string
|
||||
fields: SetupField[]
|
||||
onSave: (values: Record<string, string>) => Promise<void>
|
||||
}
|
||||
|
||||
export default function SetupPrompt({ title, description, fields, onSave }: SetupPromptProps) {
|
||||
const [values, setValues] = useState<Record<string, string>>({})
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsSaving(true)
|
||||
try {
|
||||
await onSave(values)
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const allFilled = fields.every(f => values[f.key]?.trim())
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center mb-6">
|
||||
<div className="inline-flex items-center justify-center w-12 h-12 rounded-full bg-muted mb-3">
|
||||
<Settings className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="font-semibold">{title}</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">{description}</p>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{fields.map(field => (
|
||||
<div key={field.key} className="space-y-2">
|
||||
<Label htmlFor={`setup-${field.key}`}>{field.label}</Label>
|
||||
<Input
|
||||
id={`setup-${field.key}`}
|
||||
type={field.type ?? 'text'}
|
||||
placeholder={field.placeholder}
|
||||
value={values[field.key] ?? ''}
|
||||
onChange={e => setValues(prev => ({ ...prev, [field.key]: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<Button type="submit" className="w-full" disabled={!allFilled || isSaving}>
|
||||
{isSaving ? 'Sparar...' : 'Kom igang'}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,965 @@
|
||||
'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
@@ -2,7 +2,7 @@
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { getVatTreatmentLabel } from '@/lib/invoice/vat-rules'
|
||||
import { getVatTreatmentLabel } from '@/lib/invoices/vat-rules'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
import type { Customer, Currency, VatTreatment } from '@/types'
|
||||
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { SECTORS } from '@/lib/extensions/sectors'
|
||||
import { resolveIcon } from '@/lib/extensions/icon-resolver'
|
||||
import { Briefcase, ArrowRight, Loader2 } from 'lucide-react'
|
||||
|
||||
interface Step2SectorSelectionProps {
|
||||
onNext: (data: { sector_slug: string | null }) => void
|
||||
onBack: () => void
|
||||
isSaving: boolean
|
||||
}
|
||||
|
||||
export default function Step2SectorSelection({ onNext, onBack, isSaving }: Step2SectorSelectionProps) {
|
||||
const [selected, setSelected] = useState<string | null>(null)
|
||||
|
||||
const industrySectors = SECTORS.filter(s => s.slug !== 'general')
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Vilken bransch verkar du inom?</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Vi anpassar verktyg och tillägg baserat på din bransch. Du kan alltid ändra detta senare.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{industrySectors.map(sector => {
|
||||
const Icon = resolveIcon(sector.icon)
|
||||
const isSelected = selected === sector.slug
|
||||
return (
|
||||
<Card
|
||||
key={sector.slug}
|
||||
className={cn(
|
||||
'cursor-pointer transition-all',
|
||||
isSelected
|
||||
? 'border-primary ring-2 ring-primary/20'
|
||||
: 'hover:border-primary/50'
|
||||
)}
|
||||
onClick={() => setSelected(sector.slug)}
|
||||
>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={cn(
|
||||
'flex h-10 w-10 items-center justify-center rounded-lg flex-shrink-0',
|
||||
isSelected ? 'bg-primary/10 text-primary' : 'bg-muted/50 text-muted-foreground'
|
||||
)}>
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">{sector.name}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{sector.description}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{sector.extensions.length} branschverktyg
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* "Other" option */}
|
||||
<Card
|
||||
className={cn(
|
||||
'cursor-pointer transition-all',
|
||||
selected === 'other'
|
||||
? 'border-primary ring-2 ring-primary/20'
|
||||
: 'hover:border-primary/50'
|
||||
)}
|
||||
onClick={() => setSelected('other')}
|
||||
>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={cn(
|
||||
'flex h-10 w-10 items-center justify-center rounded-lg flex-shrink-0',
|
||||
selected === 'other' ? 'bg-primary/10 text-primary' : 'bg-muted/50 text-muted-foreground'
|
||||
)}>
|
||||
<Briefcase className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">Annan bransch</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Generella verktyg utan branschspecifika tillägg
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" onClick={onBack}>
|
||||
Tillbaka
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => onNext({ sector_slug: null })}>
|
||||
Hoppa över
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => onNext({ sector_slug: selected === 'other' ? null : selected })}
|
||||
disabled={!selected || isSaving}
|
||||
>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Sparar...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Nästa
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { getSector } from '@/lib/extensions/sectors'
|
||||
import { resolveIcon } from '@/lib/extensions/icon-resolver'
|
||||
import CategoryBadge from '@/components/extensions/CategoryBadge'
|
||||
import { ArrowRight, Loader2 } from 'lucide-react'
|
||||
import type { SectorSlug, ExtensionDefinition } from '@/lib/extensions/types'
|
||||
|
||||
interface Step3Props {
|
||||
sectorSlug: string | null
|
||||
onNext: (data: { enabled_extensions: { sector_slug: string; extension_slug: string }[] }) => void
|
||||
onBack: () => void
|
||||
isSaving: boolean
|
||||
}
|
||||
|
||||
export default function Step3ExtensionSuggestions({ sectorSlug, onNext, onBack, isSaving }: Step3Props) {
|
||||
const generalSector = getSector('general')
|
||||
const selectedSector = sectorSlug ? getSector(sectorSlug as SectorSlug) : null
|
||||
|
||||
const [toggles, setToggles] = useState<Record<string, boolean>>({})
|
||||
|
||||
const handleToggle = (ext: ExtensionDefinition) => {
|
||||
const key = `${ext.sector}/${ext.slug}`
|
||||
setToggles(prev => ({ ...prev, [key]: !prev[key] }))
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
const enabled = Object.entries(toggles)
|
||||
.filter(([, enabled]) => enabled)
|
||||
.map(([key]) => {
|
||||
const [sector_slug, extension_slug] = key.split('/')
|
||||
return { sector_slug, extension_slug }
|
||||
})
|
||||
onNext({ enabled_extensions: enabled })
|
||||
}
|
||||
|
||||
const handleSkip = () => {
|
||||
onNext({ enabled_extensions: [] })
|
||||
}
|
||||
|
||||
// Group: first general, then sector-specific
|
||||
const sections: { label: string; extensions: ExtensionDefinition[] }[] = []
|
||||
if (generalSector) {
|
||||
sections.push({ label: generalSector.name, extensions: generalSector.extensions })
|
||||
}
|
||||
if (selectedSector) {
|
||||
sections.push({ label: selectedSector.name, extensions: selectedSector.extensions })
|
||||
}
|
||||
|
||||
const enabledCount = Object.values(toggles).filter(Boolean).length
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Välj dina tillägg</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Aktivera de verktyg du vill använda. Du kan alltid ändra detta senare under Tillägg.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{sections.map(section => (
|
||||
<div key={section.label}>
|
||||
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3">
|
||||
{section.label}
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{section.extensions.map(ext => {
|
||||
const Icon = resolveIcon(ext.icon)
|
||||
const key = `${ext.sector}/${ext.slug}`
|
||||
const isEnabled = toggles[key] ?? false
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={cn(
|
||||
'flex items-center justify-between gap-3 rounded-lg border p-3 transition-colors',
|
||||
isEnabled && 'border-primary/30 bg-primary/5'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className={cn(
|
||||
'flex h-9 w-9 items-center justify-center rounded-lg flex-shrink-0',
|
||||
isEnabled ? 'bg-primary/10 text-primary' : 'bg-muted/50 text-muted-foreground'
|
||||
)}>
|
||||
<Icon className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium">{ext.name}</p>
|
||||
<CategoryBadge category={ext.category} />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground line-clamp-1">{ext.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={isEnabled}
|
||||
onCheckedChange={() => handleToggle(ext)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="flex items-center justify-between pt-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" onClick={onBack}>
|
||||
Tillbaka
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={handleSkip}>
|
||||
Hoppa över
|
||||
</Button>
|
||||
</div>
|
||||
<Button onClick={handleSubmit} disabled={isSaving}>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Sparar...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{enabledCount > 0 ? `Nästa (${enabledCount} valda)` : 'Nästa'}
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import { ThemeProvider as NextThemesProvider } from 'next-themes'
|
||||
import type { ComponentProps } from 'react'
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
...props
|
||||
}: ComponentProps<typeof NextThemesProvider>) {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
|
||||
}
|
||||
@@ -8,7 +8,7 @@ const Card = React.forwardRef<
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-xl border border-border/50 bg-card text-card-foreground shadow-[0_1px_3px_rgba(30,25,20,0.04)]",
|
||||
"rounded-xl border border-border/50 bg-card text-card-foreground shadow-[var(--shadow-sm)]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
+521
@@ -0,0 +1,521 @@
|
||||
# Extension System — Design Document
|
||||
|
||||
## The App
|
||||
|
||||
erp-base is a Swedish accounting platform for sole traders (enskild firma) and limited companies (aktiebolag). It handles the legally required bookkeeping and financial management that every Swedish business needs.
|
||||
|
||||
## Core Functionality
|
||||
|
||||
The core is the standard accounting system. It's what every user gets out of the box — the features that exist in any accounting platform like Fortnox, Visma, or Björn Lundén. Nothing more, nothing less:
|
||||
|
||||
- Double-entry bookkeeping (journal entries, BAS chart of accounts, voucher numbering)
|
||||
- Invoicing (create, send, track, payment matching)
|
||||
- Supplier invoice management
|
||||
- Bank transaction reconciliation
|
||||
- Financial reports (income statement, balance sheet, trial balance, VAT declaration, general ledger)
|
||||
- Tax compliance (SRU export, NE-bilaga, tax deadline tracking)
|
||||
- Document archive with 7-year legal retention
|
||||
- Customer and supplier management
|
||||
|
||||
That's the core. It doesn't include receipt scanning, AI categorization, AI chat, push notifications, or PSD2 bank connection. Those are not standard accounting features — they're value-adds.
|
||||
|
||||
## Extensions
|
||||
|
||||
Extensions are **everything beyond the core accounting system**. They are self-contained tools that a user adds to their dashboard. No extensions are active by default — the user chooses which ones they want.
|
||||
|
||||
There are two kinds of extensions:
|
||||
|
||||
### General Extensions
|
||||
|
||||
General extensions are not tied to any specific business sector. They're useful for any business but they go beyond what a standard accounting system offers. They are optional — the user toggles them on from the marketplace.
|
||||
|
||||
Examples:
|
||||
- **Receipt OCR** — Scan receipts and extract data automatically
|
||||
- **AI Categorization** — AI-powered transaction categorization suggestions
|
||||
- **AI Chat** — AI assistant for tax and bookkeeping questions
|
||||
- **Push Notifications** — Event notifications for accounting activities
|
||||
- **Enable Banking** — PSD2 automatic bank transaction sync
|
||||
|
||||
These currently exist in the codebase at `extensions/` and are always loaded. They need to be migrated to the toggle system so users choose to enable them.
|
||||
|
||||
### Sector Extensions
|
||||
|
||||
Sector extensions are tied to a specific market sector. They're only relevant to businesses operating in that sector. A restaurant owner wants "Food Cost %" but an IT consultant does not.
|
||||
|
||||
Examples:
|
||||
- **Restaurant:** Food Cost %, Earnings Per Alcohol Liter, POS Z-Report Import, Tip Tracking
|
||||
- **Construction:** ROT Calculator, Project Cost Tracking
|
||||
- **Hotel:** RevPAR, Occupancy Tracking
|
||||
- **IT/Consulting:** Billable Hours Ratio, Project Billing Metrics
|
||||
- **E-commerce:** Shopify Order Import, Multi-channel Revenue Analytics
|
||||
|
||||
### The Unified Model
|
||||
|
||||
Both general and sector extensions live in the same system:
|
||||
|
||||
```
|
||||
extensions/
|
||||
general/ ← General extensions (any business)
|
||||
receipt-ocr/
|
||||
ai-categorization/
|
||||
ai-chat/
|
||||
push-notifications/
|
||||
enable-banking/
|
||||
restaurant/ ← Restaurant sector extensions
|
||||
food-cost/
|
||||
earnings-per-liter/
|
||||
pos-import/
|
||||
tip-tracking/
|
||||
construction/ ← Construction sector extensions
|
||||
rot-calculator/
|
||||
project-cost/
|
||||
hotel/ ← Hotel sector extensions
|
||||
revpar/
|
||||
occupancy/
|
||||
tech/ ← IT/Consulting sector extensions
|
||||
billable-hours/
|
||||
project-billing/
|
||||
ecommerce/ ← E-commerce sector extensions
|
||||
shopify-import/
|
||||
multichannel-revenue/
|
||||
```
|
||||
|
||||
In the marketplace:
|
||||
- General extensions are shown to everyone, always visible
|
||||
- Sector extensions are suggested based on the user's primary sector
|
||||
- But all extensions are browsable by everyone regardless of sector
|
||||
|
||||
In the sidebar under "Your Extensions":
|
||||
- Both general and sector extensions appear together
|
||||
- Whatever the user has enabled shows up here
|
||||
|
||||
---
|
||||
|
||||
## Design Decisions (Confirmed)
|
||||
|
||||
### 1. Extensions are self-contained — they do NOT write to the core accounting system
|
||||
|
||||
Extensions are **independent tools that live on the dashboard**. They are NOT part of the core accounting system. They have their own world, their own data, their own purpose. They never create journal entries, invoices, or modify any accounting records.
|
||||
|
||||
There are two one-way data flows into an extension. Data never flows back:
|
||||
|
||||
```
|
||||
Core Accounting Data ──→ Extension (reads it, displays it, uses it in calculations)
|
||||
User Manual Input ──→ Extension (stores it in extension's own data, processes it)
|
||||
Extension ──✗──→ Core Accounting (never writes back)
|
||||
```
|
||||
|
||||
An extension may:
|
||||
- **Be fed core data** — the platform feeds accounting data (journal entries, transactions, invoices) into the extension for it to read and use in calculations
|
||||
- **Accept user input** — the user submits data directly into the extension for data that doesn't exist in any accounting system (e.g. liters of alcohol sold per day, POS Z-report files, Shopify order exports)
|
||||
- **Store its own data** — extension-specific data lives in the extension's own storage, separate from core accounting
|
||||
- **Calculate and display** — combine core data + extension data to produce metrics, reports, insights
|
||||
|
||||
An extension may NOT:
|
||||
- Create journal entries
|
||||
- Create or modify invoices
|
||||
- Modify transactions or any core accounting table
|
||||
- Write back to the core accounting system in any way
|
||||
|
||||
This is a critical architectural constraint. Extensions are safe — enabling or disabling one can never corrupt or affect the accounting data. The core bookkeeping is a walled garden that extensions can look into but never modify.
|
||||
|
||||
**Important:** Features like POS Z-Report Import and Shopify Order Import are EXTENSIONS. They import data into the extension's own storage and provide analytics on that data. They do not create journal entries from imported data. The bookkeeping of POS data or Shopify orders is a separate activity the user does in the core platform.
|
||||
|
||||
### 2. Data source depends on the extension — three patterns
|
||||
|
||||
**Pattern A: Fed from core accounting data**
|
||||
Some extensions are fed existing bookkeeping data. For example, a "Food Cost %" extension reads journal entries for food purchase accounts (4000-series) and food revenue accounts (3000-series), then calculates and displays the metric. The user doesn't enter anything — the data already exists in the bookkeeping. These are extensions for data that Fortnox, Visma, and other accounting systems already have.
|
||||
|
||||
**Pattern B: User submits data manually**
|
||||
Some extensions need data that doesn't exist in any accounting system. No system tracks liters of alcohol sold, or daily staff tips, or room occupancy counts. For these extensions, the user manually submits data into the extension's workspace. The extension stores, processes, and displays this data. This has nothing to do with the core accounting functionality.
|
||||
|
||||
**Pattern C: Both**
|
||||
Some extensions combine core accounting data with user-submitted data. "Earnings Per Alcohol Liter" reads alcohol revenue from the bookkeeping (Pattern A) and takes user-entered liter counts (Pattern B) to calculate revenue per liter.
|
||||
|
||||
### 3. Full marketplace for post-onboarding management
|
||||
|
||||
After onboarding, users have a dedicated "Extensions" marketplace page where they can:
|
||||
- Browse all available extensions (general + all sectors)
|
||||
- Read descriptions and details
|
||||
- Toggle extensions on/off at any time
|
||||
- Discover extensions from sectors other than their primary one
|
||||
|
||||
### 4. Primary sector with cross-sector browsing
|
||||
|
||||
During onboarding, the user selects a **primary sector** (e.g. "Restaurant & Cafe"). The app then suggests extensions for that sector, plus general extensions. But the user is NOT locked in — they can browse and enable extensions from any sector at any time via the marketplace.
|
||||
|
||||
The primary sector serves as a **recommendation filter**, not a restriction.
|
||||
|
||||
### 5. First-party now, third-party later
|
||||
|
||||
We build all extensions ourselves initially. But the architecture should be clean and well-defined enough that external developers could eventually build extensions too. This means:
|
||||
- Clear extension interface/contract
|
||||
- Well-documented data access patterns
|
||||
- Self-contained extension structure (each extension is a standalone module)
|
||||
|
||||
---
|
||||
|
||||
## The User Experience
|
||||
|
||||
1. User signs up, goes through onboarding
|
||||
2. During onboarding, they select their business sector ("Restaurant & Cafe")
|
||||
3. The app suggests extensions: general extensions + extensions for that sector
|
||||
4. User toggles on the ones they want
|
||||
5. On the dashboard, the sidebar has a **"Your Extensions"** section listing all enabled extensions
|
||||
6. Clicking an extension opens its workspace — a dedicated page with the extension's own UI
|
||||
7. The user interacts with the extension: views data, enters inputs, sees calculations/reports
|
||||
8. User can browse the marketplace anytime to add/remove extensions
|
||||
|
||||
---
|
||||
|
||||
## Extension Definition
|
||||
|
||||
### What an Extension Contains
|
||||
|
||||
| Part | Required? | Description |
|
||||
|------|-----------|-------------|
|
||||
| **Metadata** | Yes | Name, description, sector (or 'general'), category, icon — for marketplace and sidebar |
|
||||
| **Workspace UI** | Yes | A React component — the main page the user sees when they click the extension |
|
||||
| **Extension data** | Depends | Storage for user-submitted data and extension state |
|
||||
| **Configuration** | Optional | Settings panel for customizing the extension's behavior |
|
||||
| **Core data queries** | Optional | Queries that read from journal entries, transactions, invoices, etc. |
|
||||
|
||||
### Extension Interface
|
||||
|
||||
```typescript
|
||||
interface ExtensionDefinition {
|
||||
// Identity
|
||||
slug: string // URL-safe ID, unique within sector (e.g. 'earnings-per-liter')
|
||||
name: string // Display name (e.g. 'Earnings Per Alcohol Liter')
|
||||
sector: string // 'general' | 'restaurant' | 'construction' | 'hotel' | 'tech' | 'ecommerce'
|
||||
category: ExtensionCategory // 'accounting' | 'reports' | 'import' | 'operations'
|
||||
|
||||
// Display (for marketplace and sidebar)
|
||||
description: string // One-line description
|
||||
longDescription: string // Detailed description with features
|
||||
icon: string // Lucide icon name
|
||||
entityTypes?: EntityType[] // Supported entity types (default: both EF and AB)
|
||||
|
||||
// Data patterns
|
||||
dataPattern: 'core' | 'manual' | 'both' // How the extension gets its data
|
||||
readsCoreTables?: string[] // Which core tables this extension reads (for pattern A/C)
|
||||
hasOwnData?: boolean // Whether users submit data into this extension (for pattern B/C)
|
||||
}
|
||||
```
|
||||
|
||||
### Sector Definition
|
||||
|
||||
```typescript
|
||||
interface Sector {
|
||||
slug: string // 'general' | 'restaurant' | 'construction' | etc.
|
||||
name: string // 'General' | 'Restaurant & Cafe' | etc.
|
||||
icon: string // Lucide icon name
|
||||
description: string // Short tagline
|
||||
extensions: ExtensionDefinition[]
|
||||
}
|
||||
```
|
||||
|
||||
### Extension Categories
|
||||
|
||||
```typescript
|
||||
type ExtensionCategory = 'accounting' | 'reports' | 'import' | 'operations'
|
||||
```
|
||||
|
||||
| Category | Color | Purpose |
|
||||
|----------|-------|---------|
|
||||
| Accounting & Tax | Red | Calculations related to bookkeeping, VAT, deductions |
|
||||
| Industry Reports | Blue | KPIs, analytics, metrics |
|
||||
| Smart Import | Green | Parse and import data from external tools |
|
||||
| Operational Tools | Gray | Day-to-day business tools |
|
||||
|
||||
---
|
||||
|
||||
## Concrete Examples
|
||||
|
||||
| Extension | Sector | Data Pattern | User Input | Reads Core Data | What it Does |
|
||||
|-----------|--------|--------------|------------|-----------------|--------------|
|
||||
| Receipt OCR | General | B (manual) | Uploads receipt images | None | Scans receipts, extracts merchant/amount/VAT data |
|
||||
| AI Categorization | General | A (core) | None | Uncategorized transactions | Suggests BAS account categories using AI |
|
||||
| Enable Banking | General | B (manual) | Bank connection setup | None | Syncs bank transactions via PSD2 |
|
||||
| Earnings Per Alcohol Liter | Restaurant | A + B (both) | Liters sold per day/week | Alcohol revenue from BAS 3001 | Calculates revenue/liter, trends over time |
|
||||
| Food Cost % | Restaurant | A (core) | None | Food purchases (4000-series), food revenue (3000-series) | Calculates food_cost/food_revenue %, trends |
|
||||
| Tip Tracking | Restaurant | B (manual) | Tip amounts per shift | Optionally reads staff cost accounts | Total tips, tips/employee, tip % of revenue |
|
||||
| POS Z-Report Import | Restaurant | B (manual) | Uploads Z-report CSV/Excel | None | Parses POS data, stores in extension, shows daily sales analytics |
|
||||
| Shopify Order Import | E-commerce | B (manual) | Uploads order export | None | Imports orders into extension, shows revenue by product, trends |
|
||||
| ROT Calculator | Construction | A + B (both) | Labor hours, material costs per job | Invoice data for customer billing | ROT deduction amounts (30% of labor, max 50k/year per customer) |
|
||||
| RevPAR | Hotel | A + B (both) | Room count and occupancy | Room revenue accounts | Revenue Per Available Room, occupancy rate |
|
||||
| Billable Hours Ratio | IT/Consulting | A + B (both) | Hours worked per project | Invoice data for billed amounts | Billable/total hours, effective hourly rate |
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### Where Things Live
|
||||
|
||||
```
|
||||
extensions/
|
||||
general/ ← General extensions
|
||||
receipt-ocr/
|
||||
index.ts ← Extension definition + logic
|
||||
lib/
|
||||
__tests__/
|
||||
ai-categorization/
|
||||
index.ts
|
||||
lib/
|
||||
ai-chat/
|
||||
index.ts
|
||||
lib/
|
||||
push-notifications/
|
||||
index.ts
|
||||
lib/
|
||||
enable-banking/
|
||||
index.ts
|
||||
lib/
|
||||
restaurant/ ← Restaurant sector
|
||||
earnings-per-liter/
|
||||
index.ts
|
||||
lib/
|
||||
food-cost/
|
||||
index.ts
|
||||
lib/
|
||||
pos-import/
|
||||
index.ts
|
||||
lib/
|
||||
tip-tracking/
|
||||
index.ts
|
||||
lib/
|
||||
construction/ ← Construction sector
|
||||
rot-calculator/
|
||||
index.ts
|
||||
lib/
|
||||
hotel/ ← Hotel sector
|
||||
revpar/
|
||||
index.ts
|
||||
lib/
|
||||
tech/ ← IT/Consulting sector
|
||||
billable-hours/
|
||||
index.ts
|
||||
lib/
|
||||
ecommerce/ ← E-commerce sector
|
||||
shopify-import/
|
||||
index.ts
|
||||
lib/
|
||||
|
||||
lib/
|
||||
extensions/
|
||||
types.ts ← ExtensionDefinition, Sector, ExtensionCategory types
|
||||
sectors.ts ← Sector + extension metadata registry (source of truth)
|
||||
workspace-registry.tsx ← Maps sector/slug → lazy-loaded React component
|
||||
hooks.ts ← useExtensionToggle, useEnabledExtensions
|
||||
|
||||
components/
|
||||
extensions/
|
||||
ExtensionWorkspaceShell.tsx ← Shared layout wrapper
|
||||
shared/ ← Shared UI primitives
|
||||
KPICard.tsx
|
||||
DataEntryForm.tsx
|
||||
DateRangeFilter.tsx
|
||||
EmptyExtensionState.tsx
|
||||
ExtensionLoadingSkeleton.tsx
|
||||
general/ ← General extension workspaces
|
||||
ReceiptOcrWorkspace.tsx
|
||||
AiCategorizationWorkspace.tsx
|
||||
AiChatWorkspace.tsx
|
||||
restaurant/ ← Restaurant extension workspaces
|
||||
EarningsPerLiterWorkspace.tsx
|
||||
FoodCostWorkspace.tsx
|
||||
PosImportWorkspace.tsx
|
||||
construction/
|
||||
RotCalculatorWorkspace.tsx
|
||||
hotel/
|
||||
RevparWorkspace.tsx
|
||||
tech/
|
||||
BillableHoursWorkspace.tsx
|
||||
ecommerce/
|
||||
ShopifyImportWorkspace.tsx
|
||||
|
||||
app/(dashboard)/
|
||||
extensions/ ← Marketplace
|
||||
page.tsx ← Extension hub (browse sectors + general)
|
||||
[sector]/
|
||||
page.tsx ← Extensions for a specific sector
|
||||
[extension]/
|
||||
page.tsx ← Extension detail + toggle
|
||||
e/ ← Extension workspaces
|
||||
[sector]/
|
||||
[slug]/
|
||||
page.tsx ← Renders the workspace component
|
||||
```
|
||||
|
||||
### Data Storage
|
||||
|
||||
Extensions store their data in the existing `extension_data` table:
|
||||
|
||||
```
|
||||
extension_data:
|
||||
user_id: auth user
|
||||
extension_id: 'restaurant/earnings-per-liter' (sector/slug format)
|
||||
key: 'settings' | 'entries' | 'config' | custom keys
|
||||
value: JSONB (flexible)
|
||||
```
|
||||
|
||||
For the "Earnings Per Liter" extension, data might look like:
|
||||
```
|
||||
key: 'settings' → { "defaultUnit": "liter", "currency": "SEK" }
|
||||
key: 'entries' → [{ "date": "2025-01-15", "liters": 42.5, "type": "spirits" }, ...]
|
||||
key: 'config' → { "revenueAccounts": ["3001"], "trackByType": true }
|
||||
```
|
||||
|
||||
### The Toggle System
|
||||
|
||||
New database table:
|
||||
|
||||
```sql
|
||||
create table extension_toggles (
|
||||
id uuid primary key default uuid_generate_v4(),
|
||||
user_id uuid not null references auth.users on delete cascade,
|
||||
sector_slug text not null, -- 'general' | 'restaurant' | 'construction' | etc.
|
||||
extension_slug text not null,
|
||||
enabled boolean not null default true,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
constraint extension_toggles_unique unique (user_id, sector_slug, extension_slug)
|
||||
);
|
||||
```
|
||||
|
||||
Also add to company_settings:
|
||||
```sql
|
||||
alter table company_settings add column sector_slug text;
|
||||
```
|
||||
|
||||
### API Routes for Extensions
|
||||
|
||||
Each extension that needs data persistence gets API routes:
|
||||
|
||||
```
|
||||
app/api/extensions/[sector]/[slug]/
|
||||
data/route.ts — GET (read entries), POST (submit new entry), DELETE (remove entry)
|
||||
settings/route.ts — GET (read settings), PATCH (update settings)
|
||||
```
|
||||
|
||||
These are simple CRUD routes that read/write to `extension_data`. They follow the existing API route pattern (auth check, RLS, user_id filtering).
|
||||
|
||||
### Sidebar Integration
|
||||
|
||||
The sidebar (`DashboardNav.tsx`) gets a new section: **"Your Extensions"**. It reads enabled extensions from `extension_toggles` and renders links:
|
||||
|
||||
```
|
||||
── Your Extensions ──────────
|
||||
📷 Receipt OCR → /e/general/receipt-ocr
|
||||
🤖 AI Categorization → /e/general/ai-categorization
|
||||
📊 Food Cost % → /e/restaurant/food-cost
|
||||
🍷 Earnings Per Liter → /e/restaurant/earnings-per-liter
|
||||
```
|
||||
|
||||
Each link goes to `/e/{sector}/{slug}` which renders the extension's workspace component.
|
||||
|
||||
### Onboarding Integration
|
||||
|
||||
Add two new steps to the onboarding flow (after entity type selection):
|
||||
|
||||
**Step 2: Sector Selection**
|
||||
"What type of business do you run?"
|
||||
Grid of sectors with icons and descriptions. User picks one.
|
||||
Stores `sector_slug` on `company_settings`.
|
||||
|
||||
**Step 3: Extension Suggestions**
|
||||
"Here are tools for your business. Pick the ones you want."
|
||||
Shows general extensions + extensions for the selected sector, grouped by category.
|
||||
User toggles desired extensions. Inserts into `extension_toggles`.
|
||||
Can be skipped — user can always add extensions later from the marketplace.
|
||||
|
||||
---
|
||||
|
||||
## The Extension Workspace Pattern
|
||||
|
||||
Every extension workspace follows the same pattern:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Extension Workspace Shell │
|
||||
│ ┌─────────────────────────────────────────────┐ │
|
||||
│ │ Header: Extension name + settings link │ │
|
||||
│ ├─────────────────────────────────────────────┤ │
|
||||
│ │ │ │
|
||||
│ │ Extension-specific UI │ │
|
||||
│ │ │ │
|
||||
│ │ This is where the extension does its thing │ │
|
||||
│ │ - Data entry forms │ │
|
||||
│ │ - KPI cards and charts │ │
|
||||
│ │ - Tables of submitted data │ │
|
||||
│ │ - Calculation results │ │
|
||||
│ │ - Date range filters │ │
|
||||
│ │ │ │
|
||||
│ └─────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
The `ExtensionWorkspaceShell` provides consistent chrome (header, breadcrumbs, settings link). The extension fills in the content area with whatever UI it needs.
|
||||
|
||||
### Example: Earnings Per Alcohol Liter
|
||||
|
||||
When the user clicks this extension, they see:
|
||||
|
||||
1. **KPI cards at top**: Current earnings/liter, trend vs last month, total liters this month
|
||||
2. **Data entry section**: Form to log daily sales (date, liters sold, alcohol type)
|
||||
3. **History table**: Past entries with edit/delete
|
||||
4. **Chart**: Earnings per liter over time (line chart)
|
||||
5. **Revenue breakdown**: Reads from core journal entries — alcohol revenue by account
|
||||
|
||||
The extension reads revenue data from journal_entry_lines (BAS 3001 for 25% alcohol revenue) and combines it with user-submitted liter data to calculate the metric.
|
||||
|
||||
---
|
||||
|
||||
## Migration from Current Architecture
|
||||
|
||||
The current codebase has receipt-ocr, ai-categorization, ai-chat, push-notifications, sru-export, ne-bilaga, and enable-banking implemented as always-on server-side plugins using the `Extension` interface, the extension registry, and the event bus.
|
||||
|
||||
These need to become general extensions in the new system:
|
||||
1. Move from `extensions/{name}/` to `extensions/general/{name}/`
|
||||
2. Add metadata (description, icon, category) to each
|
||||
3. Register them in the sector data registry under the `general` sector
|
||||
4. Create workspace components for each
|
||||
5. Make them toggleable via `extension_toggles` (instead of always loaded)
|
||||
6. The existing event bus integration and server-side behavior stays — general extensions may still use the event bus for background processing (e.g. ai-categorization reacting to transaction.synced). The toggle check becomes a gate in their event handlers.
|
||||
|
||||
Note: SRU export and NE-bilaga may remain as core features since they're legally required for Swedish accounting compliance, not optional value-adds. This is a decision to make during implementation.
|
||||
|
||||
---
|
||||
|
||||
## What Needs to Be Built
|
||||
|
||||
1. **Extension types** — ExtensionDefinition, Sector, ExtensionCategory in types
|
||||
2. **Sector data registry** — All sectors and extension metadata in code
|
||||
3. **Database migration** — extension_toggles table + sector_slug on company_settings
|
||||
4. **Toggle hooks** — useExtensionToggle, useEnabledExtensions (client-side)
|
||||
5. **Workspace component registry** — Maps sector/slug → lazy-loaded React component
|
||||
6. **Workspace routing** — `app/(dashboard)/e/[sector]/[slug]/page.tsx`
|
||||
7. **Workspace shell** — Shared layout wrapper component
|
||||
8. **Marketplace pages** — `app/(dashboard)/extensions/` for browsing and toggling
|
||||
9. **Sidebar "Your Extensions"** — Wire enabled extensions into DashboardNav
|
||||
10. **Onboarding steps** — Sector selection + extension suggestions
|
||||
11. **Shared UI components** — KPICard, DataEntryForm, DateRangeFilter, EmptyExtensionState
|
||||
12. **Extension API routes** — Generic CRUD for extension_data
|
||||
13. **Migrate general extensions** — Move current extensions to new toggle system
|
||||
14. **Build first sector extensions** — Starting with restaurant sector
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
**The app** is a Swedish accounting platform.
|
||||
|
||||
**Core functionality** is the standard accounting system: bookkeeping, invoicing, reports, tax, bank reconciliation. Every user gets this.
|
||||
|
||||
**Extensions** are everything beyond core accounting. They come in two kinds:
|
||||
- **General extensions** (receipt-ocr, ai-categorization, etc.) — useful for any business, not sector-specific
|
||||
- **Sector extensions** (food cost %, earnings per liter, etc.) — tied to a specific market sector
|
||||
|
||||
All extensions live in the same system, use the same toggle mechanism, appear in the same marketplace, and show up under "Your Extensions" in the sidebar. No extensions are active by default — the user chooses which ones to add.
|
||||
|
||||
Extensions are read-only with respect to the core accounting system. They can be fed accounting data, they can accept manual user input, but they never write back to the bookkeeping.
|
||||
@@ -0,0 +1,2 @@
|
||||
// Project Cost extension — Track costs per construction project
|
||||
export const PROJECT_COST_EXTENSION_ID = 'construction/project-cost'
|
||||
@@ -0,0 +1,186 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
calculateProjectStats,
|
||||
calculateCategoryBreakdown,
|
||||
filterByDateRange,
|
||||
getBudgetStatus,
|
||||
type CostEntry,
|
||||
type RevenueEntry,
|
||||
} from '../project-cost-calculator'
|
||||
|
||||
describe('calculateProjectStats', () => {
|
||||
it('calculates basic project stats correctly', () => {
|
||||
const costs: CostEntry[] = [
|
||||
{ projectId: 'p1', amount: 200000, date: '2025-03-01', category: 'materials' },
|
||||
{ projectId: 'p1', amount: 100000, date: '2025-03-15', category: 'labor' },
|
||||
]
|
||||
const revenues: RevenueEntry[] = [
|
||||
{ projectId: 'p1', amount: 500000, date: '2025-03-20' },
|
||||
]
|
||||
|
||||
const stats = calculateProjectStats(costs, revenues, 400000)
|
||||
|
||||
expect(stats.totalCost).toBe(300000)
|
||||
expect(stats.totalRevenue).toBe(500000)
|
||||
// margin: (500000 - 300000) / 500000 * 100 = 40
|
||||
expect(stats.margin).toBe(40)
|
||||
// budgetUsed: 300000 / 400000 * 100 = 75
|
||||
expect(stats.budgetUsed).toBe(75)
|
||||
expect(stats.budgetStatus).toBe('ok')
|
||||
})
|
||||
|
||||
it('returns margin 0 when revenue is zero', () => {
|
||||
const costs: CostEntry[] = [
|
||||
{ projectId: 'p1', amount: 50000, date: '2025-01-10', category: 'materials' },
|
||||
]
|
||||
const revenues: RevenueEntry[] = []
|
||||
|
||||
const stats = calculateProjectStats(costs, revenues, 100000)
|
||||
|
||||
expect(stats.totalCost).toBe(50000)
|
||||
expect(stats.totalRevenue).toBe(0)
|
||||
expect(stats.margin).toBe(0)
|
||||
})
|
||||
|
||||
it('returns negative margin when costs exceed revenue', () => {
|
||||
const costs: CostEntry[] = [
|
||||
{ projectId: 'p1', amount: 150000, date: '2025-02-01', category: 'labor' },
|
||||
]
|
||||
const revenues: RevenueEntry[] = [
|
||||
{ projectId: 'p1', amount: 100000, date: '2025-02-15' },
|
||||
]
|
||||
|
||||
const stats = calculateProjectStats(costs, revenues, 200000)
|
||||
|
||||
// margin: (100000 - 150000) / 100000 * 100 = -50
|
||||
expect(stats.margin).toBe(-50)
|
||||
})
|
||||
|
||||
it('caps budgetUsed at 100 when costs exceed budget', () => {
|
||||
const costs: CostEntry[] = [
|
||||
{ projectId: 'p1', amount: 210000, date: '2025-04-01', category: 'materials' },
|
||||
]
|
||||
const revenues: RevenueEntry[] = [
|
||||
{ projectId: 'p1', amount: 300000, date: '2025-04-10' },
|
||||
]
|
||||
|
||||
const stats = calculateProjectStats(costs, revenues, 200000)
|
||||
|
||||
// raw budgetUsed: 210000 / 200000 * 100 = 105, capped at 100
|
||||
expect(stats.budgetUsed).toBe(100)
|
||||
expect(stats.budgetStatus).toBe('danger')
|
||||
})
|
||||
|
||||
it('returns zero stats for empty arrays', () => {
|
||||
const stats = calculateProjectStats([], [], 100000)
|
||||
|
||||
expect(stats.totalCost).toBe(0)
|
||||
expect(stats.totalRevenue).toBe(0)
|
||||
expect(stats.margin).toBe(0)
|
||||
expect(stats.budgetUsed).toBe(0)
|
||||
expect(stats.budgetStatus).toBe('ok')
|
||||
})
|
||||
|
||||
it('handles monetary rounding edge cases', () => {
|
||||
const costs: CostEntry[] = [
|
||||
{ projectId: 'p1', amount: 33333.33, date: '2025-01-01', category: 'materials' },
|
||||
{ projectId: 'p1', amount: 33333.33, date: '2025-01-02', category: 'labor' },
|
||||
{ projectId: 'p1', amount: 33333.34, date: '2025-01-03', category: 'equipment' },
|
||||
]
|
||||
const revenues: RevenueEntry[] = [
|
||||
{ projectId: 'p1', amount: 200000, date: '2025-01-15' },
|
||||
]
|
||||
|
||||
const stats = calculateProjectStats(costs, revenues, 150000)
|
||||
|
||||
// totalCost: 33333.33 + 33333.33 + 33333.34 = 100000.00
|
||||
expect(stats.totalCost).toBe(100000)
|
||||
expect(stats.totalRevenue).toBe(200000)
|
||||
// margin: (200000 - 100000) / 200000 * 100 = 50
|
||||
expect(stats.margin).toBe(50)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getBudgetStatus', () => {
|
||||
it('returns ok when budget used is below 80%', () => {
|
||||
expect(getBudgetStatus(0)).toBe('ok')
|
||||
expect(getBudgetStatus(50)).toBe('ok')
|
||||
expect(getBudgetStatus(79.99)).toBe('ok')
|
||||
})
|
||||
|
||||
it('returns warning when budget used is between 80% and 99.99%', () => {
|
||||
expect(getBudgetStatus(80)).toBe('warning')
|
||||
expect(getBudgetStatus(85)).toBe('warning')
|
||||
expect(getBudgetStatus(99.99)).toBe('warning')
|
||||
})
|
||||
|
||||
it('returns danger when budget used is 100% or above', () => {
|
||||
expect(getBudgetStatus(100)).toBe('danger')
|
||||
expect(getBudgetStatus(105)).toBe('danger')
|
||||
expect(getBudgetStatus(200)).toBe('danger')
|
||||
})
|
||||
})
|
||||
|
||||
describe('calculateCategoryBreakdown', () => {
|
||||
it('breaks down costs by category with correct percentages', () => {
|
||||
const costs: CostEntry[] = [
|
||||
{ projectId: 'p1', amount: 60000, date: '2025-01-01', category: 'materials' },
|
||||
{ projectId: 'p1', amount: 30000, date: '2025-01-05', category: 'labor' },
|
||||
{ projectId: 'p1', amount: 10000, date: '2025-01-10', category: 'equipment' },
|
||||
]
|
||||
|
||||
const breakdown = calculateCategoryBreakdown(costs)
|
||||
|
||||
expect(breakdown).toHaveLength(3)
|
||||
// Sorted by amount descending
|
||||
expect(breakdown[0]).toEqual({ category: 'materials', amount: 60000, percent: 60 })
|
||||
expect(breakdown[1]).toEqual({ category: 'labor', amount: 30000, percent: 30 })
|
||||
expect(breakdown[2]).toEqual({ category: 'equipment', amount: 10000, percent: 10 })
|
||||
})
|
||||
|
||||
it('returns single category as 100%', () => {
|
||||
const costs: CostEntry[] = [
|
||||
{ projectId: 'p1', amount: 25000, date: '2025-01-01', category: 'labor' },
|
||||
{ projectId: 'p1', amount: 75000, date: '2025-01-05', category: 'labor' },
|
||||
]
|
||||
|
||||
const breakdown = calculateCategoryBreakdown(costs)
|
||||
|
||||
expect(breakdown).toHaveLength(1)
|
||||
expect(breakdown[0]).toEqual({ category: 'labor', amount: 100000, percent: 100 })
|
||||
})
|
||||
|
||||
it('returns empty array for empty costs', () => {
|
||||
const breakdown = calculateCategoryBreakdown([])
|
||||
|
||||
expect(breakdown).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('filterByDateRange', () => {
|
||||
const entries: CostEntry[] = [
|
||||
{ projectId: 'p1', amount: 1000, date: '2025-01-15', category: 'materials' },
|
||||
{ projectId: 'p1', amount: 2000, date: '2025-02-15', category: 'labor' },
|
||||
{ projectId: 'p1', amount: 3000, date: '2025-03-15', category: 'equipment' },
|
||||
]
|
||||
|
||||
it('includes entries within the date range', () => {
|
||||
const filtered = filterByDateRange(entries, '2025-01-01', '2025-02-28')
|
||||
|
||||
expect(filtered).toHaveLength(2)
|
||||
expect(filtered[0].date).toBe('2025-01-15')
|
||||
expect(filtered[1].date).toBe('2025-02-15')
|
||||
})
|
||||
|
||||
it('excludes entries outside the date range', () => {
|
||||
const filtered = filterByDateRange(entries, '2025-04-01', '2025-04-30')
|
||||
|
||||
expect(filtered).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('includes entries on boundary dates', () => {
|
||||
const filtered = filterByDateRange(entries, '2025-01-15', '2025-03-15')
|
||||
|
||||
expect(filtered).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Pure calculation functions for the Project Cost extension.
|
||||
*
|
||||
* Tracks costs, revenues, margins, and budget usage per construction project.
|
||||
* All monetary calculations use Math.round(x * 100) / 100 per project rules.
|
||||
*/
|
||||
|
||||
export interface CostEntry {
|
||||
projectId: string
|
||||
amount: number
|
||||
date: string
|
||||
category: string
|
||||
}
|
||||
|
||||
export interface RevenueEntry {
|
||||
projectId: string
|
||||
amount: number
|
||||
date: string
|
||||
}
|
||||
|
||||
export interface ProjectStats {
|
||||
totalCost: number
|
||||
totalRevenue: number
|
||||
margin: number // (revenue - cost) / revenue * 100, or 0 if no revenue
|
||||
budgetUsed: number // cost / budget * 100, capped at 100
|
||||
budgetStatus: 'ok' | 'warning' | 'danger' // ok < 80%, warning >= 80%, danger >= 100%
|
||||
}
|
||||
|
||||
export interface CategoryBreakdown {
|
||||
category: string
|
||||
amount: number
|
||||
percent: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine budget status based on percentage of budget used.
|
||||
* - ok: less than 80%
|
||||
* - warning: 80% to below 100%
|
||||
* - danger: 100% or above
|
||||
*/
|
||||
export function getBudgetStatus(budgetUsedPct: number): 'ok' | 'warning' | 'danger' {
|
||||
if (budgetUsedPct >= 100) return 'danger'
|
||||
if (budgetUsedPct >= 80) return 'warning'
|
||||
return 'ok'
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate project statistics from cost entries, revenue entries, and budget.
|
||||
*
|
||||
* - totalCost: sum of all cost entry amounts
|
||||
* - totalRevenue: sum of all revenue entry amounts
|
||||
* - margin: (revenue - cost) / revenue * 100, or 0 when revenue is zero
|
||||
* - budgetUsed: cost / budget * 100, capped at 100; 0 if budget <= 0
|
||||
* - budgetStatus: derived from budgetUsed via getBudgetStatus
|
||||
*/
|
||||
export function calculateProjectStats(
|
||||
costs: CostEntry[],
|
||||
revenues: RevenueEntry[],
|
||||
budget: number
|
||||
): ProjectStats {
|
||||
const totalCost = Math.round(
|
||||
costs.reduce((sum, c) => sum + c.amount, 0) * 100
|
||||
) / 100
|
||||
|
||||
const totalRevenue = Math.round(
|
||||
revenues.reduce((sum, r) => sum + r.amount, 0) * 100
|
||||
) / 100
|
||||
|
||||
const margin = totalRevenue > 0
|
||||
? Math.round(((totalRevenue - totalCost) / totalRevenue) * 10000) / 100
|
||||
: 0
|
||||
|
||||
const rawBudgetUsed = budget > 0
|
||||
? Math.round((totalCost / budget) * 10000) / 100
|
||||
: 0
|
||||
|
||||
const budgetUsed = Math.min(rawBudgetUsed, 100)
|
||||
const budgetStatus = getBudgetStatus(rawBudgetUsed)
|
||||
|
||||
return {
|
||||
totalCost,
|
||||
totalRevenue,
|
||||
margin,
|
||||
budgetUsed,
|
||||
budgetStatus,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate cost breakdown by category.
|
||||
*
|
||||
* Returns an array of categories sorted by amount descending,
|
||||
* each with the category name, total amount, and percentage of total cost.
|
||||
* Returns an empty array if there are no costs.
|
||||
*/
|
||||
export function calculateCategoryBreakdown(costs: CostEntry[]): CategoryBreakdown[] {
|
||||
if (costs.length === 0) return []
|
||||
|
||||
const totalCost = Math.round(
|
||||
costs.reduce((sum, c) => sum + c.amount, 0) * 100
|
||||
) / 100
|
||||
|
||||
const categoryMap = new Map<string, number>()
|
||||
for (const cost of costs) {
|
||||
const current = categoryMap.get(cost.category) ?? 0
|
||||
categoryMap.set(cost.category, current + cost.amount)
|
||||
}
|
||||
|
||||
const breakdown: CategoryBreakdown[] = []
|
||||
for (const [category, rawAmount] of categoryMap) {
|
||||
const amount = Math.round(rawAmount * 100) / 100
|
||||
const percent = totalCost > 0
|
||||
? Math.round((amount / totalCost) * 10000) / 100
|
||||
: 0
|
||||
breakdown.push({ category, amount, percent })
|
||||
}
|
||||
|
||||
breakdown.sort((a, b) => b.amount - a.amount)
|
||||
|
||||
return breakdown
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter entries by date range (inclusive on both ends).
|
||||
* Dates are compared as ISO date strings (YYYY-MM-DD).
|
||||
*/
|
||||
export function filterByDateRange<T extends { date: string }>(
|
||||
entries: T[],
|
||||
start: string,
|
||||
end: string
|
||||
): T[] {
|
||||
return entries.filter(e => e.date >= start && e.date <= end)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// ROT Calculator extension — Calculate ROT tax deductions for construction work
|
||||
export const ROT_CALCULATOR_EXTENSION_ID = 'construction/rot-calculator'
|
||||
@@ -0,0 +1,164 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
calculateRotDeduction,
|
||||
calculateCustomerQuotas,
|
||||
filterJobsByYear,
|
||||
generateRotCsvContent,
|
||||
MAX_ROT_YEARLY,
|
||||
ROT_RATE,
|
||||
type RotJob,
|
||||
} from '../rot-calculator'
|
||||
|
||||
describe('calculateRotDeduction', () => {
|
||||
it('calculates 30% of labor as ROT deduction', () => {
|
||||
const result = calculateRotDeduction(100000, 40000, 0)
|
||||
|
||||
// Labor = 100000 - 40000 = 60000
|
||||
// ROT = 60000 * 0.30 = 18000
|
||||
expect(result.labor).toBe(60000)
|
||||
expect(result.rotDeduction).toBe(18000)
|
||||
expect(result.customerPays).toBe(82000)
|
||||
expect(result.remainingQuota).toBe(MAX_ROT_YEARLY - 18000)
|
||||
})
|
||||
|
||||
it('caps ROT deduction at remaining yearly quota', () => {
|
||||
// Customer has already used 45000 of 50000 quota
|
||||
const result = calculateRotDeduction(100000, 40000, 45000)
|
||||
|
||||
// Labor = 60000, raw ROT = 18000, but only 5000 remaining
|
||||
expect(result.rotDeduction).toBe(5000)
|
||||
expect(result.customerPays).toBe(95000)
|
||||
expect(result.remainingQuota).toBe(0)
|
||||
})
|
||||
|
||||
it('returns zero deduction when labor is zero (material equals total)', () => {
|
||||
const result = calculateRotDeduction(50000, 50000, 0)
|
||||
|
||||
expect(result.labor).toBe(0)
|
||||
expect(result.rotDeduction).toBe(0)
|
||||
expect(result.customerPays).toBe(50000)
|
||||
expect(result.remainingQuota).toBe(MAX_ROT_YEARLY)
|
||||
})
|
||||
|
||||
it('returns zero deduction when quota is already exhausted', () => {
|
||||
const result = calculateRotDeduction(80000, 30000, MAX_ROT_YEARLY)
|
||||
|
||||
expect(result.labor).toBe(50000)
|
||||
expect(result.rotDeduction).toBe(0)
|
||||
expect(result.customerPays).toBe(80000)
|
||||
expect(result.remainingQuota).toBe(0)
|
||||
})
|
||||
|
||||
it('calculates customerPays as total minus rotDeduction', () => {
|
||||
const result = calculateRotDeduction(75000, 25000, 0)
|
||||
|
||||
// Labor = 50000, ROT = 15000
|
||||
expect(result.customerPays).toBe(75000 - result.rotDeduction)
|
||||
expect(result.customerPays).toBe(60000)
|
||||
})
|
||||
|
||||
it('handles monetary rounding correctly', () => {
|
||||
// total=10001, material=3333 -> labor=6668
|
||||
// ROT = 6668 * 0.30 = 2000.4
|
||||
const result = calculateRotDeduction(10001, 3333, 0)
|
||||
|
||||
expect(result.labor).toBe(6668)
|
||||
expect(result.rotDeduction).toBe(2000.4)
|
||||
expect(result.customerPays).toBe(8000.6)
|
||||
expect(result.remainingQuota).toBe(Math.round((MAX_ROT_YEARLY - 2000.4) * 100) / 100)
|
||||
})
|
||||
})
|
||||
|
||||
describe('calculateCustomerQuotas', () => {
|
||||
const baseJob: RotJob = {
|
||||
id: 'job-1',
|
||||
customerId: 'cust-1',
|
||||
total: 100000,
|
||||
material: 40000,
|
||||
labor: 60000,
|
||||
rotDeduction: 18000,
|
||||
date: '2025-03-15',
|
||||
status: 'completed',
|
||||
}
|
||||
|
||||
it('calculates used quota from completed jobs for a customer', () => {
|
||||
const jobs: RotJob[] = [
|
||||
{ ...baseJob, id: 'job-1', rotDeduction: 10000 },
|
||||
{ ...baseJob, id: 'job-2', rotDeduction: 15000 },
|
||||
]
|
||||
|
||||
const quotas = calculateCustomerQuotas(jobs, 2025)
|
||||
|
||||
expect(quotas.get('cust-1')).toBe(25000)
|
||||
})
|
||||
|
||||
it('does not count draft jobs toward quota', () => {
|
||||
const jobs: RotJob[] = [
|
||||
{ ...baseJob, id: 'job-1', rotDeduction: 10000, status: 'completed' },
|
||||
{ ...baseJob, id: 'job-2', rotDeduction: 15000, status: 'draft' },
|
||||
]
|
||||
|
||||
const quotas = calculateCustomerQuotas(jobs, 2025)
|
||||
|
||||
expect(quotas.get('cust-1')).toBe(10000)
|
||||
})
|
||||
|
||||
it('tracks multiple customers with different quotas', () => {
|
||||
const jobs: RotJob[] = [
|
||||
{ ...baseJob, id: 'job-1', customerId: 'cust-1', rotDeduction: 12000 },
|
||||
{ ...baseJob, id: 'job-2', customerId: 'cust-2', rotDeduction: 8000 },
|
||||
{ ...baseJob, id: 'job-3', customerId: 'cust-1', rotDeduction: 5000 },
|
||||
]
|
||||
|
||||
const quotas = calculateCustomerQuotas(jobs, 2025)
|
||||
|
||||
expect(quotas.get('cust-1')).toBe(17000)
|
||||
expect(quotas.get('cust-2')).toBe(8000)
|
||||
})
|
||||
})
|
||||
|
||||
describe('filterJobsByYear', () => {
|
||||
it('returns only jobs from the specified year', () => {
|
||||
const jobs: RotJob[] = [
|
||||
{ id: '1', customerId: 'c1', total: 50000, material: 20000, labor: 30000, rotDeduction: 9000, date: '2025-06-01', status: 'completed' },
|
||||
{ id: '2', customerId: 'c1', total: 60000, material: 25000, labor: 35000, rotDeduction: 10500, date: '2024-11-15', status: 'completed' },
|
||||
{ id: '3', customerId: 'c1', total: 70000, material: 30000, labor: 40000, rotDeduction: 12000, date: '2025-12-31', status: 'completed' },
|
||||
]
|
||||
|
||||
const filtered = filterJobsByYear(jobs, 2025)
|
||||
|
||||
expect(filtered).toHaveLength(2)
|
||||
expect(filtered.map(j => j.id)).toEqual(['1', '3'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateRotCsvContent', () => {
|
||||
it('generates CSV with correct columns for completed jobs only', () => {
|
||||
const jobs: RotJob[] = [
|
||||
{ id: '1', customerId: 'c1', total: 80000, material: 30000, labor: 50000, rotDeduction: 15000, date: '2025-04-10', status: 'completed' },
|
||||
{ id: '2', customerId: 'c2', total: 60000, material: 20000, labor: 40000, rotDeduction: 12000, date: '2025-05-20', status: 'draft' },
|
||||
{ id: '3', customerId: 'c1', total: 40000, material: 15000, labor: 25000, rotDeduction: 7500, date: '2025-06-15', status: 'completed' },
|
||||
]
|
||||
|
||||
const customers = new Map([
|
||||
['c1', { name: 'Anna Svensson', personalNumber: '198501011234' }],
|
||||
['c2', { name: 'Erik Johansson', personalNumber: '199003025678' }],
|
||||
])
|
||||
|
||||
const csv = generateRotCsvContent(jobs, customers)
|
||||
const lines = csv.split('\n')
|
||||
|
||||
expect(lines[0]).toBe('PersonalNumber,CustomerName,Labor,RotDeduction,Date')
|
||||
// Draft job (id=2) should be excluded
|
||||
expect(lines).toHaveLength(3)
|
||||
expect(lines[1]).toBe('198501011234,Anna Svensson,50000,15000,2025-04-10')
|
||||
expect(lines[2]).toBe('198501011234,Anna Svensson,25000,7500,2025-06-15')
|
||||
})
|
||||
})
|
||||
|
||||
describe('constants', () => {
|
||||
it('has correct ROT rate and yearly maximum', () => {
|
||||
expect(ROT_RATE).toBe(0.30)
|
||||
expect(MAX_ROT_YEARLY).toBe(50000)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* ROT (Repairs, Conversion, Extension) tax deduction calculator.
|
||||
*
|
||||
* ROT deduction allows Swedish homeowners to deduct 30% of labor costs
|
||||
* for home renovation work, up to SEK 50 000 per person per year.
|
||||
*
|
||||
* All monetary values use Math.round(x * 100) / 100 to avoid
|
||||
* floating-point precision issues.
|
||||
*/
|
||||
|
||||
export const MAX_ROT_YEARLY = 50000
|
||||
export const ROT_RATE = 0.30
|
||||
|
||||
export interface RotJob {
|
||||
id: string
|
||||
customerId: string
|
||||
total: number
|
||||
material: number
|
||||
labor: number
|
||||
rotDeduction: number
|
||||
date: string
|
||||
status: 'draft' | 'completed'
|
||||
}
|
||||
|
||||
export interface RotCalculation {
|
||||
labor: number
|
||||
rotDeduction: number
|
||||
customerPays: number
|
||||
remainingQuota: number
|
||||
}
|
||||
|
||||
export interface CustomerQuota {
|
||||
customerId: string
|
||||
usedQuota: number
|
||||
remainingQuota: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate ROT deduction for a job given the customer's already-used quota.
|
||||
*
|
||||
* The deduction is 30% of labor (total - material), capped by the
|
||||
* remaining yearly quota (MAX_ROT_YEARLY - usedQuota).
|
||||
*/
|
||||
export function calculateRotDeduction(
|
||||
total: number,
|
||||
material: number,
|
||||
usedQuota: number
|
||||
): RotCalculation {
|
||||
const labor = Math.round((total - material) * 100) / 100
|
||||
const remaining = Math.max(MAX_ROT_YEARLY - usedQuota, 0)
|
||||
const rawDeduction = Math.round(labor * ROT_RATE * 100) / 100
|
||||
const rotDeduction = Math.round(Math.min(rawDeduction, remaining) * 100) / 100
|
||||
const customerPays = Math.round((total - rotDeduction) * 100) / 100
|
||||
const remainingQuota = Math.round((remaining - rotDeduction) * 100) / 100
|
||||
|
||||
return {
|
||||
labor,
|
||||
rotDeduction,
|
||||
customerPays,
|
||||
remainingQuota,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate per-customer used quota for a given year.
|
||||
* Only completed jobs count toward the quota.
|
||||
*/
|
||||
export function calculateCustomerQuotas(
|
||||
jobs: RotJob[],
|
||||
year: number
|
||||
): Map<string, number> {
|
||||
const yearJobs = filterJobsByYear(jobs, year).filter(
|
||||
j => j.status === 'completed'
|
||||
)
|
||||
|
||||
const quotas = new Map<string, number>()
|
||||
|
||||
for (const job of yearJobs) {
|
||||
const current = quotas.get(job.customerId) ?? 0
|
||||
quotas.set(
|
||||
job.customerId,
|
||||
Math.round((current + job.rotDeduction) * 100) / 100
|
||||
)
|
||||
}
|
||||
|
||||
return quotas
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter jobs whose date falls within the specified year.
|
||||
*/
|
||||
export function filterJobsByYear(jobs: RotJob[], year: number): RotJob[] {
|
||||
const yearStr = String(year)
|
||||
return jobs.filter(j => j.date.startsWith(yearStr))
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate CSV content for Skatteverket ROT deduction reporting.
|
||||
* Only completed jobs are included.
|
||||
*
|
||||
* Columns: PersonalNumber, CustomerName, Labor, RotDeduction, Date
|
||||
*/
|
||||
export function generateRotCsvContent(
|
||||
jobs: RotJob[],
|
||||
customers: Map<string, { name: string; personalNumber: string }>
|
||||
): string {
|
||||
const header = 'PersonalNumber,CustomerName,Labor,RotDeduction,Date'
|
||||
const completedJobs = jobs.filter(j => j.status === 'completed')
|
||||
|
||||
const rows = completedJobs.map(job => {
|
||||
const customer = customers.get(job.customerId)
|
||||
const personalNumber = customer?.personalNumber ?? ''
|
||||
const customerName = customer?.name ?? ''
|
||||
return `${personalNumber},${customerName},${job.labor},${job.rotDeduction},${job.date}`
|
||||
})
|
||||
|
||||
return [header, ...rows].join('\n')
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Multichannel Revenue extension — Track revenue across sales channels
|
||||
export const MULTICHANNEL_REVENUE_EXTENSION_ID = 'ecommerce/multichannel-revenue'
|
||||
+211
@@ -0,0 +1,211 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
calculateChannelSummary,
|
||||
calculateOverallAOV,
|
||||
findDuplicate,
|
||||
calculateChannelGrowth,
|
||||
buildMonthlyComparison,
|
||||
filterEntriesByRange,
|
||||
type RevenueEntry,
|
||||
} from '../multichannel-calculator'
|
||||
|
||||
describe('calculateChannelSummary', () => {
|
||||
it('groups entries by channel and calculates totals with AOV', () => {
|
||||
const entries: RevenueEntry[] = [
|
||||
{ id: '1', month: '2025-01', channel: 'Shopify', revenue: 50000, orderCount: 200 },
|
||||
{ id: '2', month: '2025-02', channel: 'Shopify', revenue: 60000, orderCount: 250 },
|
||||
{ id: '3', month: '2025-01', channel: 'Amazon', revenue: 30000, orderCount: 150 },
|
||||
{ id: '4', month: '2025-02', channel: 'Amazon', revenue: 35000, orderCount: 180 },
|
||||
]
|
||||
|
||||
const result = calculateChannelSummary(entries)
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
|
||||
const shopify = result.find(s => s.channel === 'Shopify')!
|
||||
expect(shopify.totalRevenue).toBe(110000)
|
||||
expect(shopify.totalOrders).toBe(450)
|
||||
// 110000 / 450 = 244.444... -> 244.44
|
||||
expect(shopify.aov).toBe(244.44)
|
||||
|
||||
const amazon = result.find(s => s.channel === 'Amazon')!
|
||||
expect(amazon.totalRevenue).toBe(65000)
|
||||
expect(amazon.totalOrders).toBe(330)
|
||||
// 65000 / 330 = 196.969696... -> 196.97
|
||||
expect(amazon.aov).toBe(196.97)
|
||||
})
|
||||
|
||||
it('returns AOV of 0 when a channel has zero orders', () => {
|
||||
const entries: RevenueEntry[] = [
|
||||
{ id: '1', month: '2025-01', channel: 'Wholesale', revenue: 0, orderCount: 0 },
|
||||
]
|
||||
|
||||
const result = calculateChannelSummary(entries)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].aov).toBe(0)
|
||||
expect(result[0].totalOrders).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('calculateOverallAOV', () => {
|
||||
it('calculates overall AOV across all channels', () => {
|
||||
const entries: RevenueEntry[] = [
|
||||
{ id: '1', month: '2025-01', channel: 'Shopify', revenue: 50000, orderCount: 200 },
|
||||
{ id: '2', month: '2025-01', channel: 'Amazon', revenue: 30000, orderCount: 100 },
|
||||
]
|
||||
|
||||
// Total revenue: 80000, Total orders: 300
|
||||
// 80000 / 300 = 266.666... -> 266.67
|
||||
const result = calculateOverallAOV(entries)
|
||||
expect(result).toBe(266.67)
|
||||
})
|
||||
|
||||
it('returns 0 for empty entries', () => {
|
||||
const result = calculateOverallAOV([])
|
||||
expect(result).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('findDuplicate', () => {
|
||||
const entries: RevenueEntry[] = [
|
||||
{ id: '1', month: '2025-01', channel: 'Shopify', revenue: 50000, orderCount: 200 },
|
||||
{ id: '2', month: '2025-02', channel: 'Amazon', revenue: 30000, orderCount: 150 },
|
||||
]
|
||||
|
||||
it('finds an existing entry for same month and channel', () => {
|
||||
const dup = findDuplicate(entries, '2025-01', 'Shopify')
|
||||
expect(dup).toBeDefined()
|
||||
expect(dup!.id).toBe('1')
|
||||
})
|
||||
|
||||
it('returns undefined when no duplicate exists', () => {
|
||||
const dup = findDuplicate(entries, '2025-03', 'Shopify')
|
||||
expect(dup).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('calculateChannelGrowth', () => {
|
||||
it('calculates positive growth percentage', () => {
|
||||
const previous: RevenueEntry[] = [
|
||||
{ id: '1', month: '2025-01', channel: 'Shopify', revenue: 40000, orderCount: 100 },
|
||||
]
|
||||
const current: RevenueEntry[] = [
|
||||
{ id: '2', month: '2025-02', channel: 'Shopify', revenue: 50000, orderCount: 120 },
|
||||
]
|
||||
|
||||
const result = calculateChannelGrowth(current, previous)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].channel).toBe('Shopify')
|
||||
expect(result[0].currentRevenue).toBe(50000)
|
||||
expect(result[0].previousRevenue).toBe(40000)
|
||||
// (50000 - 40000) / 40000 * 100 = 25
|
||||
expect(result[0].growthPct).toBe(25)
|
||||
})
|
||||
|
||||
it('calculates negative growth percentage', () => {
|
||||
const previous: RevenueEntry[] = [
|
||||
{ id: '1', month: '2025-01', channel: 'Amazon', revenue: 60000, orderCount: 200 },
|
||||
]
|
||||
const current: RevenueEntry[] = [
|
||||
{ id: '2', month: '2025-02', channel: 'Amazon', revenue: 45000, orderCount: 150 },
|
||||
]
|
||||
|
||||
const result = calculateChannelGrowth(current, previous)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].growthPct).toBe(-25)
|
||||
})
|
||||
|
||||
it('returns null growthPct for new channel with no previous data', () => {
|
||||
const previous: RevenueEntry[] = [
|
||||
{ id: '1', month: '2025-01', channel: 'Shopify', revenue: 40000, orderCount: 100 },
|
||||
]
|
||||
const current: RevenueEntry[] = [
|
||||
{ id: '2', month: '2025-02', channel: 'Shopify', revenue: 50000, orderCount: 120 },
|
||||
{ id: '3', month: '2025-02', channel: 'TikTok Shop', revenue: 10000, orderCount: 50 },
|
||||
]
|
||||
|
||||
const result = calculateChannelGrowth(current, previous)
|
||||
|
||||
const tiktok = result.find(r => r.channel === 'TikTok Shop')!
|
||||
expect(tiktok.currentRevenue).toBe(10000)
|
||||
expect(tiktok.previousRevenue).toBe(0)
|
||||
expect(tiktok.growthPct).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildMonthlyComparison', () => {
|
||||
it('builds comparison table with 2 channels across 3 months', () => {
|
||||
const entries: RevenueEntry[] = [
|
||||
{ id: '1', month: '2025-01', channel: 'Shopify', revenue: 50000, orderCount: 200 },
|
||||
{ id: '2', month: '2025-01', channel: 'Amazon', revenue: 30000, orderCount: 100 },
|
||||
{ id: '3', month: '2025-02', channel: 'Shopify', revenue: 55000, orderCount: 220 },
|
||||
{ id: '4', month: '2025-02', channel: 'Amazon', revenue: 32000, orderCount: 110 },
|
||||
{ id: '5', month: '2025-03', channel: 'Shopify', revenue: 60000, orderCount: 240 },
|
||||
{ id: '6', month: '2025-03', channel: 'Amazon', revenue: 35000, orderCount: 130 },
|
||||
]
|
||||
const channelNames = ['Shopify', 'Amazon']
|
||||
|
||||
const result = buildMonthlyComparison(entries, channelNames)
|
||||
|
||||
expect(result).toHaveLength(3)
|
||||
|
||||
// Months should be sorted ascending
|
||||
expect(result[0].month).toBe('2025-01')
|
||||
expect(result[1].month).toBe('2025-02')
|
||||
expect(result[2].month).toBe('2025-03')
|
||||
|
||||
// January
|
||||
expect(result[0].channels['Shopify']).toBe(50000)
|
||||
expect(result[0].channels['Amazon']).toBe(30000)
|
||||
expect(result[0].total).toBe(80000)
|
||||
|
||||
// March
|
||||
expect(result[2].channels['Shopify']).toBe(60000)
|
||||
expect(result[2].channels['Amazon']).toBe(35000)
|
||||
expect(result[2].total).toBe(95000)
|
||||
})
|
||||
})
|
||||
|
||||
describe('filterEntriesByRange', () => {
|
||||
it('filters entries by YYYY-MM date range inclusive', () => {
|
||||
const entries: RevenueEntry[] = [
|
||||
{ id: '1', month: '2024-11', channel: 'Shopify', revenue: 40000, orderCount: 100 },
|
||||
{ id: '2', month: '2024-12', channel: 'Shopify', revenue: 45000, orderCount: 110 },
|
||||
{ id: '3', month: '2025-01', channel: 'Shopify', revenue: 50000, orderCount: 120 },
|
||||
{ id: '4', month: '2025-02', channel: 'Shopify', revenue: 55000, orderCount: 130 },
|
||||
{ id: '5', month: '2025-03', channel: 'Shopify', revenue: 60000, orderCount: 140 },
|
||||
]
|
||||
|
||||
const result = filterEntriesByRange(entries, '2024-12', '2025-02')
|
||||
|
||||
expect(result).toHaveLength(3)
|
||||
expect(result.map(e => e.month)).toEqual(['2024-12', '2025-01', '2025-02'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('monetary rounding', () => {
|
||||
it('applies Math.round(x * 100) / 100 for all monetary outputs', () => {
|
||||
const entries: RevenueEntry[] = [
|
||||
{ id: '1', month: '2025-01', channel: 'Shopify', revenue: 33333.33, orderCount: 7 },
|
||||
{ id: '2', month: '2025-01', channel: 'Amazon', revenue: 16666.67, orderCount: 3 },
|
||||
]
|
||||
|
||||
// Overall AOV: 50000 / 10 = 5000
|
||||
expect(calculateOverallAOV(entries)).toBe(5000)
|
||||
|
||||
// Per-channel AOV
|
||||
const summaries = calculateChannelSummary(entries)
|
||||
const shopify = summaries.find(s => s.channel === 'Shopify')!
|
||||
// 33333.33 / 7 = 4761.9042857... -> 4761.9
|
||||
expect(shopify.aov).toBe(4761.9)
|
||||
expect(shopify.totalRevenue).toBe(33333.33)
|
||||
|
||||
const amazon = summaries.find(s => s.channel === 'Amazon')!
|
||||
// 16666.67 / 3 = 5555.5566... -> 5555.56
|
||||
expect(amazon.aov).toBe(5555.56)
|
||||
expect(amazon.totalRevenue).toBe(16666.67)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* Pure calculation functions for the Multichannel Revenue extension.
|
||||
*
|
||||
* Aggregates revenue data across sales channels (e.g. Shopify, Amazon,
|
||||
* physical store) and computes per-channel summaries, growth rates,
|
||||
* and monthly comparison tables.
|
||||
*
|
||||
* All monetary values use Math.round(x * 100) / 100 for precision.
|
||||
*/
|
||||
|
||||
export interface RevenueEntry {
|
||||
id: string
|
||||
month: string // YYYY-MM
|
||||
channel: string
|
||||
revenue: number
|
||||
orderCount: number
|
||||
}
|
||||
|
||||
export interface ChannelSummary {
|
||||
channel: string
|
||||
totalRevenue: number
|
||||
totalOrders: number
|
||||
aov: number // revenue / orders, or 0
|
||||
}
|
||||
|
||||
export interface ChannelGrowth {
|
||||
channel: string
|
||||
currentRevenue: number
|
||||
previousRevenue: number
|
||||
growthPct: number | null // null if no previous data
|
||||
}
|
||||
|
||||
export interface MonthlyComparison {
|
||||
month: string
|
||||
channels: Record<string, number> // channel name -> revenue
|
||||
total: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate per-channel summary with AOV (Average Order Value).
|
||||
* Groups entries by channel and computes totals.
|
||||
*/
|
||||
export function calculateChannelSummary(entries: RevenueEntry[]): ChannelSummary[] {
|
||||
const byChannel = new Map<string, { revenue: number; orders: number }>()
|
||||
|
||||
for (const entry of entries) {
|
||||
const existing = byChannel.get(entry.channel)
|
||||
if (existing) {
|
||||
existing.revenue += entry.revenue
|
||||
existing.orders += entry.orderCount
|
||||
} else {
|
||||
byChannel.set(entry.channel, {
|
||||
revenue: entry.revenue,
|
||||
orders: entry.orderCount,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const summaries: ChannelSummary[] = []
|
||||
for (const [channel, data] of byChannel) {
|
||||
const totalRevenue = Math.round(data.revenue * 100) / 100
|
||||
const totalOrders = data.orders
|
||||
const aov = totalOrders > 0
|
||||
? Math.round((totalRevenue / totalOrders) * 100) / 100
|
||||
: 0
|
||||
|
||||
summaries.push({ channel, totalRevenue, totalOrders, aov })
|
||||
}
|
||||
|
||||
return summaries
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate overall AOV across all channels.
|
||||
* Returns 0 if there are no orders.
|
||||
*/
|
||||
export function calculateOverallAOV(entries: RevenueEntry[]): number {
|
||||
const totalRevenue = entries.reduce((sum, e) => sum + e.revenue, 0)
|
||||
const totalOrders = entries.reduce((sum, e) => sum + e.orderCount, 0)
|
||||
|
||||
if (totalOrders === 0) return 0
|
||||
|
||||
return Math.round((totalRevenue / totalOrders) * 100) / 100
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for duplicate entry (same month + channel).
|
||||
* Returns the first matching entry or undefined.
|
||||
*/
|
||||
export function findDuplicate(
|
||||
entries: RevenueEntry[],
|
||||
month: string,
|
||||
channel: string
|
||||
): RevenueEntry | undefined {
|
||||
return entries.find(e => e.month === month && e.channel === channel)
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate channel growth between current and previous period entries.
|
||||
* A channel present only in currentEntries gets growthPct: null.
|
||||
*/
|
||||
export function calculateChannelGrowth(
|
||||
currentEntries: RevenueEntry[],
|
||||
previousEntries: RevenueEntry[]
|
||||
): ChannelGrowth[] {
|
||||
const currentByChannel = new Map<string, number>()
|
||||
for (const entry of currentEntries) {
|
||||
currentByChannel.set(
|
||||
entry.channel,
|
||||
(currentByChannel.get(entry.channel) ?? 0) + entry.revenue
|
||||
)
|
||||
}
|
||||
|
||||
const previousByChannel = new Map<string, number>()
|
||||
for (const entry of previousEntries) {
|
||||
previousByChannel.set(
|
||||
entry.channel,
|
||||
(previousByChannel.get(entry.channel) ?? 0) + entry.revenue
|
||||
)
|
||||
}
|
||||
|
||||
const results: ChannelGrowth[] = []
|
||||
for (const [channel, currentRevenue] of currentByChannel) {
|
||||
const rounded = Math.round(currentRevenue * 100) / 100
|
||||
const previousRevenue = previousByChannel.get(channel)
|
||||
|
||||
if (previousRevenue === undefined || previousRevenue === 0) {
|
||||
results.push({
|
||||
channel,
|
||||
currentRevenue: rounded,
|
||||
previousRevenue: 0,
|
||||
growthPct: null,
|
||||
})
|
||||
} else {
|
||||
const prevRounded = Math.round(previousRevenue * 100) / 100
|
||||
const growthPct = Math.round(
|
||||
((rounded - prevRounded) / prevRounded) * 10000
|
||||
) / 100
|
||||
|
||||
results.push({
|
||||
channel,
|
||||
currentRevenue: rounded,
|
||||
previousRevenue: prevRounded,
|
||||
growthPct,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* Build monthly comparison table.
|
||||
* Each row contains per-channel revenue and a total for that month.
|
||||
* Months are sorted in ascending order.
|
||||
*/
|
||||
export function buildMonthlyComparison(
|
||||
entries: RevenueEntry[],
|
||||
channelNames: string[]
|
||||
): MonthlyComparison[] {
|
||||
const monthMap = new Map<string, Record<string, number>>()
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!monthMap.has(entry.month)) {
|
||||
const channels: Record<string, number> = {}
|
||||
for (const name of channelNames) {
|
||||
channels[name] = 0
|
||||
}
|
||||
monthMap.set(entry.month, channels)
|
||||
}
|
||||
|
||||
const channels = monthMap.get(entry.month)!
|
||||
channels[entry.channel] = Math.round(
|
||||
((channels[entry.channel] ?? 0) + entry.revenue) * 100
|
||||
) / 100
|
||||
}
|
||||
|
||||
const months = Array.from(monthMap.keys()).sort()
|
||||
|
||||
return months.map(month => {
|
||||
const channels = monthMap.get(month)!
|
||||
const total = Math.round(
|
||||
Object.values(channels).reduce((sum, v) => sum + v, 0) * 100
|
||||
) / 100
|
||||
|
||||
return { month, channels, total }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter entries by date range using the month field (YYYY-MM).
|
||||
* Inclusive on both ends: startDate <= month <= endDate.
|
||||
*/
|
||||
export function filterEntriesByRange(
|
||||
entries: RevenueEntry[],
|
||||
startDate: string,
|
||||
endDate: string
|
||||
): RevenueEntry[] {
|
||||
return entries.filter(e => e.month >= startDate && e.month <= endDate)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Shopify Import extension — Import orders and transactions from Shopify
|
||||
export const SHOPIFY_IMPORT_EXTENSION_ID = 'ecommerce/shopify-import'
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user