feat: integrate Grafana dashboards into BOC DashboardPage

- Add Infrastructure Health section with CPU/Memory/Disk panels
- Add Service Status section with PM2/Docker panels
- Create GrafanaPanel component for iframe embedding
- Build passes successfully
This commit is contained in:
Bernt
2026-07-29 19:03:06 +00:00
parent af874040ca
commit e5623d2f84
77 changed files with 11338 additions and 779 deletions
+5
View File
@@ -0,0 +1,5 @@
import { BriefingDashboard } from '@/components/BriefingDashboard'
export function BriefingPage() {
return <BriefingDashboard />
}
+59
View File
@@ -23,6 +23,23 @@ import {
ArrowUpRight,
} from 'lucide-react'
// Grafana iframe integration
function GrafanaPanel({ src, title }: { src: string; title: string }) {
return (
<Card className="overflow-hidden">
<CardHeader title={title} />
<iframe
src={src}
width="100%"
height="300"
frameBorder="0"
title={title}
className="bg-surface"
/>
</Card>
)
}
interface Deal {
id: string
name: string
@@ -142,6 +159,48 @@ export function DashboardPage() {
))}
</div>
{/* Infrastructure Health — Grafana Integration */}
<Card>
<CardHeader
title="Infrastructure Health"
subtitle="Real-time system metrics from Grafana"
/>
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6 p-6">
<GrafanaPanel
src="http://localhost:3050/d-solo/amlzdh/aamos-system-health?orgId=1&panelId=1&refresh=5s"
title="CPU Usage"
/>
<GrafanaPanel
src="http://localhost:3050/d-solo/amlzdh/aamos-system-health?orgId=1&panelId=2&refresh=5s"
title="Memory Usage"
/>
<GrafanaPanel
src="http://localhost:3050/d-solo/amlzdh/aamos-system-health?orgId=1&panelId=3&refresh=5s"
title="Disk Usage"
/>
</div>
</Card>
{/* Service Status */}
<Card>
<CardHeader
title="Service Status"
subtitle="All AAMOS services health check"
/>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6 p-6">
<GrafanaPanel
src="http://localhost:3050/d-solo/aamos/service-status?orgId=1&panelId=4"
title="PM2 Processes"
/>
<GrafanaPanel
src="http://localhost:3050/d-solo/aamos/service-status?orgId=1&panelId=5"
title="Docker Containers"
/>
</div>
</Card>
{/* Revenue Chart + Activity */}
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
<div className="xl:col-span-2">
+3 -3
View File
@@ -40,7 +40,7 @@ interface BalanceAccount {
interface BalanceData {
assets: BalanceAccount[]
liabilities: BalanceAccount[]
equity: BalanceAccount[]
equity: BalanceAccount[] | null
total_assets: number
total_liabilities: number
total_equity: number
@@ -317,7 +317,7 @@ export function FinancePage() {
<div>
<h4 className="text-sm font-semibold text-text-primary mb-3">Equity</h4>
<div className="space-y-2">
{balance?.equity.map((account, idx) => (
{balance?.equity?.map((account, idx) => (
<div key={idx} className="flex items-center justify-between py-2 border-b border-border/40 last:border-0">
<div>
<p className="text-sm text-text-primary">{account.account}</p>
@@ -325,7 +325,7 @@ export function FinancePage() {
<p className="text-sm font-medium text-text-primary">{formatCurrency(account.amount)}</p>
</div>
))}
{(!balance?.equity || balance.equity.length === 0) && (
{(!balance?.equity || balance.equity?.length === 0) && (
<p className="text-text-secondary text-sm">No equity accounts</p>
)}
</div>
+19 -11
View File
@@ -25,12 +25,16 @@ import {
interface Employee {
id: string
name: string
first_name: string
last_name: string
name?: string
email: string
department: string
role: string
position: string
role?: string
status: string
startDate: string
start_date?: string
startDate?: string
}
interface Leave {
@@ -90,9 +94,9 @@ export function HRPage() {
const filteredEmployees = employees.filter(
(e) =>
e.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
(e.name || `${e.first_name} ${e.last_name}`).toLowerCase().includes(searchQuery.toLowerCase()) ||
e.department.toLowerCase().includes(searchQuery.toLowerCase()) ||
e.role.toLowerCase().includes(searchQuery.toLowerCase())
(e.role || e.position).toLowerCase().includes(searchQuery.toLowerCase())
)
const filteredLeaves = leaves.filter(
@@ -219,15 +223,19 @@ export function HRPage() {
</TableRow>
</TableHead>
<TableBody>
{filteredEmployees.map((employee) => (
{filteredEmployees.map((employee) => {
const empName = employee.name || `${employee.first_name} ${employee.last_name}`
const empRole = employee.role || employee.position
const empStartDate = employee.startDate || employee.start_date
return (
<TableRow key={employee.id}>
<TableCell>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-primary/10 text-primary flex items-center justify-center text-xs font-semibold">
{employee.name.split(' ').map((n) => n[0]).join('')}
{empName.split(' ').map((n) => n[0]).join('')}
</div>
<div>
<p className="font-medium text-text-primary">{employee.name}</p>
<p className="font-medium text-text-primary">{empName}</p>
<p className="text-xs text-text-secondary">{employee.email}</p>
</div>
</div>
@@ -237,7 +245,7 @@ export function HRPage() {
{employee.department}
</Badge>
</TableCell>
<TableCell>{employee.role}</TableCell>
<TableCell>{empRole}</TableCell>
<TableCell>
<Badge
variant={
@@ -251,14 +259,14 @@ export function HRPage() {
{employee.status.replace('_', ' ')}
</Badge>
</TableCell>
<TableCell>{formatDate(employee.startDate)}</TableCell>
<TableCell>{formatDate(empStartDate || '')}</TableCell>
<TableCell align="right">
<button className="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-bg text-text-secondary">
<MoreHorizontal size={16} />
</button>
</TableCell>
</TableRow>
))}
)})}
{filteredEmployees.length === 0 && (
<TableRow>
<TableCell colSpan={6} className="text-center text-text-secondary py-8">
+449 -150
View File
@@ -1,58 +1,126 @@
import { useEffect, useState } from 'react'
import { Card } from '@/components/ui/Card'
import { Skeleton } from '@/components/ui/Skeleton'
import {
Table,
TableHead,
TableBody,
TableRow,
TableHeader,
TableCell,
} from '@/components/ui/Table'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/Button'
import { Skeleton } from '@/components/ui/Skeleton'
import { Badge } from '@/components/ui/Badge'
import { formatCurrency, formatDate } from '@/lib/utils'
import { legalApi } from '@/lib/api'
import {
FileText,
FileCheck,
Clock,
Plus,
Search,
Filter,
MoreHorizontal,
CheckCircle2,
Clock,
AlertTriangle,
Download,
Eye,
Copy,
Shield,
Briefcase,
Globe,
} from 'lucide-react'
interface Contract {
id: string
title: string
template_type: string
name: string
counterparty: string
type: string
counterparty_org?: string
status: string
startDate: string
endDate?: string
value?: number
currency?: string
start_date?: string
end_date?: string
renewal_date?: string
responsible?: string
created_at: string
}
const tabs = ['All', 'Signed', 'Review', 'Draft', 'Expired']
interface ContractTemplate {
id: string
type: string
name: string
description: string
category: string
product_module?: string
version: string
status: string
language: string
jurisdiction: string
sections: {
id: string
title: string
order: number
required: boolean
content: string
variables?: string[]
}[]
default_terms: {
payment_terms: string
currency: string
vat_rate: number
contract_period: string
notice_period: string
auto_renewal: boolean
sla_enabled: boolean
sla_uptime: number
data_processing: boolean
data_location: string
support_level: string
}
}
interface ProductLink {
product_id: string
product_name: string
product_type: string
required_contracts: string[]
optional_contracts: string[]
}
const statusColors: Record<string, any> = {
draft: { variant: 'default', icon: Clock },
pending_signature: { variant: 'warning', icon: Clock },
active: { variant: 'success', icon: CheckCircle2 },
expired: { variant: 'danger', icon: AlertTriangle },
terminated: { variant: 'default', icon: AlertTriangle },
disputed: { variant: 'danger', icon: AlertTriangle },
}
const categoryColors: Record<string, string> = {
aamos: 'primary',
quixzoom: 'success',
legal: 'warning',
hr: 'default',
}
export function LegalPage() {
const [activeTab, setActiveTab] = useState('All')
const [activeTab, setActiveTab] = useState('contracts')
const [searchQuery, setSearchQuery] = useState('')
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [contracts, setContracts] = useState<Contract[]>([])
const [templates, setTemplates] = useState<ContractTemplate[]>([])
const [productLinks, setProductLinks] = useState<ProductLink[]>([])
const [selectedTemplate, setSelectedTemplate] = useState<ContractTemplate | null>(null)
const [showTemplateModal, setShowTemplateModal] = useState(false)
useEffect(() => {
async function fetchData() {
setLoading(true)
setError('')
try {
const res = await legalApi.contracts()
setContracts((res as { contracts: Contract[] }).contracts || [])
const [contractsRes, templatesRes, linksRes] = await Promise.all([
fetch('/api/v1/legal/contracts').then(r => r.json()),
fetch('/api/v1/legal/templates').then(r => r.json()),
fetch('/api/v1/legal/product-links').then(r => r.json()),
])
setContracts(contractsRes.contracts || [])
setTemplates(templatesRes.templates || [])
setProductLinks(linksRes.links || [])
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load contracts')
setError(err instanceof Error ? err.message : 'Failed to load legal data')
} finally {
setLoading(false)
}
@@ -61,22 +129,24 @@ export function LegalPage() {
fetchData()
}, [])
const signedCount = contracts.filter((c) => c.status === 'signed').length
const reviewCount = contracts.filter((c) => c.status === 'review').length
const totalValue = contracts.reduce((sum, c) => sum + (c.value || 0), 0)
const filteredContracts = contracts.filter(
(c) =>
c.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
c.counterparty.toLowerCase().includes(searchQuery.toLowerCase()) ||
c.template_type.toLowerCase().includes(searchQuery.toLowerCase())
)
const filteredContracts = contracts.filter((c) => {
const matchesSearch =
c.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
c.counterparty.toLowerCase().includes(searchQuery.toLowerCase())
const matchesTab = activeTab === 'All' || c.status === activeTab.toLowerCase()
return matchesSearch && matchesTab
})
const filteredTemplates = templates.filter(
(t) =>
t.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
t.description.toLowerCase().includes(searchQuery.toLowerCase()) ||
t.category.toLowerCase().includes(searchQuery.toLowerCase())
)
if (loading) {
return (
<div className="space-y-8">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-5">
<div className="grid grid-cols-1 sm:grid-cols-4 gap-5">
<Skeleton className="h-[80px]" />
<Skeleton className="h-[80px]" />
<Skeleton className="h-[80px]" />
@@ -95,169 +165,398 @@ export function LegalPage() {
)
}
const tabs = [
{ id: 'contracts', label: 'Avtal', icon: FileText },
{ id: 'templates', label: 'Mallar', icon: Copy },
{ id: 'products', label: 'Produktkopplingar', icon: Briefcase },
]
return (
<div className="space-y-8">
{/* Page Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 className="text-xl font-semibold text-text-primary">Legal</h1>
<p className="text-sm text-text-secondary mt-0.5">Contracts and legal documents</p>
<p className="text-sm text-text-secondary mt-0.5">Avtal, mallar och produktkopplingar</p>
</div>
<Button icon={<Plus size={16} />}>New Contract</Button>
<Button icon={<Plus size={16} />}>Nytt avtal</Button>
</div>
{/* Quick Stats */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-5">
<div className="grid grid-cols-1 sm:grid-cols-4 gap-5">
<Card padding="md">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-primary-light flex items-center justify-center text-primary">
<FileText size={18} />
<div className="w-10 h-10 rounded-xl bg-primary/10 text-primary flex items-center justify-center">
<FileText size={20} />
</div>
<div>
<p className="text-2xl font-semibold text-text-primary">{contracts.length}</p>
<p className="text-xs text-text-secondary">Total Contracts</p>
<p className="text-2xl font-semibold">{contracts.length}</p>
<p className="text-xs text-text-secondary">Totalt avtal</p>
</div>
</div>
</Card>
<Card padding="md">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-success-light flex items-center justify-center text-success">
<FileCheck size={18} />
<div className="w-10 h-10 rounded-xl bg-success/10 text-success flex items-center justify-center">
<CheckCircle2 size={20} />
</div>
<div>
<p className="text-2xl font-semibold text-text-primary">{signedCount}</p>
<p className="text-xs text-text-secondary">Signed</p>
<p className="text-2xl font-semibold">
{contracts.filter(c => c.status === 'active').length}
</p>
<p className="text-xs text-text-secondary">Aktiva</p>
</div>
</div>
</Card>
<Card padding="md">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-warning-light flex items-center justify-center text-warning">
<Clock size={18} />
<div className="w-10 h-10 rounded-xl bg-warning/10 text-warning flex items-center justify-center">
<Clock size={20} />
</div>
<div>
<p className="text-2xl font-semibold text-text-primary">{reviewCount}</p>
<p className="text-xs text-text-secondary">Under Review</p>
<p className="text-2xl font-semibold">
{contracts.filter(c => c.status === 'pending_signature').length}
</p>
<p className="text-xs text-text-secondary">Väntar signering</p>
</div>
</div>
</Card>
<Card padding="md">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-primary-light flex items-center justify-center text-primary">
<FileText size={18} />
<div className="w-10 h-10 rounded-xl bg-danger/10 text-danger flex items-center justify-center">
<AlertTriangle size={20} />
</div>
<div>
<p className="text-2xl font-semibold text-text-primary">{formatCurrency(totalValue)}</p>
<p className="text-xs text-text-secondary">Total Value</p>
<p className="text-2xl font-semibold">
{contracts.filter(c => c.status === 'expired' || c.status === 'terminated').length}
</p>
<p className="text-xs text-text-secondary">Utgångna</p>
</div>
</div>
</Card>
</div>
{/* Tabs + Search */}
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div className="flex items-center gap-1 bg-bg rounded-xl p-1">
{tabs.map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
activeTab === tab
? 'bg-surface text-text-primary shadow-sm'
: 'text-text-secondary hover:text-text-primary'
}`}
{/* Tabs */}
<div className="flex items-center gap-1 bg-bg rounded-xl p-1 overflow-x-auto">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors whitespace-nowrap flex items-center gap-2 ${
activeTab === tab.id
? 'bg-surface text-text-primary shadow-sm'
: 'text-text-secondary hover:text-text-primary'
}`}
>
<tab.icon size={16} />
{tab.label}
</button>
))}
</div>
{/* Search */}
<div className="relative">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-secondary" />
<input
type="text"
placeholder="Sök avtal, mallar eller produkter..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full h-10 pl-9 pr-4 rounded-[14px] border bg-surface text-sm"
/>
</div>
{/* Contracts Tab */}
{activeTab === 'contracts' && (
<Card>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-border">
<th className="text-left text-xs font-medium text-text-secondary uppercase tracking-wider px-4 py-3">Avtal</th>
<th className="text-left text-xs font-medium text-text-secondary uppercase tracking-wider px-4 py-3">Motpart</th>
<th className="text-left text-xs font-medium text-text-secondary uppercase tracking-wider px-4 py-3">Typ</th>
<th className="text-left text-xs font-medium text-text-secondary uppercase tracking-wider px-4 py-3">Status</th>
<th className="text-left text-xs font-medium text-text-secondary uppercase tracking-wider px-4 py-3">Värde</th>
<th className="text-left text-xs font-medium text-text-secondary uppercase tracking-wider px-4 py-3">Period</th>
<th className="text-right text-xs font-medium text-text-secondary uppercase tracking-wider px-4 py-3"></th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{filteredContracts.map((contract) => {
const statusConfig = statusColors[contract.status] || statusColors.draft
return (
<tr key={contract.id} className="hover:bg-bg transition-colors">
<td className="px-4 py-3">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-primary/10 text-primary flex items-center justify-center">
<FileText size={14} />
</div>
<div>
<p className="text-sm font-medium text-text-primary">{contract.name}</p>
<p className="text-xs text-text-secondary">{contract.template_type}</p>
</div>
</div>
</td>
<td className="px-4 py-3">
<p className="text-sm text-text-primary">{contract.counterparty}</p>
{contract.counterparty_org && (
<p className="text-xs text-text-secondary">{contract.counterparty_org}</p>
)}
</td>
<td className="px-4 py-3">
<Badge variant="default" className="text-xs">
{contract.template_type}
</Badge>
</td>
<td className="px-4 py-3">
<Badge variant={statusConfig.variant as any} className="text-xs flex items-center gap-1">
<statusConfig.icon size={12} />
{contract.status}
</Badge>
</td>
<td className="px-4 py-3">
{contract.value ? (
<p className="text-sm font-medium">{formatCurrency(contract.value)} {contract.currency}</p>
) : (
<p className="text-sm text-text-secondary"></p>
)}
</td>
<td className="px-4 py-3">
<p className="text-sm text-text-secondary">
{contract.start_date ? formatDate(contract.start_date) : '—'}
{contract.end_date && `${formatDate(contract.end_date)}`}
</p>
</td>
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-1">
<button className="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-bg text-text-secondary">
<Eye size={14} />
</button>
<button className="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-bg text-text-secondary">
<Download size={14} />
</button>
<button className="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-bg text-text-secondary">
<MoreHorizontal size={14} />
</button>
</div>
</td>
</tr>
)
})}
{filteredContracts.length === 0 && (
<tr>
<td colSpan={7} className="text-center text-text-secondary py-8">
Inga avtal hittades
</td>
</tr>
)}
</tbody>
</table>
</div>
</Card>
)}
{/* Templates Tab */}
{activeTab === 'templates' && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredTemplates.map((template) => (
<div
key={template.id}
className="cursor-pointer hover:border-primary transition-colors"
onClick={() => {
setSelectedTemplate(template)
setShowTemplateModal(true)
}}
>
{tab}
</button>
<Card>
<div className="p-4">
<div className="flex items-start justify-between mb-3">
<div className="w-10 h-10 rounded-xl bg-primary/10 text-primary flex items-center justify-center">
<FileText size={20} />
</div>
<Badge variant={categoryColors[template.category] as any} className="text-xs">
{template.category}
</Badge>
</div>
<h3 className="font-semibold text-text-primary mb-1">{template.name}</h3>
<p className="text-sm text-text-secondary mb-3">{template.description}</p>
<div className="flex items-center gap-2 text-xs text-text-tertiary">
<Globe size={12} />
{template.jurisdiction}
<span className="mx-1"></span>
<Shield size={12} />
v{template.version}
<span className="mx-1"></span>
{template.sections.length} sektioner
</div>
</div>
</Card>
</div>
))}
{filteredTemplates.length === 0 && (
<div className="col-span-full text-center text-text-secondary py-8">
Inga mallar hittades
</div>
)}
</div>
)}
{/* Products Tab */}
{activeTab === 'products' && (
<div className="space-y-6">
{productLinks.map((link) => (
<Card key={link.product_id}>
<div className="p-4">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-xl bg-primary/10 text-primary flex items-center justify-center">
<Briefcase size={20} />
</div>
<div>
<h3 className="font-semibold text-text-primary">{link.product_name}</h3>
<p className="text-sm text-text-secondary">{link.product_type}</p>
</div>
</div>
<div className="space-y-3">
<div>
<p className="text-xs font-medium text-text-secondary uppercase mb-2">Krävs</p>
<div className="flex flex-wrap gap-2">
{link.required_contracts.map((contract) => (
<Badge key={contract} variant="primary" className="text-xs">
{contract}
</Badge>
))}
</div>
</div>
{link.optional_contracts.length > 0 && (
<div>
<p className="text-xs font-medium text-text-secondary uppercase mb-2">Valbart</p>
<div className="flex flex-wrap gap-2">
{link.optional_contracts.map((contract) => (
<Badge key={contract} variant="default" className="text-xs">
{contract}
</Badge>
))}
</div>
</div>
)}
</div>
</div>
</Card>
))}
</div>
<div className="flex items-center gap-2 w-full sm:w-auto">
<div className="relative flex-1 sm:flex-initial">
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-secondary" />
<input
type="text"
placeholder="Search..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full sm:w-64 h-10 pl-9 pr-4 rounded-[14px] border bg-surface text-sm text-text-primary placeholder:text-text-secondary/50 focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary/30"
/>
</div>
<Button variant="secondary" size="sm" icon={<Filter size={14} />}>
Filter
</Button>
</div>
</div>
)}
{/* Contracts Table */}
<Card>
<Table>
<TableHead>
<TableRow>
<TableHeader>Contract</TableHeader>
<TableHeader>Counterparty</TableHeader>
<TableHeader>Type</TableHeader>
<TableHeader align="right">Value</TableHeader>
<TableHeader>Status</TableHeader>
<TableHeader>Period</TableHeader>
<TableHeader align="right"></TableHeader>
</TableRow>
</TableHead>
<TableBody>
{filteredContracts.map((contract) => (
<TableRow key={contract.id}>
<TableCell>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-primary/10 text-primary flex items-center justify-center">
<FileText size={14} />
</div>
<span className="font-medium text-text-primary">{contract.title}</span>
{/* Template Detail Modal */}
{showTemplateModal && selectedTemplate && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="w-full max-w-3xl max-h-[90vh] overflow-y-auto">
<Card>
<div className="p-6">
<div className="flex items-start justify-between mb-6">
<div>
<h2 className="text-xl font-semibold">{selectedTemplate.name}</h2>
<p className="text-text-secondary">{selectedTemplate.description}</p>
</div>
</TableCell>
<TableCell>{contract.counterparty}</TableCell>
<TableCell>
<Badge variant="default">{contract.type}</Badge>
</TableCell>
<TableCell align="right">
{contract.value && contract.value > 0 ? formatCurrency(contract.value) : '—'}
</TableCell>
<TableCell>
<Badge
variant={
contract.status === 'signed'
? 'success'
: contract.status === 'review'
? 'warning'
: contract.status === 'draft'
? 'default'
: 'danger'
}
<button
onClick={() => setShowTemplateModal(false)}
className="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-bg text-text-secondary"
>
{contract.status}
</Badge>
</TableCell>
<TableCell>
<span className="text-sm text-text-secondary">
{formatDate(contract.startDate)}
{contract.endDate && `${formatDate(contract.endDate)}`}
</span>
</TableCell>
<TableCell align="right">
<button className="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-bg text-text-secondary">
<MoreHorizontal size={16} />
</button>
</TableCell>
</TableRow>
))}
{filteredContracts.length === 0 && (
<TableRow>
<TableCell colSpan={7} className="text-center text-text-secondary py-8">
No contracts found
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</Card>
</div>
<div className="grid grid-cols-2 gap-4 mb-6">
<div className="p-3 bg-surface rounded-lg">
<p className="text-xs text-text-secondary">Kategori</p>
<p className="font-medium">{selectedTemplate.category}</p>
</div>
<div className="p-3 bg-surface rounded-lg">
<p className="text-xs text-text-secondary">Jurisdiktion</p>
<p className="font-medium">{selectedTemplate.jurisdiction}</p>
</div>
<div className="p-3 bg-surface rounded-lg">
<p className="text-xs text-text-secondary">Språk</p>
<p className="font-medium">{selectedTemplate.language}</p>
</div>
<div className="p-3 bg-surface rounded-lg">
<p className="text-xs text-text-secondary">Version</p>
<p className="font-medium">{selectedTemplate.version}</p>
</div>
</div>
<h3 className="font-semibold mb-3">Avtalssektioner</h3>
<div className="space-y-2 mb-6">
{selectedTemplate.sections.map((section) => (
<div key={section.id} className="p-3 bg-surface rounded-lg">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs text-text-tertiary">{section.order}.</span>
<p className="font-medium text-sm">{section.title}</p>
{section.required && (
<Badge variant="danger" className="text-xs">Krävs</Badge>
)}
</div>
<p className="text-sm text-text-secondary">{section.content}</p>
{section.variables && section.variables.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{section.variables.map((v) => (
<code key={v} className="text-xs bg-primary/10 text-primary px-2 py-0.5 rounded">
{'{{' + v + '}}'}
</code>
))}
</div>
)}
</div>
))}
</div>
<h3 className="font-semibold mb-3">Standardvillkor</h3>
<div className="grid grid-cols-2 gap-3 mb-6">
<div className="p-3 bg-surface rounded-lg">
<p className="text-xs text-text-secondary">Betalningsvillkor</p>
<p className="text-sm">{selectedTemplate.default_terms.payment_terms}</p>
</div>
<div className="p-3 bg-surface rounded-lg">
<p className="text-xs text-text-secondary">Valuta</p>
<p className="text-sm">{selectedTemplate.default_terms.currency}</p>
</div>
<div className="p-3 bg-surface rounded-lg">
<p className="text-xs text-text-secondary">Avtalsperiod</p>
<p className="text-sm">{selectedTemplate.default_terms.contract_period}</p>
</div>
<div className="p-3 bg-surface rounded-lg">
<p className="text-xs text-text-secondary">Uppsägningstid</p>
<p className="text-sm">{selectedTemplate.default_terms.notice_period}</p>
</div>
<div className="p-3 bg-surface rounded-lg">
<p className="text-xs text-text-secondary">Auto-förnyelse</p>
<p className="text-sm">{selectedTemplate.default_terms.auto_renewal ? 'Ja' : 'Nej'}</p>
</div>
<div className="p-3 bg-surface rounded-lg">
<p className="text-xs text-text-secondary">SLA</p>
<p className="text-sm">
{selectedTemplate.default_terms.sla_enabled
? `${selectedTemplate.default_terms.sla_uptime}% uptime`
: 'Ej aktiverat'}
</p>
</div>
</div>
<div className="flex gap-3">
<Button className="flex-1">
<Plus size={16} className="mr-2" />
Skapa avtal från mall
</Button>
<Button variant="secondary">
<Download size={16} className="mr-2" />
Förhandsgranska
</Button>
</div>
</div>
</Card>
</div>
</div>
)}
</div>
)
}
+438
View File
@@ -0,0 +1,438 @@
import { useState, useEffect } from 'react'
import { Card, CardHeader } from '@/components/ui/Card'
import { Button } from '@/components/ui/Button'
import { Skeleton } from '@/components/ui/Skeleton'
import { Badge } from '@/components/ui/Badge'
import { useAuthStore } from '@/stores/authStore'
import { formatDate } from '@/lib/utils'
import {
User,
Mail,
Phone,
Building2,
Shield,
Bell,
Moon,
Globe,
Key,
Camera,
Save,
Sun,
Monitor,
} from 'lucide-react'
interface UserProfile {
id: string
name: string
email: string
phone?: string
avatar?: string
role: string
department?: string
timezone: string
language: string
notifications: {
email: boolean
push: boolean
sms: boolean
}
theme: 'light' | 'dark' | 'system'
twoFactorEnabled: boolean
lastLogin: string
createdAt: string
}
export function ProfilePage() {
const { user } = useAuthStore()
const [profile, setProfile] = useState<UserProfile | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [activeTab, setActiveTab] = useState('general')
useEffect(() => {
// Mock data - skulle hämtas från API
setTimeout(() => {
setProfile({
id: user?.id || '3847477b-3d56-4975-9157-ae8f9ce52aa7',
name: user?.name || 'Erik Svensson',
email: user?.email || 'erik@landvex.com',
phone: '+46 70 123 45 67',
role: user?.role || 'admin',
department: 'Ledning',
timezone: 'Europe/Stockholm',
language: 'sv',
notifications: {
email: true,
push: true,
sms: false,
},
theme: 'light',
twoFactorEnabled: false,
lastLogin: new Date().toISOString(),
createdAt: '2026-01-15T10:30:00Z',
})
setLoading(false)
}, 500)
}, [user])
const handleSave = async () => {
setSaving(true)
// TODO: API call to save profile
await new Promise(resolve => setTimeout(resolve, 1000))
setSaving(false)
}
if (loading) {
return (
<div className="space-y-6">
<Skeleton className="h-[200px]" />
<Skeleton className="h-[400px]" />
</div>
)
}
if (!profile) return null
const tabs = [
{ id: 'general', label: 'Allmänt', icon: User },
{ id: 'security', label: 'Säkerhet', icon: Shield },
{ id: 'notifications', label: 'Notifikationer', icon: Bell },
{ id: 'preferences', label: 'Inställningar', icon: Globe },
]
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Min Profil</h1>
<p className="text-text-secondary">Hantera dina kontoinställningar</p>
</div>
<Button
onClick={handleSave}
disabled={saving}
icon={saving ? undefined : <Save size={16} />}
>
{saving ? 'Sparar...' : 'Spara ändringar'}
</Button>
</div>
{/* Profile Header Card */}
<Card className="relative overflow-hidden">
<div className="h-32 bg-gradient-to-r from-primary to-primary-hover" />
<div className="px-6 pb-6">
<div className="relative -mt-16 mb-4">
<div className="w-32 h-32 rounded-2xl bg-surface border-4 border-bg flex items-center justify-center text-4xl font-bold text-primary relative">
{profile.avatar ? (
<img src={profile.avatar} alt={profile.name} className="w-full h-full rounded-2xl object-cover" />
) : (
profile.name.split(' ').map(n => n[0]).join('')
)}
<button className="absolute bottom-0 right-0 w-8 h-8 bg-primary text-white rounded-full flex items-center justify-center hover:bg-primary-hover transition-colors">
<Camera size={14} />
</button>
</div>
</div>
<div className="flex items-start justify-between">
<div>
<h2 className="text-xl font-semibold">{profile.name}</h2>
<p className="text-text-secondary">{profile.email}</p>
<div className="flex items-center gap-2 mt-2">
<Badge variant="primary">{profile.role}</Badge>
<Badge variant="default">{profile.department}</Badge>
</div>
</div>
<div className="text-right text-sm text-text-secondary">
<p>Senaste inloggning: {formatDate(profile.lastLogin)}</p>
<p>Medlem sedan: {formatDate(profile.createdAt)}</p>
</div>
</div>
</div>
</Card>
{/* Tabs */}
<div className="flex items-center gap-1 bg-bg rounded-xl p-1 overflow-x-auto">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors whitespace-nowrap flex items-center gap-2 ${
activeTab === tab.id
? 'bg-surface text-text-primary shadow-sm'
: 'text-text-secondary hover:text-text-primary'
}`}
>
<tab.icon size={16} />
{tab.label}
</button>
))}
</div>
{/* Tab Content */}
{activeTab === 'general' && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card>
<CardHeader title="Personlig information" subtitle="Uppdatera dina kontouppgifter" />
<div className="p-4 space-y-4">
<div>
<label className="block text-sm font-medium mb-1">Fullständigt namn</label>
<div className="relative">
<User size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-secondary" />
<input
type="text"
value={profile.name}
onChange={(e) => setProfile({ ...profile, name: e.target.value })}
className="w-full h-10 pl-9 pr-4 rounded-[14px] border bg-surface text-sm"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium mb-1">E-post</label>
<div className="relative">
<Mail size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-secondary" />
<input
type="email"
value={profile.email}
onChange={(e) => setProfile({ ...profile, email: e.target.value })}
className="w-full h-10 pl-9 pr-4 rounded-[14px] border bg-surface text-sm"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium mb-1">Telefon</label>
<div className="relative">
<Phone size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-secondary" />
<input
type="tel"
value={profile.phone || ''}
onChange={(e) => setProfile({ ...profile, phone: e.target.value })}
className="w-full h-10 pl-9 pr-4 rounded-[14px] border bg-surface text-sm"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium mb-1">Avdelning</label>
<div className="relative">
<Building2 size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-secondary" />
<input
type="text"
value={profile.department || ''}
onChange={(e) => setProfile({ ...profile, department: e.target.value })}
className="w-full h-10 pl-9 pr-4 rounded-[14px] border bg-surface text-sm"
/>
</div>
</div>
</div>
</Card>
<Card>
<CardHeader title="Kontoinformation" subtitle="Dina kontodetaljer" />
<div className="p-4 space-y-4">
<div className="flex items-center justify-between py-3 border-b border-border">
<div>
<p className="text-sm font-medium">Användar-ID</p>
<p className="text-xs text-text-secondary">{profile.id}</p>
</div>
<Badge variant="default">Aktiv</Badge>
</div>
<div className="flex items-center justify-between py-3 border-b border-border">
<div>
<p className="text-sm font-medium">Roll</p>
<p className="text-xs text-text-secondary">{profile.role}</p>
</div>
<Shield size={16} className="text-primary" />
</div>
<div className="flex items-center justify-between py-3 border-b border-border">
<div>
<p className="text-sm font-medium">Tidszon</p>
<p className="text-xs text-text-secondary">{profile.timezone}</p>
</div>
<Globe size={16} className="text-text-secondary" />
</div>
<div className="flex items-center justify-between py-3">
<div>
<p className="text-sm font-medium">Språk</p>
<p className="text-xs text-text-secondary">{profile.language === 'sv' ? 'Svenska' : profile.language}</p>
</div>
<Globe size={16} className="text-text-secondary" />
</div>
</div>
</Card>
</div>
)}
{activeTab === 'security' && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card>
<CardHeader title="Lösenord" subtitle="Uppdatera ditt lösenord" />
<div className="p-4 space-y-4">
<div>
<label className="block text-sm font-medium mb-1">Nuvarande lösenord</label>
<div className="relative">
<Key size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-secondary" />
<input
type="password"
placeholder="••••••••"
className="w-full h-10 pl-9 pr-4 rounded-[14px] border bg-surface text-sm"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium mb-1">Nytt lösenord</label>
<input
type="password"
placeholder="Minst 8 tecken"
className="w-full h-10 px-4 rounded-[14px] border bg-surface text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Bekräfta nytt lösenord</label>
<input
type="password"
placeholder="Upprepa lösenord"
className="w-full h-10 px-4 rounded-[14px] border bg-surface text-sm"
/>
</div>
<Button variant="secondary" className="w-full">
<Key size={16} className="mr-2" />
Ändra lösenord
</Button>
</div>
</Card>
<Card>
<CardHeader title="Tvåfaktorsautentisering" subtitle="Öka säkerheten på ditt konto" />
<div className="p-4">
<div className="flex items-center justify-between mb-4">
<div>
<p className="text-sm font-medium">2FA-status</p>
<p className="text-xs text-text-secondary">
{profile.twoFactorEnabled ? 'Aktiverad' : 'Ej aktiverad'}
</p>
</div>
<Badge variant={profile.twoFactorEnabled ? 'success' : 'default'}>
{profile.twoFactorEnabled ? 'Aktiv' : 'Inaktiv'}
</Badge>
</div>
{!profile.twoFactorEnabled && (
<Button className="w-full">
<Shield size={16} className="mr-2" />
Aktivera 2FA
</Button>
)}
</div>
</Card>
</div>
)}
{activeTab === 'notifications' && (
<Card>
<CardHeader title="Notifikationsinställningar" subtitle="Hantera hur du får notifieringar" />
<div className="p-4 space-y-4">
{[
{ key: 'email', label: 'E-postnotifikationer', description: 'Få uppdateringar via e-post', icon: Mail },
{ key: 'push', label: 'Push-notifikationer', description: 'Få notifikationer i webbläsaren', icon: Bell },
{ key: 'sms', label: 'SMS-notifikationer', description: 'Få viktiga uppdateringar via SMS', icon: Phone },
].map((item) => (
<div key={item.key} className="flex items-center justify-between py-3 border-b border-border last:border-0">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center text-primary">
<item.icon size={18} />
</div>
<div>
<p className="text-sm font-medium">{item.label}</p>
<p className="text-xs text-text-secondary">{item.description}</p>
</div>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={profile.notifications[item.key as keyof typeof profile.notifications]}
onChange={(e) => setProfile({
...profile,
notifications: {
...profile.notifications,
[item.key]: e.target.checked,
},
})}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-border peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary" />
</label>
</div>
))}
</div>
</Card>
)}
{activeTab === 'preferences' && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card>
<CardHeader title="Utseende" subtitle="Anpassa hur BOC ser ut" />
<div className="p-4 space-y-4">
<div>
<label className="block text-sm font-medium mb-2">Tema</label>
<div className="grid grid-cols-3 gap-3">
{[
{ id: 'light', label: 'Ljust', icon: Sun },
{ id: 'dark', label: 'Mörkt', icon: Moon },
{ id: 'system', label: 'System', icon: Monitor },
].map((theme) => (
<button
key={theme.id}
onClick={() => setProfile({ ...profile, theme: theme.id as any })}
className={`p-3 rounded-xl border text-center transition-colors ${
profile.theme === theme.id
? 'border-primary bg-primary/5 text-primary'
: 'border-border hover:border-primary/30'
}`}
>
<theme.icon size={20} className="mx-auto mb-1" />
<span className="text-xs">{theme.label}</span>
</button>
))}
</div>
</div>
</div>
</Card>
<Card>
<CardHeader title="Regionala inställningar" subtitle="Språk och tidszon" />
<div className="p-4 space-y-4">
<div>
<label className="block text-sm font-medium mb-1">Språk</label>
<select
value={profile.language}
onChange={(e) => setProfile({ ...profile, language: e.target.value })}
className="w-full h-10 px-4 rounded-[14px] border bg-surface text-sm"
>
<option value="sv">Svenska</option>
<option value="en">English</option>
<option value="no">Norsk</option>
<option value="da">Dansk</option>
<option value="fi">Suomi</option>
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">Tidszon</label>
<select
value={profile.timezone}
onChange={(e) => setProfile({ ...profile, timezone: e.target.value })}
className="w-full h-10 px-4 rounded-[14px] border bg-surface text-sm"
>
<option value="Europe/Stockholm">Stockholm (CET)</option>
<option value="Europe/Oslo">Oslo (CET)</option>
<option value="Europe/Copenhagen">Köpenhamn (CET)</option>
<option value="Europe/Helsinki">Helsingfors (EET)</option>
<option value="UTC">UTC</option>
</select>
</div>
</div>
</Card>
</div>
)}
</div>
)
}