feat: integrate Grafana dashboards into BOC DashboardPage

- Add Infrastructure Health section with CPU/Memory/Disk panels
- Add Service Status section with PM2/Docker panels
- Create GrafanaPanel component for iframe embedding
- Build passes successfully
This commit is contained in:
Bernt
2026-07-29 19:03:06 +00:00
parent af874040ca
commit e5623d2f84
77 changed files with 11338 additions and 779 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -8,8 +8,8 @@
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
<script type="module" crossorigin src="/assets/index-ClCGdXUz.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BoDgaLhD.css">
<script type="module" crossorigin src="/assets/index-CbMx-xOV.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-B0Irvkd7.css">
</head>
<body>
<div id="root"></div>
+6 -1
View File
@@ -3,6 +3,8 @@ import { useAuthStore } from '@/stores/authStore'
import { AppShell } from '@/components/layout/AppShell'
import { LoginPage } from '@/pages/LoginPage'
import { DashboardPage } from '@/pages/DashboardPage'
import { BriefingPage } from '@/pages/BriefingPage'
import { ProfilePage } from '@/pages/ProfilePage'
import { CRMPage } from '@/pages/CRMPage'
import { SalesPage } from '@/pages/SalesPage'
import { FinancePage } from '@/pages/FinancePage'
@@ -27,7 +29,8 @@ export default function App() {
<Routes>
<Route path="/login" element={<PublicRoute><LoginPage /></PublicRoute>} />
<Route element={<ProtectedRoute><AppShell /></ProtectedRoute>}>
<Route path="/" element={<DashboardPage />} />
<Route path="/" element={<BriefingPage />} />
<Route path="/dashboard" element={<DashboardPage />} />
<Route path="/crm" element={<CRMPage />} />
<Route path="/sales" element={<SalesPage />} />
<Route path="/finance" element={<FinancePage />} />
@@ -36,6 +39,8 @@ export default function App() {
<Route path="/marketing" element={<MarketingPage />} />
<Route path="/support" element={<SupportPage />} />
<Route path="/automation" element={<AutomationPage />} />
<Route path="/legal" element={<LegalPage />} />
<Route path="/profile" element={<ProfilePage />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
+202
View File
@@ -0,0 +1,202 @@
import { Badge } from '@/components/ui/Badge'
import { formatCurrency } from '@/lib/utils'
import {
AlertTriangle,
TrendingUp,
TrendingDown,
AlertCircle,
Briefcase,
Clock,
ArrowRight,
} from 'lucide-react'
interface BriefingCardProps {
title: string
value: string | number
subtitle?: string
icon?: React.ReactNode
trend?: 'up' | 'down' | 'neutral'
trendValue?: string
variant?: 'default' | 'success' | 'warning' | 'danger'
}
export function BriefingCard({
title,
value,
subtitle,
icon,
trend,
trendValue,
variant = 'default',
}: BriefingCardProps) {
const variantStyles = {
default: 'bg-surface border-border',
success: 'bg-success/5 border-success/20',
warning: 'bg-warning/5 border-warning/20',
danger: 'bg-danger/5 border-danger/20',
}
return (
<div className={`p-4 rounded-xl border ${variantStyles[variant]}`}>
<div className="flex items-start justify-between">
<div className="space-y-1">
<p className="text-sm text-text-secondary">{title}</p>
<p className="text-2xl font-semibold">{value}</p>
{subtitle && <p className="text-xs text-text-tertiary">{subtitle}</p>}
</div>
{icon && <div className="text-text-secondary">{icon}</div>}
</div>
{trend && (
<div className="flex items-center gap-1 mt-2">
{trend === 'up' && <TrendingUp size={14} className="text-success" />}
{trend === 'down' && <TrendingDown size={14} className="text-danger" />}
{trend === 'neutral' && <AlertCircle size={14} className="text-text-secondary" />}
<span className={`text-xs ${trend === 'up' ? 'text-success' : trend === 'down' ? 'text-danger' : 'text-text-secondary'}`}>
{trendValue}
</span>
</div>
)}
</div>
)
}
interface AlertCardProps {
alert: {
level: string
title: string
description: string
source: string
}
}
export function AlertCard({ alert }: AlertCardProps) {
const levelStyles = {
info: { icon: <AlertCircle size={16} />, color: 'text-primary bg-primary/5 border-primary/20' },
warning: { icon: <AlertTriangle size={16} />, color: 'text-warning bg-warning/5 border-warning/20' },
critical: { icon: <AlertTriangle size={16} />, color: 'text-danger bg-danger/5 border-danger/20' },
}
const style = levelStyles[alert.level as keyof typeof levelStyles] || levelStyles.info
return (
<div className={`p-3 rounded-lg border ${style.color}`}>
<div className="flex items-start gap-2">
{style.icon}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">{alert.title}</p>
<p className="text-xs opacity-80 mt-0.5">{alert.description}</p>
</div>
<Badge variant="default" className="text-xs">{alert.source}</Badge>
</div>
</div>
)
}
interface ActionCardProps {
action: {
priority: number
title: string
description: string
impact: string
time_estimate: string
}
}
export function ActionCard({ action }: ActionCardProps) {
const priorityColors = {
10: 'bg-danger text-white',
9: 'bg-danger/80 text-white',
8: 'bg-warning text-white',
7: 'bg-warning/80 text-white',
6: 'bg-primary text-white',
5: 'bg-primary/80 text-white',
}
return (
<div className="p-3 rounded-lg border border-border bg-surface hover:bg-surface-hover transition-colors cursor-pointer group">
<div className="flex items-start gap-3">
<div className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-medium shrink-0 ${priorityColors[action.priority as keyof typeof priorityColors] || 'bg-text-secondary text-white'}`}>
{action.priority}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium group-hover:text-primary transition-colors">{action.title}</p>
<p className="text-xs text-text-secondary mt-0.5">{action.description}</p>
<div className="flex items-center gap-3 mt-2">
<span className="text-xs text-text-tertiary flex items-center gap-1">
<Clock size={12} />
{action.time_estimate}
</span>
<span className="text-xs text-text-tertiary">{action.impact}</span>
</div>
</div>
<ArrowRight size={16} className="text-text-tertiary group-hover:text-primary transition-colors shrink-0" />
</div>
</div>
)
}
interface TaskCardProps {
task: {
id: string
title: string
priority: string
status: string
days_open: number
category: string
}
}
export function TaskCard({ task }: TaskCardProps) {
const priorityVariants = {
critical: 'danger',
high: 'warning',
medium: 'default',
low: 'success',
}
return (
<div className="flex items-center gap-3 p-2 rounded-lg hover:bg-surface-hover transition-colors">
<div className="w-2 h-2 rounded-full bg-warning shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm truncate">{task.title}</p>
<div className="flex items-center gap-2 mt-0.5">
<Badge variant={priorityVariants[task.priority as keyof typeof priorityVariants] as any} className="text-xs">
{task.priority}
</Badge>
<span className="text-xs text-text-tertiary">{task.category}</span>
</div>
</div>
<span className="text-xs text-text-tertiary shrink-0">{task.days_open}d</span>
</div>
)
}
interface DealCardProps {
deal: {
id: string
name: string
customer: string
value: number
currency: string
stage: string
probability: number
}
}
export function DealCard({ deal }: DealCardProps) {
return (
<div className="flex items-center gap-3 p-2 rounded-lg hover:bg-surface-hover transition-colors">
<div className="w-8 h-8 rounded-lg bg-primary/10 flex items-center justify-center shrink-0">
<Briefcase size={16} className="text-primary" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{deal.name}</p>
<p className="text-xs text-text-secondary">{deal.customer}</p>
</div>
<div className="text-right shrink-0">
<p className="text-sm font-medium">{formatCurrency(deal.value)}</p>
<p className="text-xs text-text-tertiary">{deal.probability}%</p>
</div>
</div>
)
}
+287
View File
@@ -0,0 +1,287 @@
import { useEffect, useState } from 'react'
import { briefingApi, type RealDailyBriefing } from '@/lib/briefingApi'
import { BriefingCard, AlertCard, ActionCard, TaskCard, DealCard } from '@/components/BriefingCard'
import { Card, CardHeader } from '@/components/ui/Card'
import { Badge } from '@/components/ui/Badge'
import { Skeleton } from '@/components/ui/Skeleton'
import { formatCurrency, formatNumber } from '@/lib/utils'
import {
DollarSign,
Users,
Briefcase,
AlertTriangle,
CheckCircle2,
Target,
} from 'lucide-react'
export function BriefingDashboard() {
const [briefing, setBriefing] = useState<RealDailyBriefing | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
useEffect(() => {
async function fetchBriefing() {
try {
setLoading(true)
const data = await briefingApi.getRealBriefing()
setBriefing(data)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load briefing')
} finally {
setLoading(false)
}
}
fetchBriefing()
// Uppdatera var 5:e minut
const interval = setInterval(fetchBriefing, 300000)
return () => clearInterval(interval)
}, [])
if (loading) {
return (
<div className="space-y-6">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-[120px]" />
))}
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<Skeleton className="h-[400px] lg:col-span-2" />
<Skeleton className="h-[400px]" />
</div>
</div>
)
}
if (error) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-center">
<AlertTriangle size={48} className="text-danger mx-auto mb-4" />
<p className="text-danger font-medium">{error}</p>
<button
onClick={() => window.location.reload()}
className="mt-4 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-hover transition-colors"
>
Försök igen
</button>
</div>
</div>
)
}
if (!briefing) return null
const { company_health, my_tasks, my_deals, team_overview, financial_status, alerts, actions } = briefing
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Dagsöversikt</h1>
<p className="text-text-secondary">
{briefing.user.name} {new Date(briefing.generated_at).toLocaleDateString('sv-SE', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</p>
</div>
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${company_health.status === 'healthy' ? 'bg-success' : company_health.status === 'warning' ? 'bg-warning' : 'bg-danger'}`} />
<span className="text-sm text-text-secondary capitalize">{company_health.status}</span>
</div>
</div>
{/* KPI Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<BriefingCard
title="Cash Position"
value={formatCurrency(company_health.cash_position)}
subtitle="Eget kapital"
icon={<DollarSign size={20} />}
trend={company_health.profit_margin > 0 ? 'up' : 'down'}
trendValue={`${formatNumber(company_health.profit_margin)}% marginal`}
variant={company_health.cash_position > 1000000 ? 'success' : company_health.cash_position > 500000 ? 'warning' : 'danger'}
/>
<BriefingCard
title="Pipeline"
value={formatCurrency(company_health.total_pipeline)}
subtitle={`${company_health.active_deals} aktiva deals`}
icon={<Briefcase size={20} />}
trend="up"
trendValue="+12% vs förra månaden"
/>
<BriefingCard
title="Team"
value={`${team_overview.active_now}/${team_overview.total_members}`}
subtitle={`${team_overview.on_leave} på leave`}
icon={<Users size={20} />}
trend={team_overview.on_leave > 0 ? 'neutral' : 'up'}
trendValue={team_overview.on_leave > 0 ? `${team_overview.on_leave} borta idag` : 'Alla närvarande'}
/>
<BriefingCard
title="Mina Tasks"
value={my_tasks.length}
subtitle={`${my_tasks.filter(t => t.priority === 'critical').length} kritiska`}
icon={<Target size={20} />}
variant={my_tasks.filter(t => t.priority === 'critical').length > 0 ? 'danger' : my_tasks.filter(t => t.priority === 'high').length > 0 ? 'warning' : 'success'}
/>
</div>
{/* Alerts */}
{alerts.length > 0 && (
<div className="space-y-2">
<h2 className="text-lg font-semibold flex items-center gap-2">
<AlertTriangle size={20} className="text-warning" />
Varningar
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{alerts.map((alert, i) => (
<AlertCard key={i} alert={alert} />
))}
</div>
</div>
)}
{/* Main Content Grid */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left Column - Actions & Tasks */}
<div className="lg:col-span-2 space-y-6">
{/* Recommended Actions */}
{actions.length > 0 && (
<Card>
<CardHeader
title="Rekommenderade Åtgärder"
subtitle="Prioriterade baserat på din roll och aktuell data"
/>
<div className="p-4 space-y-3">
{actions.map((action, i) => (
<ActionCard key={i} action={action} />
))}
</div>
</Card>
)}
{/* My Tasks */}
<Card>
<CardHeader
title="Mina Uppgifter"
subtitle={`${my_tasks.length} öppna uppgifter`}
/>
<div className="p-4 space-y-1">
{my_tasks.length === 0 ? (
<div className="text-center py-8 text-text-secondary">
<CheckCircle2 size={48} className="mx-auto mb-2 text-success" />
<p>Alla uppgifter är klara!</p>
</div>
) : (
my_tasks.map((task) => (
<TaskCard key={task.id} task={task} />
))
)}
</div>
</Card>
{/* My Deals */}
{my_deals.length > 0 && (
<Card>
<CardHeader
title="Mina Deals"
subtitle={`${my_deals.length} aktiva förhandlingar`}
/>
<div className="p-4 space-y-1">
{my_deals.map((deal) => (
<DealCard key={deal.id} deal={deal} />
))}
</div>
</Card>
)}
</div>
{/* Right Column - Team & Finance */}
<div className="space-y-6">
{/* Team Overview */}
<Card>
<CardHeader
title="Teamöversikt"
subtitle={`${team_overview.total_members} medlemmar`}
/>
<div className="p-4">
<div className="grid grid-cols-3 gap-2 mb-4">
<div className="text-center p-2 bg-success/5 rounded-lg">
<p className="text-2xl font-semibold text-success">{team_overview.active_now}</p>
<p className="text-xs text-text-secondary">Aktiva</p>
</div>
<div className="text-center p-2 bg-warning/5 rounded-lg">
<p className="text-2xl font-semibold text-warning">{team_overview.on_leave}</p>
<p className="text-xs text-text-secondary">Borta</p>
</div>
<div className="text-center p-2 bg-primary/5 rounded-lg">
<p className="text-2xl font-semibold text-primary">{team_overview.open_tickets}</p>
<p className="text-xs text-text-secondary">Tickets</p>
</div>
</div>
<div className="space-y-2">
{team_overview.members.slice(0, 5).map((member, i) => (
<div key={i} className="flex items-center gap-2 p-2 rounded-lg hover:bg-surface-hover transition-colors">
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center text-xs font-medium text-primary">
{member.name.split(' ').map(n => n[0]).join('')}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{member.name}</p>
<p className="text-xs text-text-secondary">{member.role}</p>
</div>
{member.open_tasks > 0 && (
<Badge variant="warning" className="text-xs">{member.open_tasks}</Badge>
)}
</div>
))}
</div>
</div>
</Card>
{/* Financial Status */}
<Card>
<CardHeader
title="Finansiell Status"
subtitle="Från ledger i realtid"
/>
<div className="p-4 space-y-3">
<div className="flex justify-between items-center">
<span className="text-sm text-text-secondary">Tillgångar</span>
<span className="text-sm font-medium">{formatCurrency(financial_status.total_assets)}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-text-secondary">Skulder</span>
<span className="text-sm font-medium">{formatCurrency(financial_status.total_liabilities)}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-text-secondary">Eget Kapital</span>
<span className="text-sm font-medium text-success">{formatCurrency(financial_status.equity)}</span>
</div>
{financial_status.runway_months > 0 && (
<div className="flex justify-between items-center">
<span className="text-sm text-text-secondary">Runway</span>
<span className="text-sm font-medium">{formatNumber(financial_status.runway_months)} mån</span>
</div>
)}
<div className="pt-2 border-t border-border">
<p className="text-xs text-text-tertiary mb-2">Top Konton</p>
{financial_status.top_accounts?.slice(0, 3).map((account, i) => (
<div key={i} className="flex justify-between items-center py-1">
<span className="text-xs text-text-secondary">{account.name}</span>
<span className="text-xs font-medium">{formatCurrency(account.amount)}</span>
</div>
))}
</div>
</div>
</Card>
</div>
</div>
</div>
)
}
+404
View File
@@ -0,0 +1,404 @@
import { useState } from 'react'
import { Button } from '@/components/ui/Button'
import { Card } from '@/components/ui/Card'
import { Badge } from '@/components/ui/Badge'
import {
CheckCircle2,
ArrowRight,
ArrowLeft,
Building2,
Users,
Settings,
Sparkles,
Briefcase,
} from 'lucide-react'
interface OnboardingStep {
id: string
title: string
description: string
icon: React.ElementType
component: React.ReactNode
}
interface OnboardingModalProps {
isOpen: boolean
onComplete: () => void
tenantName?: string
}
export function OnboardingModal({ isOpen, onComplete, tenantName = 'din verksamhet' }: OnboardingModalProps) {
const [currentStep, setCurrentStep] = useState(0)
const [completedSteps, setCompletedSteps] = useState<string[]>([])
const steps: OnboardingStep[] = [
{
id: 'welcome',
title: 'Välkommen till BOC',
description: `Låt oss konfigurera ${tenantName}`,
icon: Sparkles,
component: <WelcomeStep tenantName={tenantName} />,
},
{
id: 'company',
title: 'Företagsinformation',
description: 'Grundläggande uppgifter om verksamheten',
icon: Building2,
component: <CompanyStep />,
},
{
id: 'users',
title: 'Lägg till användare',
description: 'Bjud in teammedlemmar',
icon: Users,
component: <UsersStep />,
},
{
id: 'settings',
title: 'Inställningar',
description: 'Konfigurera bokföring och moms',
icon: Settings,
component: <SettingsStep />,
},
{
id: 'complete',
title: 'Klart!',
description: 'Du är redo att börja',
icon: CheckCircle2,
component: <CompleteStep onComplete={onComplete} />,
},
]
const handleNext = () => {
if (currentStep < steps.length - 1) {
setCompletedSteps([...completedSteps, steps[currentStep].id])
setCurrentStep(currentStep + 1)
}
}
const handleBack = () => {
if (currentStep > 0) {
setCurrentStep(currentStep - 1)
}
}
const handleSkip = () => {
onComplete()
}
if (!isOpen) return null
const step = steps[currentStep]
const isLastStep = currentStep === steps.length - 1
const isFirstStep = currentStep === 0
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="w-full max-w-2xl mx-4">
<Card className="overflow-hidden">
{/* Progress Header */}
<div className="bg-gradient-to-r from-primary to-primary-hover p-6 text-white">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<step.icon size={24} />
<span className="text-sm font-medium">Steg {currentStep + 1} av {steps.length}</span>
</div>
{!isLastStep && (
<button onClick={handleSkip} className="text-sm text-white/80 hover:text-white">
Hoppa över
</button>
)}
</div>
<h2 className="text-2xl font-bold mb-1">{step.title}</h2>
<p className="text-white/80">{step.description}</p>
{/* Progress Bar */}
<div className="flex items-center gap-2 mt-6">
{steps.map((s, i) => (
<div key={s.id} className="flex-1 flex items-center gap-2">
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium transition-colors ${
i <= currentStep ? 'bg-white text-primary' : 'bg-white/20 text-white/60'
}`}>
{completedSteps.includes(s.id) ? (
<CheckCircle2 size={16} />
) : (
i + 1
)}
</div>
{i < steps.length - 1 && (
<div className={`flex-1 h-1 rounded-full transition-colors ${
i < currentStep ? 'bg-white' : 'bg-white/20'
}`} />
)}
</div>
))}
</div>
</div>
{/* Step Content */}
<div className="p-6 min-h-[300px]">
{step.component}
</div>
{/* Footer */}
<div className="p-6 border-t border-border flex items-center justify-between">
<Button
variant="secondary"
onClick={handleBack}
disabled={isFirstStep}
icon={<ArrowLeft size={16} />}
>
Tillbaka
</Button>
{isLastStep ? (
<Button onClick={onComplete} icon={<Sparkles size={16} />}>
Kom igång
</Button>
) : (
<Button onClick={handleNext} icon={<ArrowRight size={16} />}>
Nästa steg
</Button>
)}
</div>
</Card>
</div>
</div>
)
}
function WelcomeStep({ tenantName }: { tenantName: string }) {
return (
<div className="text-center space-y-4">
<div className="w-20 h-20 rounded-2xl bg-primary/10 flex items-center justify-center mx-auto">
<Sparkles size={40} className="text-primary" />
</div>
<h3 className="text-xl font-semibold">Välkommen till Business Operations Center</h3>
<p className="text-text-secondary">
BOC är ditt kompletta affärssystem för att hantera {tenantName}.
Vi ska igenom några snabba steg för att komma igång.
</p>
<div className="grid grid-cols-2 gap-4 mt-6">
{[
{ icon: Building2, label: 'Företagsinfo', desc: 'Organisationsnummer, adress' },
{ icon: Users, label: 'Team', desc: 'Bjud in kollegor' },
{ icon: Briefcase, label: 'Bokföring', desc: 'BAS-kontoplan, moms' },
{ icon: Settings, label: 'Inställningar', desc: 'Valuta, språk' },
].map((item) => (
<div key={item.label} className="p-4 rounded-xl bg-surface border border-border">
<item.icon size={24} className="text-primary mb-2" />
<p className="font-medium text-sm">{item.label}</p>
<p className="text-xs text-text-secondary">{item.desc}</p>
</div>
))}
</div>
</div>
)
}
function CompanyStep() {
const [formData, setFormData] = useState({
orgNumber: '',
companyName: 'Landvex AB',
address: '',
city: '',
postalCode: '',
vatNumber: '',
})
return (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1">Företagsnamn</label>
<input
type="text"
value={formData.companyName}
onChange={(e) => setFormData({ ...formData, companyName: e.target.value })}
className="w-full h-10 px-4 rounded-[14px] border bg-surface text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Organisationsnummer</label>
<input
type="text"
placeholder="559141-7042"
value={formData.orgNumber}
onChange={(e) => setFormData({ ...formData, orgNumber: e.target.value })}
className="w-full h-10 px-4 rounded-[14px] border bg-surface text-sm"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium mb-1">Adress</label>
<input
type="text"
value={formData.address}
onChange={(e) => setFormData({ ...formData, address: e.target.value })}
className="w-full h-10 px-4 rounded-[14px] border bg-surface text-sm"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1">Postnummer</label>
<input
type="text"
value={formData.postalCode}
onChange={(e) => setFormData({ ...formData, postalCode: e.target.value })}
className="w-full h-10 px-4 rounded-[14px] border bg-surface text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Ort</label>
<input
type="text"
value={formData.city}
onChange={(e) => setFormData({ ...formData, city: e.target.value })}
className="w-full h-10 px-4 rounded-[14px] border bg-surface text-sm"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium mb-1">Momsregistreringsnummer</label>
<input
type="text"
placeholder="SE559141704201"
value={formData.vatNumber}
onChange={(e) => setFormData({ ...formData, vatNumber: e.target.value })}
className="w-full h-10 px-4 rounded-[14px] border bg-surface text-sm"
/>
</div>
</div>
)
}
function UsersStep() {
const [emails, setEmails] = useState([''])
const addEmail = () => setEmails([...emails, ''])
const updateEmail = (index: number, value: string) => {
const newEmails = [...emails]
newEmails[index] = value
setEmails(newEmails)
}
return (
<div className="space-y-4">
<p className="text-text-secondary">Bjud in dina teammedlemmar via e-post. De får en inbjudan att med i verksamheten.</p>
{emails.map((email, index) => (
<div key={index} className="flex items-center gap-2">
<input
type="email"
placeholder="namn@foretag.se"
value={email}
onChange={(e) => updateEmail(index, e.target.value)}
className="flex-1 h-10 px-4 rounded-[14px] border bg-surface text-sm"
/>
<select className="h-10 px-3 rounded-[14px] border bg-surface text-sm">
<option value="admin">Admin</option>
<option value="manager">Manager</option>
<option value="user">Användare</option>
</select>
</div>
))}
<Button variant="secondary" onClick={addEmail} className="w-full">
+ Lägg till fler
</Button>
<div className="p-4 bg-primary/5 rounded-xl border border-primary/20">
<p className="text-sm font-medium text-primary">💡 Tips</p>
<p className="text-xs text-text-secondary mt-1">
Du kan alltid bjuda in fler användare senare från HR-modulen.
</p>
</div>
</div>
)
}
function SettingsStep() {
return (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1">Valuta</label>
<select className="w-full h-10 px-4 rounded-[14px] border bg-surface text-sm">
<option value="SEK">SEK (Svenska kronor)</option>
<option value="EUR">EUR (Euro)</option>
<option value="USD">USD (US Dollar)</option>
<option value="NOK">NOK (Norska kronor)</option>
<option value="DKK">DKK (Danska kronor)</option>
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">Bokföringsstandard</label>
<select className="w-full h-10 px-4 rounded-[14px] border bg-surface text-sm">
<option value="BAS2024">BAS 2024 (Sverige)</option>
<option value="BAS2023">BAS 2023 (Sverige)</option>
<option value="EU">EU-standard</option>
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1">Momssats (%)</label>
<input
type="number"
defaultValue={25}
className="w-full h-10 px-4 rounded-[14px] border bg-surface text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Räkenskapsår start</label>
<input
type="date"
defaultValue="2026-01-01"
className="w-full h-10 px-4 rounded-[14px] border bg-surface text-sm"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium mb-1">Språk</label>
<select className="w-full h-10 px-4 rounded-[14px] border bg-surface text-sm">
<option value="sv">Svenska</option>
<option value="en">English</option>
<option value="no">Norsk</option>
<option value="da">Dansk</option>
</select>
</div>
<div className="p-4 bg-warning/5 rounded-xl border border-warning/20">
<p className="text-sm font-medium text-warning"> Viktigt</p>
<p className="text-xs text-text-secondary mt-1">
Dessa inställningar påverkar hela verksamhetens bokföring. Kontrollera med din revisor om du är osäker.
</p>
</div>
</div>
)
}
function CompleteStep({ onComplete: _onComplete }: { onComplete: () => void }) {
return (
<div className="text-center space-y-4">
<div className="w-20 h-20 rounded-full bg-success/10 flex items-center justify-center mx-auto">
<CheckCircle2 size={40} className="text-success" />
</div>
<h3 className="text-xl font-semibold">Allt är klart!</h3>
<p className="text-text-secondary">
Din verksamhet är nu konfigurerad och du är redo att börja använda BOC.
</p>
<div className="grid grid-cols-3 gap-4 mt-6">
{[
{ label: 'Företag', status: 'Klart' },
{ label: 'Team', status: 'Klart' },
{ label: 'Inställningar', status: 'Klart' },
].map((item) => (
<div key={item.label} className="p-3 rounded-xl bg-surface border border-border">
<Badge variant="success" className="mb-2">{item.status}</Badge>
<p className="text-sm font-medium">{item.label}</p>
</div>
))}
</div>
</div>
)
}
+13 -3
View File
@@ -10,7 +10,9 @@ import {
User,
Settings,
ChevronDown,
Building2,
} from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import { getInitials, formatRelativeTime } from '@/lib/utils'
import { cn } from '@/lib/utils'
@@ -41,6 +43,7 @@ const notifications = [
export function Header() {
const { user, logout } = useAuthStore()
const { toggleSidebar } = useUIStore()
const navigate = useNavigate()
const [notifOpen, setNotifOpen] = useState(false)
const [profileOpen, setProfileOpen] = useState(false)
@@ -177,13 +180,20 @@ export function Header() {
<p className="text-xs text-text-secondary">{user?.email || 'user@amos.com'}</p>
</div>
<div className="py-1">
<button className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-text-secondary hover:bg-bg transition-colors">
<button
onClick={() => { navigate('/profile'); setProfileOpen(false) }}
className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-text-secondary hover:bg-bg transition-colors"
>
<User size={15} />
Profile
Min Profil
</button>
<button className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-text-secondary hover:bg-bg transition-colors">
<Building2 size={15} />
Byt Verksamhet
</button>
<button className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-text-secondary hover:bg-bg transition-colors">
<Settings size={15} />
Settings
Inställningar
</button>
<div className="border-t border-border/60 mt-1 pt-1">
<button
+3 -1
View File
@@ -13,11 +13,13 @@ import {
Zap,
ChevronLeft,
ChevronRight,
Newspaper,
} from 'lucide-react'
import { cn } from '@/lib/utils'
const navItems = [
{ path: '/', label: 'Dashboard', icon: LayoutDashboard },
{ path: '/', label: 'Briefing', icon: Newspaper },
{ path: '/dashboard', label: 'Dashboard', icon: LayoutDashboard },
{ path: '/crm', label: 'CRM', icon: Users },
{ path: '/sales', label: 'Sales', icon: TrendingUp },
{ path: '/finance', label: 'Finance', icon: Wallet },
+9
View File
@@ -79,6 +79,9 @@ export const crmApi = {
customers: () => api.get<{ customers: unknown[]; total: number }>('/crm/customers'),
leads: () => api.get<{ leads: unknown[] }>('/crm/leads'),
pipeline: () => api.get<{ stages: unknown[] }>('/crm/pipeline'),
createCustomer: (data: unknown) => api.post<{ customer: unknown }>('/crm/customers', data),
updateCustomer: (id: string, data: unknown) => api.put<{ customer: unknown }>(`/crm/customers/${id}`, data),
deleteCustomer: (id: string) => api.delete<void>(`/crm/customers/${id}`),
}
// Sales
@@ -87,6 +90,8 @@ export const salesApi = {
products: () => api.get<{ products: unknown[] }>('/sales/products'),
mrr: () => api.get<{ mrr: unknown[] }>('/sales/mrr'),
arr: () => api.get<{ arr: unknown[] }>('/sales/arr'),
createDeal: (data: unknown) => api.post<{ deal: unknown }>('/sales/deals', data),
updateDeal: (id: string, data: unknown) => api.put<{ deal: unknown }>(`/sales/deals/${id}`, data),
}
// Finance
@@ -103,9 +108,13 @@ export const financeApi = {
export const hrApi = {
employees: () => api.get<{ employees: unknown[] }>('/hr/employees'),
leaves: () => api.get<{ leaves: unknown[] }>('/hr/leaves'),
createEmployee: (data: unknown) => api.post<{ employee: unknown }>('/hr/employees', data),
createLeave: (data: unknown) => api.post<{ leave: unknown }>('/hr/leaves', data),
}
// Legal
export const legalApi = {
contracts: () => api.get<{ contracts: unknown[] }>('/legal/contracts'),
templates: () => api.get<{ templates: unknown[] }>('/legal/templates'),
createContract: (data: unknown) => api.post<{ contract: unknown }>('/legal/contracts', data),
}
+104
View File
@@ -0,0 +1,104 @@
import { api } from './api'
export interface BriefingUser {
id: string
name: string
email: string
role: string
}
export interface CompanyHealth {
status: string
revenue_ytd: number
expenses_ytd: number
profit_margin: number
cash_position: number
active_deals: number
total_pipeline: number
}
export interface RealTask {
id: string
title: string
priority: string
status: string
days_open: number
category: string
}
export interface RealDeal {
id: string
name: string
customer: string
value: number
currency: string
stage: string
probability: number
days_in_stage: number
}
export interface TeamMember {
name: string
role: string
department: string
status: string
open_tasks: number
}
export interface TeamOverview {
total_members: number
active_now: number
on_leave: number
open_tickets: number
high_priority: number
members: TeamMember[]
}
export interface AccountSummary {
code: string
name: string
type: string
amount: number
}
export interface FinancialStatus {
total_assets: number
total_liabilities: number
equity: number
monthly_burn: number
runway_months: number
top_accounts: AccountSummary[]
}
export interface RealAlert {
level: string
title: string
description: string
source: string
action_url?: string
}
export interface RecommendedAction {
priority: number
title: string
description: string
impact: string
time_estimate: string
related_ids: string[]
}
export interface RealDailyBriefing {
user: BriefingUser
generated_at: string
company_health: CompanyHealth
my_tasks: RealTask[]
my_deals: RealDeal[]
team_overview: TeamOverview
financial_status: FinancialStatus
alerts: RealAlert[]
actions: RecommendedAction[]
}
export const briefingApi = {
getRealBriefing: () => api.get<RealDailyBriefing>('/briefing/real'),
}
+5
View File
@@ -0,0 +1,5 @@
import { BriefingDashboard } from '@/components/BriefingDashboard'
export function BriefingPage() {
return <BriefingDashboard />
}
+59
View File
@@ -23,6 +23,23 @@ import {
ArrowUpRight,
} from 'lucide-react'
// Grafana iframe integration
function GrafanaPanel({ src, title }: { src: string; title: string }) {
return (
<Card className="overflow-hidden">
<CardHeader title={title} />
<iframe
src={src}
width="100%"
height="300"
frameBorder="0"
title={title}
className="bg-surface"
/>
</Card>
)
}
interface Deal {
id: string
name: string
@@ -142,6 +159,48 @@ export function DashboardPage() {
))}
</div>
{/* Infrastructure Health — Grafana Integration */}
<Card>
<CardHeader
title="Infrastructure Health"
subtitle="Real-time system metrics from Grafana"
/>
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6 p-6">
<GrafanaPanel
src="http://localhost:3050/d-solo/amlzdh/aamos-system-health?orgId=1&panelId=1&refresh=5s"
title="CPU Usage"
/>
<GrafanaPanel
src="http://localhost:3050/d-solo/amlzdh/aamos-system-health?orgId=1&panelId=2&refresh=5s"
title="Memory Usage"
/>
<GrafanaPanel
src="http://localhost:3050/d-solo/amlzdh/aamos-system-health?orgId=1&panelId=3&refresh=5s"
title="Disk Usage"
/>
</div>
</Card>
{/* Service Status */}
<Card>
<CardHeader
title="Service Status"
subtitle="All AAMOS services health check"
/>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6 p-6">
<GrafanaPanel
src="http://localhost:3050/d-solo/aamos/service-status?orgId=1&panelId=4"
title="PM2 Processes"
/>
<GrafanaPanel
src="http://localhost:3050/d-solo/aamos/service-status?orgId=1&panelId=5"
title="Docker Containers"
/>
</div>
</Card>
{/* Revenue Chart + Activity */}
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
<div className="xl:col-span-2">
+3 -3
View File
@@ -40,7 +40,7 @@ interface BalanceAccount {
interface BalanceData {
assets: BalanceAccount[]
liabilities: BalanceAccount[]
equity: BalanceAccount[]
equity: BalanceAccount[] | null
total_assets: number
total_liabilities: number
total_equity: number
@@ -317,7 +317,7 @@ export function FinancePage() {
<div>
<h4 className="text-sm font-semibold text-text-primary mb-3">Equity</h4>
<div className="space-y-2">
{balance?.equity.map((account, idx) => (
{balance?.equity?.map((account, idx) => (
<div key={idx} className="flex items-center justify-between py-2 border-b border-border/40 last:border-0">
<div>
<p className="text-sm text-text-primary">{account.account}</p>
@@ -325,7 +325,7 @@ export function FinancePage() {
<p className="text-sm font-medium text-text-primary">{formatCurrency(account.amount)}</p>
</div>
))}
{(!balance?.equity || balance.equity.length === 0) && (
{(!balance?.equity || balance.equity?.length === 0) && (
<p className="text-text-secondary text-sm">No equity accounts</p>
)}
</div>
+19 -11
View File
@@ -25,12 +25,16 @@ import {
interface Employee {
id: string
name: string
first_name: string
last_name: string
name?: string
email: string
department: string
role: string
position: string
role?: string
status: string
startDate: string
start_date?: string
startDate?: string
}
interface Leave {
@@ -90,9 +94,9 @@ export function HRPage() {
const filteredEmployees = employees.filter(
(e) =>
e.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
(e.name || `${e.first_name} ${e.last_name}`).toLowerCase().includes(searchQuery.toLowerCase()) ||
e.department.toLowerCase().includes(searchQuery.toLowerCase()) ||
e.role.toLowerCase().includes(searchQuery.toLowerCase())
(e.role || e.position).toLowerCase().includes(searchQuery.toLowerCase())
)
const filteredLeaves = leaves.filter(
@@ -219,15 +223,19 @@ export function HRPage() {
</TableRow>
</TableHead>
<TableBody>
{filteredEmployees.map((employee) => (
{filteredEmployees.map((employee) => {
const empName = employee.name || `${employee.first_name} ${employee.last_name}`
const empRole = employee.role || employee.position
const empStartDate = employee.startDate || employee.start_date
return (
<TableRow key={employee.id}>
<TableCell>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-primary/10 text-primary flex items-center justify-center text-xs font-semibold">
{employee.name.split(' ').map((n) => n[0]).join('')}
{empName.split(' ').map((n) => n[0]).join('')}
</div>
<div>
<p className="font-medium text-text-primary">{employee.name}</p>
<p className="font-medium text-text-primary">{empName}</p>
<p className="text-xs text-text-secondary">{employee.email}</p>
</div>
</div>
@@ -237,7 +245,7 @@ export function HRPage() {
{employee.department}
</Badge>
</TableCell>
<TableCell>{employee.role}</TableCell>
<TableCell>{empRole}</TableCell>
<TableCell>
<Badge
variant={
@@ -251,14 +259,14 @@ export function HRPage() {
{employee.status.replace('_', ' ')}
</Badge>
</TableCell>
<TableCell>{formatDate(employee.startDate)}</TableCell>
<TableCell>{formatDate(empStartDate || '')}</TableCell>
<TableCell align="right">
<button className="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-bg text-text-secondary">
<MoreHorizontal size={16} />
</button>
</TableCell>
</TableRow>
))}
)})}
{filteredEmployees.length === 0 && (
<TableRow>
<TableCell colSpan={6} className="text-center text-text-secondary py-8">
+449 -150
View File
@@ -1,58 +1,126 @@
import { useEffect, useState } from 'react'
import { Card } from '@/components/ui/Card'
import { Skeleton } from '@/components/ui/Skeleton'
import {
Table,
TableHead,
TableBody,
TableRow,
TableHeader,
TableCell,
} from '@/components/ui/Table'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/Button'
import { Skeleton } from '@/components/ui/Skeleton'
import { Badge } from '@/components/ui/Badge'
import { formatCurrency, formatDate } from '@/lib/utils'
import { legalApi } from '@/lib/api'
import {
FileText,
FileCheck,
Clock,
Plus,
Search,
Filter,
MoreHorizontal,
CheckCircle2,
Clock,
AlertTriangle,
Download,
Eye,
Copy,
Shield,
Briefcase,
Globe,
} from 'lucide-react'
interface Contract {
id: string
title: string
template_type: string
name: string
counterparty: string
type: string
counterparty_org?: string
status: string
startDate: string
endDate?: string
value?: number
currency?: string
start_date?: string
end_date?: string
renewal_date?: string
responsible?: string
created_at: string
}
const tabs = ['All', 'Signed', 'Review', 'Draft', 'Expired']
interface ContractTemplate {
id: string
type: string
name: string
description: string
category: string
product_module?: string
version: string
status: string
language: string
jurisdiction: string
sections: {
id: string
title: string
order: number
required: boolean
content: string
variables?: string[]
}[]
default_terms: {
payment_terms: string
currency: string
vat_rate: number
contract_period: string
notice_period: string
auto_renewal: boolean
sla_enabled: boolean
sla_uptime: number
data_processing: boolean
data_location: string
support_level: string
}
}
interface ProductLink {
product_id: string
product_name: string
product_type: string
required_contracts: string[]
optional_contracts: string[]
}
const statusColors: Record<string, any> = {
draft: { variant: 'default', icon: Clock },
pending_signature: { variant: 'warning', icon: Clock },
active: { variant: 'success', icon: CheckCircle2 },
expired: { variant: 'danger', icon: AlertTriangle },
terminated: { variant: 'default', icon: AlertTriangle },
disputed: { variant: 'danger', icon: AlertTriangle },
}
const categoryColors: Record<string, string> = {
aamos: 'primary',
quixzoom: 'success',
legal: 'warning',
hr: 'default',
}
export function LegalPage() {
const [activeTab, setActiveTab] = useState('All')
const [activeTab, setActiveTab] = useState('contracts')
const [searchQuery, setSearchQuery] = useState('')
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [contracts, setContracts] = useState<Contract[]>([])
const [templates, setTemplates] = useState<ContractTemplate[]>([])
const [productLinks, setProductLinks] = useState<ProductLink[]>([])
const [selectedTemplate, setSelectedTemplate] = useState<ContractTemplate | null>(null)
const [showTemplateModal, setShowTemplateModal] = useState(false)
useEffect(() => {
async function fetchData() {
setLoading(true)
setError('')
try {
const res = await legalApi.contracts()
setContracts((res as { contracts: Contract[] }).contracts || [])
const [contractsRes, templatesRes, linksRes] = await Promise.all([
fetch('/api/v1/legal/contracts').then(r => r.json()),
fetch('/api/v1/legal/templates').then(r => r.json()),
fetch('/api/v1/legal/product-links').then(r => r.json()),
])
setContracts(contractsRes.contracts || [])
setTemplates(templatesRes.templates || [])
setProductLinks(linksRes.links || [])
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load contracts')
setError(err instanceof Error ? err.message : 'Failed to load legal data')
} finally {
setLoading(false)
}
@@ -61,22 +129,24 @@ export function LegalPage() {
fetchData()
}, [])
const signedCount = contracts.filter((c) => c.status === 'signed').length
const reviewCount = contracts.filter((c) => c.status === 'review').length
const totalValue = contracts.reduce((sum, c) => sum + (c.value || 0), 0)
const filteredContracts = contracts.filter(
(c) =>
c.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
c.counterparty.toLowerCase().includes(searchQuery.toLowerCase()) ||
c.template_type.toLowerCase().includes(searchQuery.toLowerCase())
)
const filteredContracts = contracts.filter((c) => {
const matchesSearch =
c.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
c.counterparty.toLowerCase().includes(searchQuery.toLowerCase())
const matchesTab = activeTab === 'All' || c.status === activeTab.toLowerCase()
return matchesSearch && matchesTab
})
const filteredTemplates = templates.filter(
(t) =>
t.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
t.description.toLowerCase().includes(searchQuery.toLowerCase()) ||
t.category.toLowerCase().includes(searchQuery.toLowerCase())
)
if (loading) {
return (
<div className="space-y-8">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-5">
<div className="grid grid-cols-1 sm:grid-cols-4 gap-5">
<Skeleton className="h-[80px]" />
<Skeleton className="h-[80px]" />
<Skeleton className="h-[80px]" />
@@ -95,169 +165,398 @@ export function LegalPage() {
)
}
const tabs = [
{ id: 'contracts', label: 'Avtal', icon: FileText },
{ id: 'templates', label: 'Mallar', icon: Copy },
{ id: 'products', label: 'Produktkopplingar', icon: Briefcase },
]
return (
<div className="space-y-8">
{/* Page Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 className="text-xl font-semibold text-text-primary">Legal</h1>
<p className="text-sm text-text-secondary mt-0.5">Contracts and legal documents</p>
<p className="text-sm text-text-secondary mt-0.5">Avtal, mallar och produktkopplingar</p>
</div>
<Button icon={<Plus size={16} />}>New Contract</Button>
<Button icon={<Plus size={16} />}>Nytt avtal</Button>
</div>
{/* Quick Stats */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-5">
<div className="grid grid-cols-1 sm:grid-cols-4 gap-5">
<Card padding="md">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-primary-light flex items-center justify-center text-primary">
<FileText size={18} />
<div className="w-10 h-10 rounded-xl bg-primary/10 text-primary flex items-center justify-center">
<FileText size={20} />
</div>
<div>
<p className="text-2xl font-semibold text-text-primary">{contracts.length}</p>
<p className="text-xs text-text-secondary">Total Contracts</p>
<p className="text-2xl font-semibold">{contracts.length}</p>
<p className="text-xs text-text-secondary">Totalt avtal</p>
</div>
</div>
</Card>
<Card padding="md">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-success-light flex items-center justify-center text-success">
<FileCheck size={18} />
<div className="w-10 h-10 rounded-xl bg-success/10 text-success flex items-center justify-center">
<CheckCircle2 size={20} />
</div>
<div>
<p className="text-2xl font-semibold text-text-primary">{signedCount}</p>
<p className="text-xs text-text-secondary">Signed</p>
<p className="text-2xl font-semibold">
{contracts.filter(c => c.status === 'active').length}
</p>
<p className="text-xs text-text-secondary">Aktiva</p>
</div>
</div>
</Card>
<Card padding="md">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-warning-light flex items-center justify-center text-warning">
<Clock size={18} />
<div className="w-10 h-10 rounded-xl bg-warning/10 text-warning flex items-center justify-center">
<Clock size={20} />
</div>
<div>
<p className="text-2xl font-semibold text-text-primary">{reviewCount}</p>
<p className="text-xs text-text-secondary">Under Review</p>
<p className="text-2xl font-semibold">
{contracts.filter(c => c.status === 'pending_signature').length}
</p>
<p className="text-xs text-text-secondary">Väntar signering</p>
</div>
</div>
</Card>
<Card padding="md">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-primary-light flex items-center justify-center text-primary">
<FileText size={18} />
<div className="w-10 h-10 rounded-xl bg-danger/10 text-danger flex items-center justify-center">
<AlertTriangle size={20} />
</div>
<div>
<p className="text-2xl font-semibold text-text-primary">{formatCurrency(totalValue)}</p>
<p className="text-xs text-text-secondary">Total Value</p>
<p className="text-2xl font-semibold">
{contracts.filter(c => c.status === 'expired' || c.status === 'terminated').length}
</p>
<p className="text-xs text-text-secondary">Utgångna</p>
</div>
</div>
</Card>
</div>
{/* Tabs + Search */}
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div className="flex items-center gap-1 bg-bg rounded-xl p-1">
{tabs.map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
activeTab === tab
? 'bg-surface text-text-primary shadow-sm'
: 'text-text-secondary hover:text-text-primary'
}`}
{/* Tabs */}
<div className="flex items-center gap-1 bg-bg rounded-xl p-1 overflow-x-auto">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors whitespace-nowrap flex items-center gap-2 ${
activeTab === tab.id
? 'bg-surface text-text-primary shadow-sm'
: 'text-text-secondary hover:text-text-primary'
}`}
>
<tab.icon size={16} />
{tab.label}
</button>
))}
</div>
{/* Search */}
<div className="relative">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-secondary" />
<input
type="text"
placeholder="Sök avtal, mallar eller produkter..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full h-10 pl-9 pr-4 rounded-[14px] border bg-surface text-sm"
/>
</div>
{/* Contracts Tab */}
{activeTab === 'contracts' && (
<Card>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-border">
<th className="text-left text-xs font-medium text-text-secondary uppercase tracking-wider px-4 py-3">Avtal</th>
<th className="text-left text-xs font-medium text-text-secondary uppercase tracking-wider px-4 py-3">Motpart</th>
<th className="text-left text-xs font-medium text-text-secondary uppercase tracking-wider px-4 py-3">Typ</th>
<th className="text-left text-xs font-medium text-text-secondary uppercase tracking-wider px-4 py-3">Status</th>
<th className="text-left text-xs font-medium text-text-secondary uppercase tracking-wider px-4 py-3">Värde</th>
<th className="text-left text-xs font-medium text-text-secondary uppercase tracking-wider px-4 py-3">Period</th>
<th className="text-right text-xs font-medium text-text-secondary uppercase tracking-wider px-4 py-3"></th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{filteredContracts.map((contract) => {
const statusConfig = statusColors[contract.status] || statusColors.draft
return (
<tr key={contract.id} className="hover:bg-bg transition-colors">
<td className="px-4 py-3">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-primary/10 text-primary flex items-center justify-center">
<FileText size={14} />
</div>
<div>
<p className="text-sm font-medium text-text-primary">{contract.name}</p>
<p className="text-xs text-text-secondary">{contract.template_type}</p>
</div>
</div>
</td>
<td className="px-4 py-3">
<p className="text-sm text-text-primary">{contract.counterparty}</p>
{contract.counterparty_org && (
<p className="text-xs text-text-secondary">{contract.counterparty_org}</p>
)}
</td>
<td className="px-4 py-3">
<Badge variant="default" className="text-xs">
{contract.template_type}
</Badge>
</td>
<td className="px-4 py-3">
<Badge variant={statusConfig.variant as any} className="text-xs flex items-center gap-1">
<statusConfig.icon size={12} />
{contract.status}
</Badge>
</td>
<td className="px-4 py-3">
{contract.value ? (
<p className="text-sm font-medium">{formatCurrency(contract.value)} {contract.currency}</p>
) : (
<p className="text-sm text-text-secondary"></p>
)}
</td>
<td className="px-4 py-3">
<p className="text-sm text-text-secondary">
{contract.start_date ? formatDate(contract.start_date) : '—'}
{contract.end_date && `${formatDate(contract.end_date)}`}
</p>
</td>
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-1">
<button className="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-bg text-text-secondary">
<Eye size={14} />
</button>
<button className="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-bg text-text-secondary">
<Download size={14} />
</button>
<button className="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-bg text-text-secondary">
<MoreHorizontal size={14} />
</button>
</div>
</td>
</tr>
)
})}
{filteredContracts.length === 0 && (
<tr>
<td colSpan={7} className="text-center text-text-secondary py-8">
Inga avtal hittades
</td>
</tr>
)}
</tbody>
</table>
</div>
</Card>
)}
{/* Templates Tab */}
{activeTab === 'templates' && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredTemplates.map((template) => (
<div
key={template.id}
className="cursor-pointer hover:border-primary transition-colors"
onClick={() => {
setSelectedTemplate(template)
setShowTemplateModal(true)
}}
>
{tab}
</button>
<Card>
<div className="p-4">
<div className="flex items-start justify-between mb-3">
<div className="w-10 h-10 rounded-xl bg-primary/10 text-primary flex items-center justify-center">
<FileText size={20} />
</div>
<Badge variant={categoryColors[template.category] as any} className="text-xs">
{template.category}
</Badge>
</div>
<h3 className="font-semibold text-text-primary mb-1">{template.name}</h3>
<p className="text-sm text-text-secondary mb-3">{template.description}</p>
<div className="flex items-center gap-2 text-xs text-text-tertiary">
<Globe size={12} />
{template.jurisdiction}
<span className="mx-1"></span>
<Shield size={12} />
v{template.version}
<span className="mx-1"></span>
{template.sections.length} sektioner
</div>
</div>
</Card>
</div>
))}
{filteredTemplates.length === 0 && (
<div className="col-span-full text-center text-text-secondary py-8">
Inga mallar hittades
</div>
)}
</div>
)}
{/* Products Tab */}
{activeTab === 'products' && (
<div className="space-y-6">
{productLinks.map((link) => (
<Card key={link.product_id}>
<div className="p-4">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-xl bg-primary/10 text-primary flex items-center justify-center">
<Briefcase size={20} />
</div>
<div>
<h3 className="font-semibold text-text-primary">{link.product_name}</h3>
<p className="text-sm text-text-secondary">{link.product_type}</p>
</div>
</div>
<div className="space-y-3">
<div>
<p className="text-xs font-medium text-text-secondary uppercase mb-2">Krävs</p>
<div className="flex flex-wrap gap-2">
{link.required_contracts.map((contract) => (
<Badge key={contract} variant="primary" className="text-xs">
{contract}
</Badge>
))}
</div>
</div>
{link.optional_contracts.length > 0 && (
<div>
<p className="text-xs font-medium text-text-secondary uppercase mb-2">Valbart</p>
<div className="flex flex-wrap gap-2">
{link.optional_contracts.map((contract) => (
<Badge key={contract} variant="default" className="text-xs">
{contract}
</Badge>
))}
</div>
</div>
)}
</div>
</div>
</Card>
))}
</div>
<div className="flex items-center gap-2 w-full sm:w-auto">
<div className="relative flex-1 sm:flex-initial">
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-secondary" />
<input
type="text"
placeholder="Search..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full sm:w-64 h-10 pl-9 pr-4 rounded-[14px] border bg-surface text-sm text-text-primary placeholder:text-text-secondary/50 focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary/30"
/>
</div>
<Button variant="secondary" size="sm" icon={<Filter size={14} />}>
Filter
</Button>
</div>
</div>
)}
{/* Contracts Table */}
<Card>
<Table>
<TableHead>
<TableRow>
<TableHeader>Contract</TableHeader>
<TableHeader>Counterparty</TableHeader>
<TableHeader>Type</TableHeader>
<TableHeader align="right">Value</TableHeader>
<TableHeader>Status</TableHeader>
<TableHeader>Period</TableHeader>
<TableHeader align="right"></TableHeader>
</TableRow>
</TableHead>
<TableBody>
{filteredContracts.map((contract) => (
<TableRow key={contract.id}>
<TableCell>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-primary/10 text-primary flex items-center justify-center">
<FileText size={14} />
</div>
<span className="font-medium text-text-primary">{contract.title}</span>
{/* Template Detail Modal */}
{showTemplateModal && selectedTemplate && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="w-full max-w-3xl max-h-[90vh] overflow-y-auto">
<Card>
<div className="p-6">
<div className="flex items-start justify-between mb-6">
<div>
<h2 className="text-xl font-semibold">{selectedTemplate.name}</h2>
<p className="text-text-secondary">{selectedTemplate.description}</p>
</div>
</TableCell>
<TableCell>{contract.counterparty}</TableCell>
<TableCell>
<Badge variant="default">{contract.type}</Badge>
</TableCell>
<TableCell align="right">
{contract.value && contract.value > 0 ? formatCurrency(contract.value) : '—'}
</TableCell>
<TableCell>
<Badge
variant={
contract.status === 'signed'
? 'success'
: contract.status === 'review'
? 'warning'
: contract.status === 'draft'
? 'default'
: 'danger'
}
<button
onClick={() => setShowTemplateModal(false)}
className="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-bg text-text-secondary"
>
{contract.status}
</Badge>
</TableCell>
<TableCell>
<span className="text-sm text-text-secondary">
{formatDate(contract.startDate)}
{contract.endDate && `${formatDate(contract.endDate)}`}
</span>
</TableCell>
<TableCell align="right">
<button className="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-bg text-text-secondary">
<MoreHorizontal size={16} />
</button>
</TableCell>
</TableRow>
))}
{filteredContracts.length === 0 && (
<TableRow>
<TableCell colSpan={7} className="text-center text-text-secondary py-8">
No contracts found
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</Card>
</div>
<div className="grid grid-cols-2 gap-4 mb-6">
<div className="p-3 bg-surface rounded-lg">
<p className="text-xs text-text-secondary">Kategori</p>
<p className="font-medium">{selectedTemplate.category}</p>
</div>
<div className="p-3 bg-surface rounded-lg">
<p className="text-xs text-text-secondary">Jurisdiktion</p>
<p className="font-medium">{selectedTemplate.jurisdiction}</p>
</div>
<div className="p-3 bg-surface rounded-lg">
<p className="text-xs text-text-secondary">Språk</p>
<p className="font-medium">{selectedTemplate.language}</p>
</div>
<div className="p-3 bg-surface rounded-lg">
<p className="text-xs text-text-secondary">Version</p>
<p className="font-medium">{selectedTemplate.version}</p>
</div>
</div>
<h3 className="font-semibold mb-3">Avtalssektioner</h3>
<div className="space-y-2 mb-6">
{selectedTemplate.sections.map((section) => (
<div key={section.id} className="p-3 bg-surface rounded-lg">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs text-text-tertiary">{section.order}.</span>
<p className="font-medium text-sm">{section.title}</p>
{section.required && (
<Badge variant="danger" className="text-xs">Krävs</Badge>
)}
</div>
<p className="text-sm text-text-secondary">{section.content}</p>
{section.variables && section.variables.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{section.variables.map((v) => (
<code key={v} className="text-xs bg-primary/10 text-primary px-2 py-0.5 rounded">
{'{{' + v + '}}'}
</code>
))}
</div>
)}
</div>
))}
</div>
<h3 className="font-semibold mb-3">Standardvillkor</h3>
<div className="grid grid-cols-2 gap-3 mb-6">
<div className="p-3 bg-surface rounded-lg">
<p className="text-xs text-text-secondary">Betalningsvillkor</p>
<p className="text-sm">{selectedTemplate.default_terms.payment_terms}</p>
</div>
<div className="p-3 bg-surface rounded-lg">
<p className="text-xs text-text-secondary">Valuta</p>
<p className="text-sm">{selectedTemplate.default_terms.currency}</p>
</div>
<div className="p-3 bg-surface rounded-lg">
<p className="text-xs text-text-secondary">Avtalsperiod</p>
<p className="text-sm">{selectedTemplate.default_terms.contract_period}</p>
</div>
<div className="p-3 bg-surface rounded-lg">
<p className="text-xs text-text-secondary">Uppsägningstid</p>
<p className="text-sm">{selectedTemplate.default_terms.notice_period}</p>
</div>
<div className="p-3 bg-surface rounded-lg">
<p className="text-xs text-text-secondary">Auto-förnyelse</p>
<p className="text-sm">{selectedTemplate.default_terms.auto_renewal ? 'Ja' : 'Nej'}</p>
</div>
<div className="p-3 bg-surface rounded-lg">
<p className="text-xs text-text-secondary">SLA</p>
<p className="text-sm">
{selectedTemplate.default_terms.sla_enabled
? `${selectedTemplate.default_terms.sla_uptime}% uptime`
: 'Ej aktiverat'}
</p>
</div>
</div>
<div className="flex gap-3">
<Button className="flex-1">
<Plus size={16} className="mr-2" />
Skapa avtal från mall
</Button>
<Button variant="secondary">
<Download size={16} className="mr-2" />
Förhandsgranska
</Button>
</div>
</div>
</Card>
</div>
</div>
)}
</div>
)
}
+438
View File
@@ -0,0 +1,438 @@
import { useState, useEffect } from 'react'
import { Card, CardHeader } from '@/components/ui/Card'
import { Button } from '@/components/ui/Button'
import { Skeleton } from '@/components/ui/Skeleton'
import { Badge } from '@/components/ui/Badge'
import { useAuthStore } from '@/stores/authStore'
import { formatDate } from '@/lib/utils'
import {
User,
Mail,
Phone,
Building2,
Shield,
Bell,
Moon,
Globe,
Key,
Camera,
Save,
Sun,
Monitor,
} from 'lucide-react'
interface UserProfile {
id: string
name: string
email: string
phone?: string
avatar?: string
role: string
department?: string
timezone: string
language: string
notifications: {
email: boolean
push: boolean
sms: boolean
}
theme: 'light' | 'dark' | 'system'
twoFactorEnabled: boolean
lastLogin: string
createdAt: string
}
export function ProfilePage() {
const { user } = useAuthStore()
const [profile, setProfile] = useState<UserProfile | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [activeTab, setActiveTab] = useState('general')
useEffect(() => {
// Mock data - skulle hämtas från API
setTimeout(() => {
setProfile({
id: user?.id || '3847477b-3d56-4975-9157-ae8f9ce52aa7',
name: user?.name || 'Erik Svensson',
email: user?.email || 'erik@landvex.com',
phone: '+46 70 123 45 67',
role: user?.role || 'admin',
department: 'Ledning',
timezone: 'Europe/Stockholm',
language: 'sv',
notifications: {
email: true,
push: true,
sms: false,
},
theme: 'light',
twoFactorEnabled: false,
lastLogin: new Date().toISOString(),
createdAt: '2026-01-15T10:30:00Z',
})
setLoading(false)
}, 500)
}, [user])
const handleSave = async () => {
setSaving(true)
// TODO: API call to save profile
await new Promise(resolve => setTimeout(resolve, 1000))
setSaving(false)
}
if (loading) {
return (
<div className="space-y-6">
<Skeleton className="h-[200px]" />
<Skeleton className="h-[400px]" />
</div>
)
}
if (!profile) return null
const tabs = [
{ id: 'general', label: 'Allmänt', icon: User },
{ id: 'security', label: 'Säkerhet', icon: Shield },
{ id: 'notifications', label: 'Notifikationer', icon: Bell },
{ id: 'preferences', label: 'Inställningar', icon: Globe },
]
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Min Profil</h1>
<p className="text-text-secondary">Hantera dina kontoinställningar</p>
</div>
<Button
onClick={handleSave}
disabled={saving}
icon={saving ? undefined : <Save size={16} />}
>
{saving ? 'Sparar...' : 'Spara ändringar'}
</Button>
</div>
{/* Profile Header Card */}
<Card className="relative overflow-hidden">
<div className="h-32 bg-gradient-to-r from-primary to-primary-hover" />
<div className="px-6 pb-6">
<div className="relative -mt-16 mb-4">
<div className="w-32 h-32 rounded-2xl bg-surface border-4 border-bg flex items-center justify-center text-4xl font-bold text-primary relative">
{profile.avatar ? (
<img src={profile.avatar} alt={profile.name} className="w-full h-full rounded-2xl object-cover" />
) : (
profile.name.split(' ').map(n => n[0]).join('')
)}
<button className="absolute bottom-0 right-0 w-8 h-8 bg-primary text-white rounded-full flex items-center justify-center hover:bg-primary-hover transition-colors">
<Camera size={14} />
</button>
</div>
</div>
<div className="flex items-start justify-between">
<div>
<h2 className="text-xl font-semibold">{profile.name}</h2>
<p className="text-text-secondary">{profile.email}</p>
<div className="flex items-center gap-2 mt-2">
<Badge variant="primary">{profile.role}</Badge>
<Badge variant="default">{profile.department}</Badge>
</div>
</div>
<div className="text-right text-sm text-text-secondary">
<p>Senaste inloggning: {formatDate(profile.lastLogin)}</p>
<p>Medlem sedan: {formatDate(profile.createdAt)}</p>
</div>
</div>
</div>
</Card>
{/* Tabs */}
<div className="flex items-center gap-1 bg-bg rounded-xl p-1 overflow-x-auto">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors whitespace-nowrap flex items-center gap-2 ${
activeTab === tab.id
? 'bg-surface text-text-primary shadow-sm'
: 'text-text-secondary hover:text-text-primary'
}`}
>
<tab.icon size={16} />
{tab.label}
</button>
))}
</div>
{/* Tab Content */}
{activeTab === 'general' && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card>
<CardHeader title="Personlig information" subtitle="Uppdatera dina kontouppgifter" />
<div className="p-4 space-y-4">
<div>
<label className="block text-sm font-medium mb-1">Fullständigt namn</label>
<div className="relative">
<User size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-secondary" />
<input
type="text"
value={profile.name}
onChange={(e) => setProfile({ ...profile, name: e.target.value })}
className="w-full h-10 pl-9 pr-4 rounded-[14px] border bg-surface text-sm"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium mb-1">E-post</label>
<div className="relative">
<Mail size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-secondary" />
<input
type="email"
value={profile.email}
onChange={(e) => setProfile({ ...profile, email: e.target.value })}
className="w-full h-10 pl-9 pr-4 rounded-[14px] border bg-surface text-sm"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium mb-1">Telefon</label>
<div className="relative">
<Phone size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-secondary" />
<input
type="tel"
value={profile.phone || ''}
onChange={(e) => setProfile({ ...profile, phone: e.target.value })}
className="w-full h-10 pl-9 pr-4 rounded-[14px] border bg-surface text-sm"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium mb-1">Avdelning</label>
<div className="relative">
<Building2 size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-secondary" />
<input
type="text"
value={profile.department || ''}
onChange={(e) => setProfile({ ...profile, department: e.target.value })}
className="w-full h-10 pl-9 pr-4 rounded-[14px] border bg-surface text-sm"
/>
</div>
</div>
</div>
</Card>
<Card>
<CardHeader title="Kontoinformation" subtitle="Dina kontodetaljer" />
<div className="p-4 space-y-4">
<div className="flex items-center justify-between py-3 border-b border-border">
<div>
<p className="text-sm font-medium">Användar-ID</p>
<p className="text-xs text-text-secondary">{profile.id}</p>
</div>
<Badge variant="default">Aktiv</Badge>
</div>
<div className="flex items-center justify-between py-3 border-b border-border">
<div>
<p className="text-sm font-medium">Roll</p>
<p className="text-xs text-text-secondary">{profile.role}</p>
</div>
<Shield size={16} className="text-primary" />
</div>
<div className="flex items-center justify-between py-3 border-b border-border">
<div>
<p className="text-sm font-medium">Tidszon</p>
<p className="text-xs text-text-secondary">{profile.timezone}</p>
</div>
<Globe size={16} className="text-text-secondary" />
</div>
<div className="flex items-center justify-between py-3">
<div>
<p className="text-sm font-medium">Språk</p>
<p className="text-xs text-text-secondary">{profile.language === 'sv' ? 'Svenska' : profile.language}</p>
</div>
<Globe size={16} className="text-text-secondary" />
</div>
</div>
</Card>
</div>
)}
{activeTab === 'security' && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card>
<CardHeader title="Lösenord" subtitle="Uppdatera ditt lösenord" />
<div className="p-4 space-y-4">
<div>
<label className="block text-sm font-medium mb-1">Nuvarande lösenord</label>
<div className="relative">
<Key size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-secondary" />
<input
type="password"
placeholder="••••••••"
className="w-full h-10 pl-9 pr-4 rounded-[14px] border bg-surface text-sm"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium mb-1">Nytt lösenord</label>
<input
type="password"
placeholder="Minst 8 tecken"
className="w-full h-10 px-4 rounded-[14px] border bg-surface text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Bekräfta nytt lösenord</label>
<input
type="password"
placeholder="Upprepa lösenord"
className="w-full h-10 px-4 rounded-[14px] border bg-surface text-sm"
/>
</div>
<Button variant="secondary" className="w-full">
<Key size={16} className="mr-2" />
Ändra lösenord
</Button>
</div>
</Card>
<Card>
<CardHeader title="Tvåfaktorsautentisering" subtitle="Öka säkerheten på ditt konto" />
<div className="p-4">
<div className="flex items-center justify-between mb-4">
<div>
<p className="text-sm font-medium">2FA-status</p>
<p className="text-xs text-text-secondary">
{profile.twoFactorEnabled ? 'Aktiverad' : 'Ej aktiverad'}
</p>
</div>
<Badge variant={profile.twoFactorEnabled ? 'success' : 'default'}>
{profile.twoFactorEnabled ? 'Aktiv' : 'Inaktiv'}
</Badge>
</div>
{!profile.twoFactorEnabled && (
<Button className="w-full">
<Shield size={16} className="mr-2" />
Aktivera 2FA
</Button>
)}
</div>
</Card>
</div>
)}
{activeTab === 'notifications' && (
<Card>
<CardHeader title="Notifikationsinställningar" subtitle="Hantera hur du får notifieringar" />
<div className="p-4 space-y-4">
{[
{ key: 'email', label: 'E-postnotifikationer', description: 'Få uppdateringar via e-post', icon: Mail },
{ key: 'push', label: 'Push-notifikationer', description: 'Få notifikationer i webbläsaren', icon: Bell },
{ key: 'sms', label: 'SMS-notifikationer', description: 'Få viktiga uppdateringar via SMS', icon: Phone },
].map((item) => (
<div key={item.key} className="flex items-center justify-between py-3 border-b border-border last:border-0">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center text-primary">
<item.icon size={18} />
</div>
<div>
<p className="text-sm font-medium">{item.label}</p>
<p className="text-xs text-text-secondary">{item.description}</p>
</div>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={profile.notifications[item.key as keyof typeof profile.notifications]}
onChange={(e) => setProfile({
...profile,
notifications: {
...profile.notifications,
[item.key]: e.target.checked,
},
})}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-border peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary" />
</label>
</div>
))}
</div>
</Card>
)}
{activeTab === 'preferences' && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card>
<CardHeader title="Utseende" subtitle="Anpassa hur BOC ser ut" />
<div className="p-4 space-y-4">
<div>
<label className="block text-sm font-medium mb-2">Tema</label>
<div className="grid grid-cols-3 gap-3">
{[
{ id: 'light', label: 'Ljust', icon: Sun },
{ id: 'dark', label: 'Mörkt', icon: Moon },
{ id: 'system', label: 'System', icon: Monitor },
].map((theme) => (
<button
key={theme.id}
onClick={() => setProfile({ ...profile, theme: theme.id as any })}
className={`p-3 rounded-xl border text-center transition-colors ${
profile.theme === theme.id
? 'border-primary bg-primary/5 text-primary'
: 'border-border hover:border-primary/30'
}`}
>
<theme.icon size={20} className="mx-auto mb-1" />
<span className="text-xs">{theme.label}</span>
</button>
))}
</div>
</div>
</div>
</Card>
<Card>
<CardHeader title="Regionala inställningar" subtitle="Språk och tidszon" />
<div className="p-4 space-y-4">
<div>
<label className="block text-sm font-medium mb-1">Språk</label>
<select
value={profile.language}
onChange={(e) => setProfile({ ...profile, language: e.target.value })}
className="w-full h-10 px-4 rounded-[14px] border bg-surface text-sm"
>
<option value="sv">Svenska</option>
<option value="en">English</option>
<option value="no">Norsk</option>
<option value="da">Dansk</option>
<option value="fi">Suomi</option>
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">Tidszon</label>
<select
value={profile.timezone}
onChange={(e) => setProfile({ ...profile, timezone: e.target.value })}
className="w-full h-10 px-4 rounded-[14px] border bg-surface text-sm"
>
<option value="Europe/Stockholm">Stockholm (CET)</option>
<option value="Europe/Oslo">Oslo (CET)</option>
<option value="Europe/Copenhagen">Köpenhamn (CET)</option>
<option value="Europe/Helsinki">Helsingfors (EET)</option>
<option value="UTC">UTC</option>
</select>
</div>
</div>
</Card>
</div>
)}
</div>
)
}
+114
View File
@@ -0,0 +1,114 @@
import { create } from 'zustand'
import { crmApi } from '@/lib/api'
export interface Customer {
id: string
name: string
email: string
phone: string
company: string
org_number: string
status: string
source: string
tags: string[]
assigned_to: string | null
created_at: string
updated_at: string
}
export interface Lead {
id: string
name: string
email: string
company: string
status: string
source: string
}
export interface PipelineStage {
stage: string
count: number
value: number
}
interface CRMState {
customers: Customer[]
leads: Lead[]
pipeline: PipelineStage[]
loading: boolean
error: string | null
fetchCustomers: () => Promise<void>
fetchLeads: () => Promise<void>
fetchPipeline: () => Promise<void>
createCustomer: (data: Partial<Customer>) => Promise<void>
updateCustomer: (id: string, data: Partial<Customer>) => Promise<void>
deleteCustomer: (id: string) => Promise<void>
}
export const useCRMStore = create<CRMState>((set, get) => ({
customers: [],
leads: [],
pipeline: [],
loading: false,
error: null,
fetchCustomers: async () => {
set({ loading: true, error: null })
try {
const data = await crmApi.customers()
set({ customers: (data.customers || []) as Customer[], loading: false })
} catch (err) {
set({ error: err instanceof Error ? err.message : 'Failed to fetch customers', loading: false })
}
},
fetchLeads: async () => {
set({ loading: true, error: null })
try {
const data = await crmApi.leads()
set({ leads: (data.leads || []) as Lead[], loading: false })
} catch (err) {
set({ error: err instanceof Error ? err.message : 'Failed to fetch leads', loading: false })
}
},
fetchPipeline: async () => {
set({ loading: true, error: null })
try {
const data = await crmApi.pipeline()
set({ pipeline: (data.stages || []) as PipelineStage[], loading: false })
} catch (err) {
set({ error: err instanceof Error ? err.message : 'Failed to fetch pipeline', loading: false })
}
},
createCustomer: async (data) => {
set({ loading: true, error: null })
try {
await crmApi.createCustomer(data)
await get().fetchCustomers()
} catch (err) {
set({ error: err instanceof Error ? err.message : 'Failed to create customer', loading: false })
}
},
updateCustomer: async (id, data) => {
set({ loading: true, error: null })
try {
await crmApi.updateCustomer(id, data)
await get().fetchCustomers()
} catch (err) {
set({ error: err instanceof Error ? err.message : 'Failed to update customer', loading: false })
}
},
deleteCustomer: async (id) => {
set({ loading: true, error: null })
try {
await crmApi.deleteCustomer(id)
await get().fetchCustomers()
} catch (err) {
set({ error: err instanceof Error ? err.message : 'Failed to delete customer', loading: false })
}
},
}))
+102
View File
@@ -0,0 +1,102 @@
import { create } from 'zustand'
import { financeApi } from '@/lib/api'
export interface Account {
code: string
name: string
type: string
balance: number
}
export interface Invoice {
id: string
customer_id: string
amount: number
currency: string
status: string
due_date: string
}
interface FinanceState {
accounts: Account[]
invoices: Invoice[]
balance: unknown
income: unknown
moms: unknown
cashflow: unknown
loading: boolean
error: string | null
fetchAccounts: () => Promise<void>
fetchInvoices: () => Promise<void>
fetchBalance: () => Promise<void>
fetchIncome: () => Promise<void>
fetchMoms: () => Promise<void>
fetchCashflow: () => Promise<void>
}
export const useFinanceStore = create<FinanceState>((set) => ({
accounts: [],
invoices: [],
balance: null,
income: null,
moms: null,
cashflow: null,
loading: false,
error: null,
fetchAccounts: async () => {
set({ loading: true, error: null })
try {
const data = await financeApi.accounts()
set({ accounts: (data.accounts || []) as Account[], loading: false })
} catch (err) {
set({ error: err instanceof Error ? err.message : 'Failed to fetch accounts', loading: false })
}
},
fetchInvoices: async () => {
set({ loading: true, error: null })
try {
const data = await financeApi.invoices()
set({ invoices: (data.invoices || []) as Invoice[], loading: false })
} catch (err) {
set({ error: err instanceof Error ? err.message : 'Failed to fetch invoices', loading: false })
}
},
fetchBalance: async () => {
try {
const data = await financeApi.balance()
set({ balance: data })
} catch (err) {
console.error('Failed to fetch balance:', err)
}
},
fetchIncome: async () => {
try {
const data = await financeApi.income()
set({ income: data })
} catch (err) {
console.error('Failed to fetch income:', err)
}
},
fetchMoms: async () => {
try {
const data = await financeApi.moms()
set({ moms: data })
} catch (err) {
console.error('Failed to fetch moms:', err)
}
},
fetchCashflow: async () => {
try {
const data = await financeApi.cashflow()
set({ cashflow: data })
} catch (err) {
console.error('Failed to fetch cashflow:', err)
}
},
}))
+80
View File
@@ -0,0 +1,80 @@
import { create } from 'zustand'
import { hrApi } from '@/lib/api'
export interface Employee {
id: string
name: string
email: string
phone: string
department: string
position: string
start_date: string
status: string
}
export interface Leave {
id: string
employee_id: string
type: string
start_date: string
end_date: string
status: string
}
interface HRState {
employees: Employee[]
leaves: Leave[]
loading: boolean
error: string | null
fetchEmployees: () => Promise<void>
fetchLeaves: () => Promise<void>
createEmployee: (data: Partial<Employee>) => Promise<void>
createLeave: (data: Partial<Leave>) => Promise<void>
}
export const useHRStore = create<HRState>((set, get) => ({
employees: [],
leaves: [],
loading: false,
error: null,
fetchEmployees: async () => {
set({ loading: true, error: null })
try {
const data = await hrApi.employees()
set({ employees: (data.employees || []) as Employee[], loading: false })
} catch (err) {
set({ error: err instanceof Error ? err.message : 'Failed to fetch employees', loading: false })
}
},
fetchLeaves: async () => {
set({ loading: true, error: null })
try {
const data = await hrApi.leaves()
set({ leaves: (data.leaves || []) as Leave[], loading: false })
} catch (err) {
set({ error: err instanceof Error ? err.message : 'Failed to fetch leaves', loading: false })
}
},
createEmployee: async (data) => {
set({ loading: true, error: null })
try {
await hrApi.createEmployee(data)
await get().fetchEmployees()
} catch (err) {
set({ error: err instanceof Error ? err.message : 'Failed to create employee', loading: false })
}
},
createLeave: async (data) => {
set({ loading: true, error: null })
try {
await hrApi.createLeave(data)
await get().fetchLeaves()
} catch (err) {
set({ error: err instanceof Error ? err.message : 'Failed to create leave', loading: false })
}
},
}))
+61
View File
@@ -0,0 +1,61 @@
import { create } from 'zustand'
import { legalApi } from '@/lib/api'
export interface Contract {
id: string
title: string
type: string
customer_id: string
value: number
currency: string
start_date: string
end_date: string
status: string
}
interface LegalState {
contracts: Contract[]
templates: unknown[]
loading: boolean
error: string | null
fetchContracts: () => Promise<void>
fetchTemplates: () => Promise<void>
createContract: (data: Partial<Contract>) => Promise<void>
}
export const useLegalStore = create<LegalState>((set, get) => ({
contracts: [],
templates: [],
loading: false,
error: null,
fetchContracts: async () => {
set({ loading: true, error: null })
try {
const data = await legalApi.contracts()
set({ contracts: (data.contracts || []) as Contract[], loading: false })
} catch (err) {
set({ error: err instanceof Error ? err.message : 'Failed to fetch contracts', loading: false })
}
},
fetchTemplates: async () => {
set({ loading: true, error: null })
try {
const data = await legalApi.templates()
set({ templates: (data.templates || []) as unknown[], loading: false })
} catch (err) {
set({ error: err instanceof Error ? err.message : 'Failed to fetch templates', loading: false })
}
},
createContract: async (data) => {
set({ loading: true, error: null })
try {
await legalApi.createContract(data)
await get().fetchContracts()
} catch (err) {
set({ error: err instanceof Error ? err.message : 'Failed to create contract', loading: false })
}
},
}))
+107
View File
@@ -0,0 +1,107 @@
import { create } from 'zustand'
import { salesApi } from '@/lib/api'
export interface Deal {
id: string
name: string
customer_id: string
value: number
currency: string
status: string
stage: string
probability: number
expected_close: string
assigned_to: string | null
}
export interface Product {
id: string
name: string
description: string
price: number
currency: string
sku: string
status: string
}
interface SalesState {
deals: Deal[]
products: Product[]
mrr: number
arr: number
loading: boolean
error: string | null
fetchDeals: () => Promise<void>
fetchProducts: () => Promise<void>
fetchMRR: () => Promise<void>
fetchARR: () => Promise<void>
createDeal: (data: Partial<Deal>) => Promise<void>
updateDeal: (id: string, data: Partial<Deal>) => Promise<void>
}
export const useSalesStore = create<SalesState>((set, get) => ({
deals: [],
products: [],
mrr: 0,
arr: 0,
loading: false,
error: null,
fetchDeals: async () => {
set({ loading: true, error: null })
try {
const data = await salesApi.deals()
set({ deals: (data.deals || []) as Deal[], loading: false })
} catch (err) {
set({ error: err instanceof Error ? err.message : 'Failed to fetch deals', loading: false })
}
},
fetchProducts: async () => {
set({ loading: true, error: null })
try {
const data = await salesApi.products()
set({ products: (data.products || []) as Product[], loading: false })
} catch (err) {
set({ error: err instanceof Error ? err.message : 'Failed to fetch products', loading: false })
}
},
fetchMRR: async () => {
try {
const data = await salesApi.mrr()
set({ mrr: (data as unknown as { mrr: number }).mrr || 0 })
} catch (err) {
console.error('Failed to fetch MRR:', err)
}
},
fetchARR: async () => {
try {
const data = await salesApi.arr()
set({ arr: (data as unknown as { arr: number }).arr || 0 })
} catch (err) {
console.error('Failed to fetch ARR:', err)
}
},
createDeal: async (data) => {
set({ loading: true, error: null })
try {
await salesApi.createDeal(data)
await get().fetchDeals()
} catch (err) {
set({ error: err instanceof Error ? err.message : 'Failed to create deal', loading: false })
}
},
updateDeal: async (id, data) => {
set({ loading: true, error: null })
try {
await salesApi.updateDeal(id, data)
await get().fetchDeals()
} catch (err) {
set({ error: err instanceof Error ? err.message : 'Failed to update deal', loading: false })
}
},
}))