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:
+6
-1
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 gå 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 gå 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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { BriefingDashboard } from '@/components/BriefingDashboard'
|
||||
|
||||
export function BriefingPage() {
|
||||
return <BriefingDashboard />
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
},
|
||||
}))
|
||||
@@ -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)
|
||||
}
|
||||
},
|
||||
}))
|
||||
@@ -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 })
|
||||
}
|
||||
},
|
||||
}))
|
||||
@@ -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 })
|
||||
}
|
||||
},
|
||||
}))
|
||||
@@ -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 })
|
||||
}
|
||||
},
|
||||
}))
|
||||
Reference in New Issue
Block a user