Added extension functionality
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
@@ -24,11 +24,14 @@ import {
|
||||
Building2,
|
||||
FileInput,
|
||||
} 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,12 +64,31 @@ 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()
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
|
||||
const [isOvrigtExpanded, setIsOvrigtExpanded] = useState(false)
|
||||
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
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Refresh extensions when dropdown is opened or mobile menu is opened
|
||||
useEffect(() => {
|
||||
if (isTillaggExpanded || isMobileMenuOpen) fetchExtensions()
|
||||
}, [isTillaggExpanded, isMobileMenuOpen, fetchExtensions])
|
||||
|
||||
const handleLogout = async () => {
|
||||
await supabase.auth.signOut()
|
||||
@@ -174,6 +196,53 @@ export default function DashboardNav({ companyName, entityType }: DashboardNavPr
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tillägg - collapsible */}
|
||||
<div className="mb-4">
|
||||
<button
|
||||
onClick={() => setIsTillaggExpanded(!isTillaggExpanded)}
|
||||
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
|
||||
@@ -357,6 +426,40 @@ export default function DashboardNav({ companyName, entityType }: DashboardNavPr
|
||||
})}
|
||||
</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' },
|
||||
reports: { label: 'Branschrapporter', className: 'bg-blue-100 text-blue-700 border-blue-200' },
|
||||
import: { label: 'Smart Import', className: 'bg-emerald-100 text-emerald-700 border-emerald-200' },
|
||||
operations: { label: 'Verktyg', className: 'bg-slate-100 text-slate-700 border-slate-200' },
|
||||
}
|
||||
|
||||
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,14 @@
|
||||
'use client'
|
||||
|
||||
import { FolderKanban } from 'lucide-react'
|
||||
import EmptyExtensionState from '@/components/extensions/shared/EmptyExtensionState'
|
||||
|
||||
export default function ProjectCostWorkspace() {
|
||||
return (
|
||||
<EmptyExtensionState
|
||||
title="Projektkostnadsuppföljning"
|
||||
description="Uppföljning av kostnader per byggprojekt kommer snart. Du kommer kunna koppla fakturor och transaktioner till specifika projekt."
|
||||
icon={<FolderKanban className="h-12 w-12 text-muted-foreground/40 mb-4" />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
'use client'
|
||||
|
||||
import { Calculator } from 'lucide-react'
|
||||
import EmptyExtensionState from '@/components/extensions/shared/EmptyExtensionState'
|
||||
|
||||
export default function RotCalculatorWorkspace() {
|
||||
return (
|
||||
<EmptyExtensionState
|
||||
title="ROT-avdragsberäkning"
|
||||
description="Beräkning av ROT-avdrag för hantverkstjänster kommer snart. Du kommer kunna beräkna kundens avdrag och generera underlag till Skatteverket."
|
||||
icon={<Calculator className="h-12 w-12 text-muted-foreground/40 mb-4" />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
'use client'
|
||||
|
||||
import { BarChart3 } from 'lucide-react'
|
||||
import EmptyExtensionState from '@/components/extensions/shared/EmptyExtensionState'
|
||||
|
||||
export default function MultichannelRevenueWorkspace() {
|
||||
return (
|
||||
<EmptyExtensionState
|
||||
title="Flerkanalintäkter"
|
||||
description="Uppföljning av intäkter per försäljningskanal kommer snart. Du kommer kunna jämföra prestanda mellan webshop, marknadsplatser och fysisk butik."
|
||||
icon={<BarChart3 className="h-12 w-12 text-muted-foreground/40 mb-4" />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
'use client'
|
||||
|
||||
import { ShoppingBag } from 'lucide-react'
|
||||
import EmptyExtensionState from '@/components/extensions/shared/EmptyExtensionState'
|
||||
|
||||
export default function ShopifyImportWorkspace() {
|
||||
return (
|
||||
<EmptyExtensionState
|
||||
title="Shopify-import"
|
||||
description="Import av ordrar och transaktioner från Shopify kommer snart. Du kommer kunna synkronisera din Shopify-butik automatiskt."
|
||||
icon={<ShoppingBag className="h-12 w-12 text-muted-foreground/40 mb-4" />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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,14 @@
|
||||
'use client'
|
||||
|
||||
import { DoorOpen } from 'lucide-react'
|
||||
import EmptyExtensionState from '@/components/extensions/shared/EmptyExtensionState'
|
||||
|
||||
export default function OccupancyWorkspace() {
|
||||
return (
|
||||
<EmptyExtensionState
|
||||
title="Beläggningsgrad"
|
||||
description="Uppföljning av rumsbeläggning kommer snart. Du kommer kunna registrera beläggning och se trender över tid."
|
||||
icon={<DoorOpen className="h-12 w-12 text-muted-foreground/40 mb-4" />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
'use client'
|
||||
|
||||
import { BedDouble } from 'lucide-react'
|
||||
import EmptyExtensionState from '@/components/extensions/shared/EmptyExtensionState'
|
||||
|
||||
export default function RevparWorkspace() {
|
||||
return (
|
||||
<EmptyExtensionState
|
||||
title="RevPAR-beräkning"
|
||||
description="Beräkning av intäkt per tillgängligt rum (RevPAR) kommer snart. Du kommer kunna följa upp RevPAR per dag, vecka och månad."
|
||||
icon={<BedDouble className="h-12 w-12 text-muted-foreground/40 mb-4" />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
|
||||
import KPICard from '@/components/extensions/shared/KPICard'
|
||||
import DateRangeFilter from '@/components/extensions/shared/DateRangeFilter'
|
||||
import DataEntryForm from '@/components/extensions/shared/DataEntryForm'
|
||||
import ExtensionLoadingSkeleton from '@/components/extensions/shared/ExtensionLoadingSkeleton'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
|
||||
export default function EarningsPerLiterWorkspace({ userId }: WorkspaceComponentProps) {
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [earningsPerLiter, setEarningsPerLiter] = useState<number>(0)
|
||||
const [totalLiters, setTotalLiters] = useState<number>(0)
|
||||
const [totalRevenue, setTotalRevenue] = useState<number>(0)
|
||||
|
||||
// Data entry state
|
||||
const [liters, setLiters] = useState('')
|
||||
const [entryDate, setEntryDate] = useState(new Date().toISOString().slice(0, 10))
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
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),
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
// In a real implementation, this would fetch liter entries and revenue data
|
||||
setIsLoading(false)
|
||||
}, [dateRange, userId])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsSubmitting(true)
|
||||
// In a real implementation, this would save the liter entry via API
|
||||
setIsSubmitting(false)
|
||||
setLiters('')
|
||||
}
|
||||
|
||||
if (isLoading) return <ExtensionLoadingSkeleton />
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<DateRangeFilter
|
||||
onRangeChange={(start, end) => setDateRange({ start, end })}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<KPICard
|
||||
label="Intäkt per liter"
|
||||
value={earningsPerLiter.toLocaleString('sv-SE')}
|
||||
suffix="kr/l"
|
||||
/>
|
||||
<KPICard
|
||||
label="Totalt liter"
|
||||
value={totalLiters.toLocaleString('sv-SE')}
|
||||
suffix="l"
|
||||
/>
|
||||
<KPICard
|
||||
label="Alkoholintäkter"
|
||||
value={totalRevenue.toLocaleString('sv-SE')}
|
||||
suffix="kr"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DataEntryForm
|
||||
title="Registrera daglig literförsäljning"
|
||||
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="entry-date">Datum</Label>
|
||||
<Input
|
||||
id="entry-date"
|
||||
type="date"
|
||||
value={entryDate}
|
||||
onChange={e => setEntryDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="liters">Antal liter</Label>
|
||||
<Input
|
||||
id="liters"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
placeholder="0.00"
|
||||
value={liters}
|
||||
onChange={e => setLiters(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</DataEntryForm>
|
||||
|
||||
<div className="rounded-xl border p-6">
|
||||
<h3 className="text-sm font-semibold mb-4">Så fungerar det</h3>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
Intäkt per liter beräknas genom att dividera alkoholintäkter med totalt antal sålda
|
||||
liter. Registrera daglig literförsäljning ovan. Alkoholintäkter hämtas automatiskt
|
||||
från bokföringen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
|
||||
import KPICard from '@/components/extensions/shared/KPICard'
|
||||
import DateRangeFilter from '@/components/extensions/shared/DateRangeFilter'
|
||||
import ExtensionLoadingSkeleton from '@/components/extensions/shared/ExtensionLoadingSkeleton'
|
||||
|
||||
export default function FoodCostWorkspace({ userId }: WorkspaceComponentProps) {
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [foodCost, setFoodCost] = useState<number>(0)
|
||||
const [revenue, setRevenue] = useState<number>(0)
|
||||
const [purchases, setPurchases] = useState<number>(0)
|
||||
|
||||
// Set initial date range to current month
|
||||
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),
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
// In a real implementation, this would fetch journal_entry_lines
|
||||
// and calculate food cost via the API
|
||||
setIsLoading(false)
|
||||
}, [dateRange, userId])
|
||||
|
||||
if (isLoading) return <ExtensionLoadingSkeleton />
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<DateRangeFilter
|
||||
onRangeChange={(start, end) => setDateRange({ start, end })}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<KPICard
|
||||
label="Food Cost %"
|
||||
value={foodCost}
|
||||
suffix="%"
|
||||
/>
|
||||
<KPICard
|
||||
label="Varuinköp"
|
||||
value={purchases.toLocaleString('sv-SE')}
|
||||
suffix="kr"
|
||||
/>
|
||||
<KPICard
|
||||
label="Livsmedelsintäkter"
|
||||
value={revenue.toLocaleString('sv-SE')}
|
||||
suffix="kr"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border p-6">
|
||||
<h3 className="text-sm font-semibold mb-4">Så fungerar det</h3>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
Food Cost % beräknas automatiskt utifrån din bokföring. Varuinköp (konton 4000-4999)
|
||||
divideras med livsmedelsintäkter (konton 3000-3999). En bra riktvärde för restauranger
|
||||
är 25-35%.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
'use client'
|
||||
|
||||
import { Receipt } from 'lucide-react'
|
||||
import EmptyExtensionState from '@/components/extensions/shared/EmptyExtensionState'
|
||||
|
||||
export default function PosImportWorkspace() {
|
||||
return (
|
||||
<EmptyExtensionState
|
||||
title="Kassa Z-rapport import"
|
||||
description="Stöd för import av Z-rapporter kommer snart. Du kommer kunna importera dagliga kassarapporter direkt från ditt kassasystem."
|
||||
icon={<Receipt className="h-12 w-12 text-muted-foreground/40 mb-4" />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
'use client'
|
||||
|
||||
import { HandCoins } from 'lucide-react'
|
||||
import EmptyExtensionState from '@/components/extensions/shared/EmptyExtensionState'
|
||||
|
||||
export default function TipTrackingWorkspace() {
|
||||
return (
|
||||
<EmptyExtensionState
|
||||
title="Dricksuppföljning"
|
||||
description="Registrering av dricks per skift kommer snart. Du kommer kunna följa upp dricksfördelning och bokföra det korrekt."
|
||||
icon={<HandCoins className="h-12 w-12 text-muted-foreground/40 mb-4" />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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,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,14 @@
|
||||
'use client'
|
||||
|
||||
import { Clock } from 'lucide-react'
|
||||
import EmptyExtensionState from '@/components/extensions/shared/EmptyExtensionState'
|
||||
|
||||
export default function BillableHoursWorkspace() {
|
||||
return (
|
||||
<EmptyExtensionState
|
||||
title="Debiterbar tid"
|
||||
description="Tidsrapportering och uppföljning av debiterbara timmar kommer snart. Du kommer kunna logga tid per kund och projekt."
|
||||
icon={<Clock className="h-12 w-12 text-muted-foreground/40 mb-4" />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
'use client'
|
||||
|
||||
import { Layers } from 'lucide-react'
|
||||
import EmptyExtensionState from '@/components/extensions/shared/EmptyExtensionState'
|
||||
|
||||
export default function ProjectBillingWorkspace() {
|
||||
return (
|
||||
<EmptyExtensionState
|
||||
title="Projektfakturering"
|
||||
description="Uppföljning av fakturering per kundprojekt kommer snart. Du kommer kunna koppla tidrapporter till fakturor automatiskt."
|
||||
icon={<Layers className="h-12 w-12 text-muted-foreground/40 mb-4" />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user