security: Add proper authentication, RBAC, and tenant isolation

- Add password hashing with bcrypt
- Add AuthService with proper login
- Add password strength validation
- Add RBAC middleware (AdminOnly, ManagerOrAdmin)
- Add tenant isolation middleware
- Update CRM handler with tenant filtering
- Add JWT fallback for development mode
- Add user context helpers
- Build successful
This commit is contained in:
Bernt
2026-08-10 12:52:48 +00:00
parent 8921fd1467
commit 78b57273e2
141 changed files with 29192 additions and 180 deletions
+309
View File
@@ -0,0 +1,309 @@
import { useEffect, useState } from 'react'
import { Card } from '@/components/ui/Card'
import { Skeleton } from '@/components/ui/Skeleton'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/Button'
import { amosApi } from '@/lib/api'
import {
Activity,
AlertTriangle,
CheckCircle,
XCircle,
RefreshCw,
Server,
Cpu,
Clock,
} from 'lucide-react'
interface Model {
id: string
name: string
version: string
status: string
accuracy: number
last_trained: string
}
interface Engine {
id: string
name: string
status: string
version: string
uptime: string
last_check: string
health: string
requests_24h: number
latency_ms: number
error_rate: number
models: Model[]
}
interface EngineSummary {
total: number
healthy: number
warning: number
critical: number
maintenance: number
}
export function AMOSControlPage() {
const [engines, setEngines] = useState<Engine[]>([])
const [summary, setSummary] = useState<EngineSummary | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [restarting, setRestarting] = useState<string | null>(null)
useEffect(() => {
fetchEngines()
}, [])
async function fetchEngines() {
setLoading(true)
setError('')
try {
const res = await amosApi.engines()
setEngines(res.engines || [])
setSummary(res.summary || null)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load engines')
} finally {
setLoading(false)
}
}
async function restartEngine(id: string) {
setRestarting(id)
try {
await amosApi.restartEngine(id)
// Refresh after restart
setTimeout(fetchEngines, 2000)
} catch (err) {
console.error('Restart failed:', err)
} finally {
setRestarting(null)
}
}
function getHealthVariant(health: string) {
switch (health) {
case 'healthy':
return 'success'
case 'warning':
return 'warning'
case 'critical':
return 'danger'
case 'maintenance':
return 'default'
default:
return 'default'
}
}
function getHealthIcon(health: string) {
switch (health) {
case 'healthy':
return <CheckCircle size={16} className="text-success" />
case 'warning':
return <AlertTriangle size={16} className="text-warning" />
case 'critical':
return <XCircle size={16} className="text-danger" />
case 'maintenance':
return <Clock size={16} className="text-text-secondary" />
default:
return <Activity size={16} className="text-text-secondary" />
}
}
if (loading) {
return (
<div className="space-y-8">
<Skeleton className="h-[80px]" />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-[200px]" />
))}
</div>
</div>
)
}
if (error) {
const isAuthError = error.includes('authorization') || error.includes('unauthorized') || error.includes('missing authorization')
return (
<div className="flex items-center justify-center h-64">
<div className="text-center max-w-md px-4">
{isAuthError ? (
<>
<Server size={48} className="mx-auto text-warning mb-4" />
<h3 className="text-lg font-medium text-text-primary mb-2">Authentication required</h3>
<p className="text-text-secondary mb-4">Please log in to access AMOS Control Panel</p>
<button
onClick={() => window.location.href = '/login'}
className="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-hover flex items-center gap-2 mx-auto"
>
Log in
</button>
</>
) : (
<>
<AlertTriangle size={48} className="text-danger mx-auto mb-4" />
<p className="text-danger font-medium">{error}</p>
<Button onClick={fetchEngines} className="mt-4" icon={<RefreshCw size={16} />}>
Retry
</Button>
</>
)}
</div>
</div>
)
}
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">AMOS Control Panel</h1>
<p className="text-sm text-text-secondary mt-0.5">
Monitor and control all AMOS engines
</p>
</div>
<Button onClick={fetchEngines} icon={<RefreshCw size={16} />}>
Refresh
</Button>
</div>
{/* Summary Cards */}
{summary && (
<div className="grid grid-cols-2 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">
<Server size={18} />
</div>
<div>
<p className="text-2xl font-semibold text-text-primary">{summary.total}</p>
<p className="text-xs text-text-secondary">Total Engines</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">
<CheckCircle size={18} />
</div>
<div>
<p className="text-2xl font-semibold text-text-primary">{summary.healthy}</p>
<p className="text-xs text-text-secondary">Healthy</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">
<AlertTriangle size={18} />
</div>
<div>
<p className="text-2xl font-semibold text-text-primary">{summary.warning}</p>
<p className="text-xs text-text-secondary">Warning</p>
</div>
</div>
</Card>
<Card padding="md">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-danger-light flex items-center justify-center text-danger">
<XCircle size={18} />
</div>
<div>
<p className="text-2xl font-semibold text-text-primary">
{summary.critical + summary.maintenance}
</p>
<p className="text-xs text-text-secondary">Critical/Maintenance</p>
</div>
</div>
</Card>
</div>
)}
{/* Engines Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
{engines.map((engine) => (
<Card key={engine.id} className="relative">
<div className="p-5 space-y-4">
{/* Header */}
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
{getHealthIcon(engine.health)}
<div>
<h3 className="text-sm font-semibold text-text-primary">{engine.name}</h3>
<p className="text-xs text-text-secondary">{engine.version}</p>
</div>
</div>
<Badge variant={getHealthVariant(engine.health)}>{engine.health}</Badge>
</div>
{/* Stats */}
<div className="grid grid-cols-2 gap-3">
<div className="p-2 bg-bg rounded-lg">
<p className="text-xs text-text-secondary">Requests (24h)</p>
<p className="text-sm font-medium text-text-primary">
{engine.requests_24h.toLocaleString()}
</p>
</div>
<div className="p-2 bg-bg rounded-lg">
<p className="text-xs text-text-secondary">Latency</p>
<p className="text-sm font-medium text-text-primary">{engine.latency_ms.toFixed(1)} ms</p>
</div>
<div className="p-2 bg-bg rounded-lg">
<p className="text-xs text-text-secondary">Error Rate</p>
<p className="text-sm font-medium text-text-primary">
{(engine.error_rate * 100).toFixed(1)}%
</p>
</div>
<div className="p-2 bg-bg rounded-lg">
<p className="text-xs text-text-secondary">Uptime</p>
<p className="text-sm font-medium text-text-primary">{engine.uptime}</p>
</div>
</div>
{/* Models */}
{engine.models && engine.models.length > 0 && (
<div className="space-y-2">
<p className="text-xs font-medium text-text-secondary uppercase">Models</p>
{engine.models.map((model) => (
<div key={model.id} className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
<Cpu size={14} className="text-text-secondary" />
<span className="text-text-primary">{model.name}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-text-secondary">{(model.accuracy * 100).toFixed(0)}%</span>
<Badge variant={model.status === 'active' ? 'success' : 'warning'} size="sm">
{model.status}
</Badge>
</div>
</div>
))}
</div>
)}
{/* Actions */}
<div className="pt-2 border-t border-border/40">
<Button
variant="secondary"
size="sm"
className="w-full"
icon={<RefreshCw size={14} />}
disabled={restarting === engine.id}
onClick={() => restartEngine(engine.id)}
>
{restarting === engine.id ? 'Restarting...' : 'Restart Engine'}
</Button>
</div>
</div>
</Card>
))}
</div>
</div>
)
}
+397
View File
@@ -0,0 +1,397 @@
import { useEffect, useState } from 'react'
import { Card, CardHeader } from '@/components/ui/Card'
import { Skeleton } from '@/components/ui/Skeleton'
import { Badge } from '@/components/ui/Badge'
import { journalApi, financeApi } from '@/lib/api'
import { formatCurrency, formatDate } from '@/lib/utils'
import {
BookOpen,
CheckCircle,
AlertTriangle,
ArrowRightLeft,
Building2,
Upload,
Link2,
History,
} from 'lucide-react'
import { MobileCard, MobileCardRow, MobileCardBadge } from '@/components/ui/MobileCard'
import { BankAccountCard } from '@/components/banking/BankAccountCard'
import { TransactionList } from '@/components/banking/TransactionList'
import { StatementUpload } from '@/components/banking/StatementUpload'
import { bankAccounts, recentTransactions, statements } from '@/data/bankAccounts'
import { BankAccount, BankTransaction, BankStatement } from '@/types/bank'
interface JournalEntry {
id: string
entry_number: number
description: string
entry_date: string
created_at: string
created_by: string
period: string
fiscal_year: number
status: string
}
interface AccountBalance {
code: string
name: string
type: string
balance: number
}
export function AccountingPage() {
const [activeTab, setActiveTab] = useState('banks')
const [entries, setEntries] = useState<JournalEntry[]>([])
const [accounts, setAccounts] = useState<AccountBalance[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [selectedAccount, setSelectedAccount] = useState<string | null>(null)
const [showUpload, setShowUpload] = useState(false)
useEffect(() => {
fetchData()
}, [])
async function fetchData() {
setLoading(true)
setError('')
try {
const [journalRes, accountsRes] = await Promise.all([
journalApi.entries(),
financeApi.accounts(),
])
setEntries(journalRes.entries || [])
setAccounts((accountsRes as { accounts: AccountBalance[] }).accounts || [])
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load data')
} finally {
setLoading(false)
}
}
const handleSync = (accountId: string) => {
alert(`Synkar konto ${accountId}... (API-koppling krävs)`)
}
const handleConnect = (accountId: string) => {
alert(`Kopplar API för konto ${accountId}...`)
}
const handleUpload = (file: File, accountId: string) => {
alert(`Laddar upp ${file.name} för konto ${accountId}...`)
}
const handleMatch = (txId: string) => {
alert(`Matchar transaktion ${txId} med verifikat...`)
}
if (loading) {
return (
<div className="space-y-8">
<Skeleton className="h-[80px]" />
<Skeleton className="h-[400px]" />
</div>
)
}
if (error) {
return (
<div className="flex items-center justify-center h-64">
<p className="text-danger">{error}</p>
</div>
)
}
const postedCount = entries.filter((e) => e.status === 'posted').length
const totalBalance = accounts.reduce((sum, a) => sum + a.balance, 0)
const totalBankBalance = bankAccounts.reduce((sum, a) => sum + a.balance, 0)
const tabs = [
{ id: 'banks', label: 'Bankkonton', icon: Building2 },
{ id: 'transactions', label: 'Transaktioner', icon: ArrowRightLeft },
{ id: 'ledger', label: 'Huvudbok', icon: BookOpen },
{ id: 'accounts', label: 'Konton', icon: CheckCircle },
]
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">Accounting</h1>
<p className="text-sm text-text-secondary mt-0.5">
Bankkoppling, dubbel bokföring riktig data från aamos-ledger
</p>
</div>
</div>
{/* Summary Cards */}
<div className="grid grid-cols-2 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">
<Building2 size={18} />
</div>
<div>
<p className="text-xs text-text-secondary">Bankkonton</p>
<p className="text-lg font-semibold text-text-primary">{bankAccounts.length}</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">
<CheckCircle size={18} />
</div>
<div>
<p className="text-xs text-text-secondary">Banktotalt</p>
<p className="text-lg font-semibold text-text-primary">
{formatCurrency(totalBankBalance)}
</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">
<BookOpen size={18} />
</div>
<div>
<p className="text-xs text-text-secondary">Verifikat</p>
<p className="text-lg font-semibold text-text-primary">{entries.length}</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">
<CheckCircle size={18} />
</div>
<div>
<p className="text-xs text-text-secondary">Bokfört</p>
<p className="text-lg font-semibold text-text-primary">{postedCount}</p>
</div>
</div>
</Card>
</div>
{/* Tabs */}
<div className="border-b border-border">
<div className="flex gap-1">
{tabs.map((tab) => {
const Icon = tab.icon
return (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium rounded-t-lg transition-colors ${
activeTab === tab.id
? 'text-primary bg-primary-light border-b-2 border-primary'
: 'text-text-secondary hover:text-text-primary hover:bg-bg'
}`}
>
<Icon size={16} />
{tab.label}
</button>
)
})}
</div>
</div>
{/* Tab Content */}
{activeTab === 'banks' && (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-text-primary">Bankkonton</h2>
<button
onClick={() => setShowUpload(!showUpload)}
className="flex items-center gap-2 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors text-sm"
>
<Upload size={16} />
Ladda upp kontoutdrag
</button>
</div>
{showUpload && (
<Card className="p-6">
<StatementUpload
accountId={selectedAccount || bankAccounts[0].id}
accountName={bankAccounts.find(a => a.id === (selectedAccount || bankAccounts[0].id))?.name || ''}
onUpload={handleUpload}
/>
</Card>
)}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
{bankAccounts.map((account) => (
<BankAccountCard
key={account.id}
account={account}
onSync={handleSync}
onConnect={handleConnect}
/>
))}
</div>
{/* Kontoutdragshistorik */}
<Card className="p-6">
<div className="flex items-center gap-2 mb-4">
<History size={18} className="text-text-secondary" />
<h3 className="font-semibold text-text-primary">Importerade kontoutdrag</h3>
</div>
<div className="space-y-2">
{statements.map((stmt) => (
<div
key={stmt.id}
className="flex items-center justify-between p-3 rounded-lg bg-bg border border-border hover:border-primary/30 transition-colors"
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-primary-light flex items-center justify-center text-primary">
<Upload size={14} />
</div>
<div>
<p className="text-sm font-medium text-text-primary">{stmt.fileName}</p>
<p className="text-xs text-text-secondary">
{stmt.periodStart} {stmt.periodEnd} {stmt.transactionCount} transaktioner
</p>
</div>
</div>
<Badge variant={stmt.status === 'completed' ? 'default' : 'warning'}>
{stmt.status === 'completed' ? 'Klart' : 'Bearbetar'}
</Badge>
</div>
))}
</div>
</Card>
</div>
)}
{activeTab === 'transactions' && (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-text-primary">Senaste transaktioner</h2>
<div className="flex items-center gap-2">
<select
value={selectedAccount || ''}
onChange={(e) => setSelectedAccount(e.target.value || null)}
className="px-3 py-2 rounded-lg border border-border bg-white text-sm"
>
<option value="">Alla konton</option>
{bankAccounts.map((account) => (
<option key={account.id} value={account.id}>{account.name}</option>
))}
</select>
</div>
</div>
<TransactionList
transactions={selectedAccount
? recentTransactions.filter(t => t.accountId === selectedAccount)
: recentTransactions
}
onMatch={handleMatch}
/>
</div>
)}
{activeTab === 'ledger' && (
<div className="space-y-6">
<h2 className="text-lg font-semibold text-text-primary">Huvudbok</h2>
<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">
Ver.nr
</th>
<th className="text-left text-xs font-medium text-text-secondary uppercase tracking-wider px-4 py-3">
Beskrivning
</th>
<th className="text-left text-xs font-medium text-text-secondary uppercase tracking-wider px-4 py-3">
Datum
</th>
<th className="text-left text-xs font-medium text-text-secondary uppercase tracking-wider px-4 py-3">
Period
</th>
<th className="text-left text-xs font-medium text-text-secondary uppercase tracking-wider px-4 py-3">
Status
</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{entries.map((entry) => (
<tr key={entry.id} className="hover:bg-bg transition-colors">
<td className="px-4 py-3 text-sm font-medium text-text-primary">
{entry.entry_number}
</td>
<td className="px-4 py-3 text-sm text-text-primary">
{entry.description}
</td>
<td className="px-4 py-3 text-sm text-text-secondary">
{formatDate(entry.entry_date)}
</td>
<td className="px-4 py-3 text-sm text-text-secondary">
{entry.period}
</td>
<td className="px-4 py-3">
<Badge
variant={entry.status === 'posted' ? 'default' : 'warning'}
>
{entry.status === 'posted' ? 'Bokförd' : 'Utkast'}
</Badge>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
</div>
)}
{activeTab === 'accounts' && (
<div className="space-y-6">
<h2 className="text-lg font-semibold text-text-primary">Konton</h2>
<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">
Kod
</th>
<th className="text-left text-xs font-medium text-text-secondary uppercase tracking-wider px-4 py-3">
Namn
</th>
<th className="text-left text-xs font-medium text-text-secondary uppercase tracking-wider px-4 py-3">
Typ
</th>
<th className="text-right text-xs font-medium text-text-secondary uppercase tracking-wider px-4 py-3">
Saldo
</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{accounts.map((account) => (
<tr key={account.code} className="hover:bg-bg transition-colors">
<td className="px-4 py-3 text-sm font-medium text-text-primary">
{account.code}
</td>
<td className="px-4 py-3 text-sm text-text-primary">{account.name}</td>
<td className="px-4 py-3 text-sm text-text-secondary">{account.type}</td>
<td className="px-4 py-3 text-sm text-right font-medium text-text-primary">
{formatCurrency(account.balance)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
</div>
)}
</div>
)
}
+154
View File
@@ -0,0 +1,154 @@
import { useState, useRef, useEffect } from 'react'
import { Card } from '@/components/ui/Card'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { Send, Bot, User, Loader2 } from 'lucide-react'
interface Message {
id: string
role: 'user' | 'assistant'
content: string
timestamp: Date
}
export function AlvaPage() {
const [messages, setMessages] = useState<Message[]>([
{
id: '1',
role: 'assistant',
content: 'Hej! Jag är Alva, din AI-assistent. Jag kan hjälpa dig med:\n\n• Analysera data och rapporter\n• Skriva och granska avtal\n• Besvara frågor om BOC\n• Hjälpa med email och kommunikation\n• Ge råd om affärsbeslut\n\nVad kan jag hjälpa dig med idag?',
timestamp: new Date(),
},
])
const [input, setInput] = useState('')
const [loading, setLoading] = useState(false)
const messagesEndRef = useRef<HTMLDivElement>(null)
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}
useEffect(() => {
scrollToBottom()
}, [messages])
const handleSend = async () => {
if (!input.trim()) return
const userMessage: Message = {
id: Date.now().toString(),
role: 'user',
content: input,
timestamp: new Date(),
}
setMessages((prev) => [...prev, userMessage])
setInput('')
setLoading(true)
// TODO: Connect to real AI backend
// For now, simulate response
setTimeout(() => {
const assistantMessage: Message = {
id: (Date.now() + 1).toString(),
role: 'assistant',
content: generateResponse(input),
timestamp: new Date(),
}
setMessages((prev) => [...prev, assistantMessage])
setLoading(false)
}, 1500)
}
const generateResponse = (userInput: string): string => {
const lower = userInput.toLowerCase()
if (lower.includes('hej') || lower.includes('hallå')) {
return 'Hej! Vad kan jag hjälpa dig med?'
}
if (lower.includes('avtal') || lower.includes('kontrakt')) {
return 'Jag kan hjälpa dig att granska avtal, skapa nya mallar, eller analysera befintliga kontrakt. Vill du att jag tittar på ett specifikt avtal?'
}
if (lower.includes('mail') || lower.includes('email')) {
return 'Jag kan hjälpa dig att skriva professionella mail, förbättra befintliga utkast, eller analysera inkommande meddelanden. Vad behöver du hjälp med?'
}
if (lower.includes('rapport') || lower.includes('analys')) {
return 'Jag kan analysera data, skapa rapporter, eller hjälpa dig tolka finansiella siffror. Vilken typ av analys behöver du?'
}
return 'Jag förstår. Jag kan hjälpa dig med detta. Kan du ge mig mer kontext eller specificera vad du vill ha hjälp med?'
}
return (
<div className="flex flex-col h-[calc(100vh-8rem)]">
{/* Header */}
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center">
<Bot size={20} className="text-primary" />
</div>
<div>
<h1 className="text-xl font-semibold text-text-primary">Alva</h1>
<p className="text-sm text-text-secondary">Din AI-assistent</p>
</div>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto space-y-4 mb-4 pr-2">
{messages.map((message) => (
<div
key={message.id}
className={`flex gap-3 ${message.role === 'user' ? 'flex-row-reverse' : ''}`}
>
<div
className={`w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 ${
message.role === 'assistant'
? 'bg-primary/10 text-primary'
: 'bg-surface text-text-secondary'
}`}
>
{message.role === 'assistant' ? <Bot size={16} /> : <User size={16} />}
</div>
<Card
className={`max-w-[80%] ${
message.role === 'user' ? 'bg-primary text-white' : ''
}`}
padding="sm"
>
<p className="text-sm whitespace-pre-wrap">{message.content}</p>
</Card>
</div>
))}
{loading && (
<div className="flex gap-3">
<div className="w-8 h-8 rounded-lg bg-primary/10 flex items-center justify-center">
<Bot size={16} className="text-primary" />
</div>
<Card className="max-w-[80%]" padding="sm">
<div className="flex items-center gap-2 text-text-secondary">
<Loader2 size={16} className="animate-spin" />
<span className="text-sm">Tänker...</span>
</div>
</Card>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<div className="flex gap-2 pt-4 border-t border-border">
<Input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSend()}
placeholder="Skriv ett meddelande..."
className="flex-1"
/>
<Button
icon={loading ? <Loader2 size={16} className="animate-spin" /> : <Send size={16} />}
onClick={handleSend}
disabled={loading || !input.trim()}
>
Skicka
</Button>
</div>
</div>
)
}
+519
View File
@@ -0,0 +1,519 @@
import { useEffect, useState } from 'react'
import { Card, CardHeader } from '@/components/ui/Card'
import { Skeleton } from '@/components/ui/Skeleton'
import { Badge } from '@/components/ui/Badge'
import { complianceApi } from '@/lib/api'
import { formatDate } from '@/lib/utils'
import {
FileCheck,
Lock,
AlertTriangle,
Scale,
BookOpen,
CheckCircle,
Clock,
TrendingUp,
} from 'lucide-react'
export function CompliancePage() {
const [activeTab, setActiveTab] = useState('iso')
const [isoCerts, setIsoCerts] = useState<any[]>([])
const [gdprRecords, setGdprRecords] = useState<any[]>([])
const [risks, setRisks] = useState<any[]>([])
const [legalCases, setLegalCases] = useState<any[]>([])
const [policies, setPolicies] = useState<any[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const tabs = [
{ key: 'iso', label: 'ISO Certifications', icon: FileCheck },
{ key: 'gdpr', label: 'GDPR / Privacy', icon: Lock },
{ key: 'risks', label: 'Risk Register', icon: AlertTriangle },
{ key: 'legal', label: 'Legal Cases', icon: Scale },
{ key: 'policies', label: 'Policies', icon: BookOpen },
]
useEffect(() => {
fetchData()
}, [])
async function fetchData() {
setLoading(true)
setError('')
try {
const [isoRes, gdprRes, risksRes, legalRes, policiesRes] = await Promise.all([
complianceApi.iso(),
complianceApi.gdpr(),
complianceApi.risks(),
complianceApi.legalCases(),
complianceApi.policies(),
])
setIsoCerts(isoRes.certifications || [])
setGdprRecords(gdprRes.records || [])
setRisks(risksRes.risks || [])
setLegalCases(legalRes.cases || [])
setPolicies(policiesRes.policies || [])
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load compliance data')
} finally {
setLoading(false)
}
}
function getStatusVariant(status: string) {
switch (status) {
case 'active':
case 'completed':
case 'signed':
case 'mitigated':
return 'success'
case 'in_progress':
case 'pending':
case 'draft':
return 'warning'
case 'critical':
case 'failed':
case 'high':
return 'danger'
default:
return 'default'
}
}
function getRiskColor(score: number) {
if (score >= 15) return 'text-danger'
if (score >= 10) return 'text-warning'
return 'text-success'
}
if (loading) {
return (
<div className="space-y-8">
<Skeleton className="h-[80px]" />
<Skeleton className="h-[400px]" />
</div>
)
}
if (error) {
return (
<div className="flex items-center justify-center h-64">
<p className="text-danger">{error}</p>
</div>
)
}
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">Compliance & Legal</h1>
<p className="text-sm text-text-secondary mt-0.5">
ISO, GDPR, risk management, legal cases & policies
</p>
</div>
</div>
{/* Tabs */}
<div className="flex items-center gap-1 bg-bg rounded-xl p-1 overflow-x-auto max-w-full">
{tabs.map((tab) => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors whitespace-nowrap flex items-center gap-2 ${
activeTab === tab.key
? 'bg-surface text-text-primary shadow-sm'
: 'text-text-secondary hover:text-text-primary'
}`}
>
<tab.icon size={16} />
{tab.label}
</button>
))}
</div>
{/* ISO Tab */}
{activeTab === 'iso' && (
<div className="space-y-6">
<div className="grid grid-cols-2 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">
<FileCheck size={18} />
</div>
<div>
<p className="text-2xl font-semibold text-text-primary">{isoCerts.length}</p>
<p className="text-xs text-text-secondary">Total</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">
<CheckCircle size={18} />
</div>
<div>
<p className="text-2xl font-semibold text-text-primary">
{isoCerts.filter((c) => c.status === 'active').length}
</p>
<p className="text-xs text-text-secondary">Active</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>
<div>
<p className="text-2xl font-semibold text-text-primary">
{isoCerts.filter((c) => c.status === 'in_progress').length}
</p>
<p className="text-xs text-text-secondary">In Progress</p>
</div>
</div>
</Card>
<Card padding="md">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-danger-light flex items-center justify-center text-danger">
<AlertTriangle size={18} />
</div>
<div>
<p className="text-2xl font-semibold text-text-primary">
{isoCerts.reduce((sum, c) => sum + c.major_findings, 0)}
</p>
<p className="text-xs text-text-secondary">Major Findings</p>
</div>
</div>
</Card>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
{isoCerts.map((cert) => (
<Card key={cert.id}>
<div className="p-5 space-y-4">
<div className="flex items-start justify-between">
<div>
<h3 className="text-sm font-semibold text-text-primary">{cert.standard}</h3>
<p className="text-xs text-text-secondary">{cert.name}</p>
</div>
<Badge variant={getStatusVariant(cert.status)}>{cert.status}</Badge>
</div>
<div className="grid grid-cols-2 gap-3 text-sm">
<div>
<p className="text-xs text-text-secondary">Issuer</p>
<p className="text-text-primary">{cert.issuer}</p>
</div>
<div>
<p className="text-xs text-text-secondary">Auditor</p>
<p className="text-text-primary">{cert.auditor}</p>
</div>
<div>
<p className="text-xs text-text-secondary">Issued</p>
<p className="text-text-primary">{cert.issued_at ? formatDate(cert.issued_at) : 'N/A'}</p>
</div>
<div>
<p className="text-xs text-text-secondary">Expires</p>
<p className="text-text-primary">{cert.expires_at ? formatDate(cert.expires_at) : 'N/A'}</p>
</div>
</div>
<div className="p-3 bg-bg rounded-lg">
<p className="text-xs text-text-secondary mb-1">Scope</p>
<p className="text-sm text-text-primary">{cert.scope}</p>
</div>
{cert.findings > 0 && (
<div className="flex items-center gap-4 text-sm">
<span className="text-text-secondary">Findings: {cert.findings}</span>
{cert.major_findings > 0 && (
<span className="text-danger">Major: {cert.major_findings}</span>
)}
</div>
)}
</div>
</Card>
))}
</div>
</div>
)}
{/* GDPR Tab */}
{activeTab === 'gdpr' && (
<div className="space-y-6">
{gdprRecords.map((record) => (
<Card key={record.id}>
<div className="p-5 space-y-4">
<div className="flex items-start justify-between">
<h3 className="text-sm font-semibold text-text-primary">{record.purpose}</h3>
<Badge variant={record.impact_assessment ? 'success' : 'warning'}>
{record.impact_assessment ? 'DPIA Done' : 'No DPIA'}
</Badge>
</div>
<div className="grid grid-cols-2 gap-3 text-sm">
<div>
<p className="text-xs text-text-secondary">Legal Basis</p>
<p className="text-text-primary">{record.legal_basis}</p>
</div>
<div>
<p className="text-xs text-text-secondary">Retention</p>
<p className="text-text-primary">{record.retention}</p>
</div>
</div>
<div>
<p className="text-xs text-text-secondary mb-2">Data Types</p>
<div className="flex flex-wrap gap-2">
{record.data_types.map((type: string, i: number) => (
<Badge key={i} variant="default" size="sm">{type}</Badge>
))}
</div>
</div>
<div>
<p className="text-xs text-text-secondary mb-2">Processors</p>
<div className="flex flex-wrap gap-2">
{record.processors.map((proc: string, i: number) => (
<Badge key={i} variant="default" size="sm">{proc}</Badge>
))}
</div>
</div>
<div className="flex items-center gap-4 text-sm">
<span className={record.dpa_exists ? 'text-success' : 'text-danger'}>
DPA: {record.dpa_exists ? '✓' : '✗'}
</span>
<span className={record.cross_border ? 'text-warning' : 'text-text-secondary'}>
Cross-border: {record.cross_border ? 'Yes' : 'No'}
</span>
</div>
</div>
</Card>
))}
</div>
)}
{/* Risks Tab */}
{activeTab === 'risks' && (
<div className="space-y-6">
<div className="grid grid-cols-2 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">
<AlertTriangle size={18} />
</div>
<div>
<p className="text-2xl font-semibold text-text-primary">{risks.length}</p>
<p className="text-xs text-text-secondary">Total Risks</p>
</div>
</div>
</Card>
<Card padding="md">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-danger-light flex items-center justify-center text-danger">
<TrendingUp size={18} />
</div>
<div>
<p className="text-2xl font-semibold text-text-primary">
{risks.filter((r) => r.status === 'active').length}
</p>
<p className="text-xs text-text-secondary">Active</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">
<CheckCircle size={18} />
</div>
<div>
<p className="text-2xl font-semibold text-text-primary">
{risks.filter((r) => r.status === 'mitigated').length}
</p>
<p className="text-xs text-text-secondary">Mitigated</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>
<div>
<p className="text-2xl font-semibold text-text-primary">
{risks.filter((r) => r.status === 'monitored').length}
</p>
<p className="text-xs text-text-secondary">Monitored</p>
</div>
</div>
</Card>
</div>
<Card>
<CardHeader title="Risk Register" subtitle="Identified risks and mitigations" />
<div className="overflow-x-auto">
<table className="w-full">
<thead className="border-b border-border">
<tr>
<th className="py-3 px-4 text-xs font-medium text-text-secondary uppercase text-left">Risk</th>
<th className="py-3 px-4 text-xs font-medium text-text-secondary uppercase text-left">Category</th>
<th className="py-3 px-4 text-xs font-medium text-text-secondary uppercase text-center">P</th>
<th className="py-3 px-4 text-xs font-medium text-text-secondary uppercase text-center">I</th>
<th className="py-3 px-4 text-xs font-medium text-text-secondary uppercase text-center">Score</th>
<th className="py-3 px-4 text-xs font-medium text-text-secondary uppercase text-left">Status</th>
<th className="py-3 px-4 text-xs font-medium text-text-secondary uppercase text-left">Owner</th>
</tr>
</thead>
<tbody>
{risks.map((risk) => (
<tr key={risk.id} className="border-b border-border/50">
<td className="py-3.5 px-4">
<div>
<p className="text-sm text-text-primary">{risk.description}</p>
<p className="text-xs text-text-secondary">{risk.mitigation}</p>
</div>
</td>
<td className="py-3.5 px-4 text-sm text-text-secondary">{risk.category}</td>
<td className="py-3.5 px-4 text-center text-sm text-text-primary">{risk.probability}</td>
<td className="py-3.5 px-4 text-center text-sm text-text-primary">{risk.impact}</td>
<td className="py-3.5 px-4 text-center">
<span className={`text-sm font-semibold ${getRiskColor(risk.score)}`}>
{risk.score}
</span>
</td>
<td className="py-3.5 px-4">
<Badge variant={getStatusVariant(risk.status)}>{risk.status}</Badge>
</td>
<td className="py-3.5 px-4 text-sm text-text-primary">{risk.owner}</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
</div>
)}
{/* Legal Cases Tab */}
{activeTab === 'legal' && (
<div className="space-y-6">
<div className="grid grid-cols-2 sm:grid-cols-3 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">
<Scale size={18} />
</div>
<div>
<p className="text-2xl font-semibold text-text-primary">{legalCases.length}</p>
<p className="text-xs text-text-secondary">Total Cases</p>
</div>
</div>
</Card>
<Card padding="md">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-danger-light flex items-center justify-center text-danger">
<AlertTriangle size={18} />
</div>
<div>
<p className="text-2xl font-semibold text-text-primary">
{legalCases.filter((c) => c.status === 'active').length}
</p>
<p className="text-xs text-text-secondary">Active</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">
<TrendingUp size={18} />
</div>
<div>
<p className="text-2xl font-semibold text-text-primary">
{legalCases.reduce((sum, c) => sum + c.value, 0).toLocaleString()} SEK
</p>
<p className="text-xs text-text-secondary">Total Exposure</p>
</div>
</div>
</Card>
</div>
<div className="space-y-4">
{legalCases.map((c) => (
<Card key={c.id}>
<div className="p-5 space-y-3">
<div className="flex items-start justify-between">
<div>
<h3 className="text-sm font-semibold text-text-primary">{c.title}</h3>
<p className="text-xs text-text-secondary">{c.opposing_party}</p>
</div>
<Badge variant={getStatusVariant(c.status)}>{c.status}</Badge>
</div>
<p className="text-sm text-text-secondary">{c.description}</p>
<div className="flex items-center gap-4 text-sm">
<span className="text-text-secondary">Type: {c.type}</span>
<span className="text-text-secondary">Lawyer: {c.lawyer}</span>
<span className="text-text-secondary">Opened: {formatDate(c.opened_at)}</span>
</div>
{c.value > 0 && (
<p className="text-sm font-medium text-danger">
Exposure: {c.value.toLocaleString()} {c.currency}
</p>
)}
</div>
</Card>
))}
</div>
</div>
)}
{/* Policies Tab */}
{activeTab === 'policies' && (
<div className="space-y-6">
<Card>
<CardHeader title="Policies" subtitle="Corporate policies and procedures" />
<div className="overflow-x-auto">
<table className="w-full">
<thead className="border-b border-border">
<tr>
<th className="py-3 px-4 text-xs font-medium text-text-secondary uppercase text-left">Policy</th>
<th className="py-3 px-4 text-xs font-medium text-text-secondary uppercase text-left">Category</th>
<th className="py-3 px-4 text-xs font-medium text-text-secondary uppercase text-left">Version</th>
<th className="py-3 px-4 text-xs font-medium text-text-secondary uppercase text-left">Status</th>
<th className="py-3 px-4 text-xs font-medium text-text-secondary uppercase text-left">Approved</th>
<th className="py-3 px-4 text-xs font-medium text-text-secondary uppercase text-right">Review</th>
</tr>
</thead>
<tbody>
{policies.map((policy) => (
<tr key={policy.id} className="border-b border-border/50">
<td className="py-3.5 px-4">
<div className="flex items-center gap-2">
<BookOpen size={16} className="text-text-secondary" />
<span className="text-sm text-text-primary">{policy.title}</span>
</div>
</td>
<td className="py-3.5 px-4 text-sm text-text-secondary">{policy.category}</td>
<td className="py-3.5 px-4 text-sm text-text-primary">{policy.version}</td>
<td className="py-3.5 px-4">
<Badge variant={getStatusVariant(policy.status)}>{policy.status}</Badge>
</td>
<td className="py-3.5 px-4 text-sm text-text-secondary">
{policy.approved_by || 'Not approved'}
</td>
<td className="py-3.5 px-4 text-right text-sm text-text-primary">
{formatDate(policy.review_date)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
</div>
)}
</div>
)
}
+483
View File
@@ -0,0 +1,483 @@
import { useEffect, useState } from 'react'
import { Card, CardHeader } from '@/components/ui/Card'
import { Skeleton } from '@/components/ui/Skeleton'
import { Badge } from '@/components/ui/Badge'
import { landvexApi } from '@/lib/api'
import { formatDate } from '@/lib/utils'
import {
Building2,
Users,
FileText,
Shield,
Globe,
CheckCircle,
Clock,
AlertTriangle,
XCircle,
TrendingUp,
} from 'lucide-react'
interface Entity {
id: string
name: string
type: string
org_number: string
country: string
city: string
address: string
status: string
founded_at: string
parent_id?: string
ownership_percent: number
ceo: string
board_members: Person[]
employees: number
revenue: number
currency: string
tax_status: string
compliance_status: string
}
interface Person {
id: string
name: string
role: string
email: string
phone: string
nationality: string
since: string
}
interface Document {
id: string
title: string
type: string
entity_id: string
status: string
created_at: string
expires_at?: string
signed_by: string[]
url: string
}
interface ComplianceItem {
id: string
entity_id: string
title: string
type: string
status: string
due_date: string
completed_at?: string
responsible: string
priority: string
}
export function LandvexPage() {
const [activeTab, setActiveTab] = useState('entities')
const [entities, setEntities] = useState<Entity[]>([])
const [documents, setDocuments] = useState<Document[]>([])
const [compliance, setCompliance] = useState<ComplianceItem[]>([])
const [complianceSummary, setComplianceSummary] = useState<any>(null)
const [ownership, setOwnership] = useState<any>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const tabs = [
{ key: 'entities', label: 'Entities', icon: Building2 },
{ key: 'ownership', label: 'Ownership', icon: TrendingUp },
{ key: 'documents', label: 'Documents', icon: FileText },
{ key: 'compliance', label: 'Compliance', icon: Shield },
]
useEffect(() => {
fetchData()
}, [])
async function fetchData() {
setLoading(true)
setError('')
try {
const [entitiesRes, documentsRes, complianceRes, ownershipRes] = await Promise.all([
landvexApi.entities(),
landvexApi.documents(),
landvexApi.compliance(),
landvexApi.ownership(),
])
setEntities(entitiesRes.entities || [])
setDocuments(documentsRes.documents || [])
setCompliance(complianceRes.compliance_items || [])
setComplianceSummary(complianceRes.summary || null)
setOwnership(ownershipRes.ownership || null)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load data')
} finally {
setLoading(false)
}
}
function getStatusVariant(status: string) {
switch (status) {
case 'active':
case 'completed':
case 'signed':
return 'success'
case 'pending':
case 'in_progress':
case 'draft':
return 'warning'
case 'inactive':
case 'failed':
return 'danger'
default:
return 'default'
}
}
function getComplianceIcon(status: string) {
switch (status) {
case 'completed':
return <CheckCircle size={16} className="text-success" />
case 'in_progress':
return <Clock size={16} className="text-warning" />
case 'pending':
return <AlertTriangle size={16} className="text-danger" />
default:
return <XCircle size={16} className="text-text-secondary" />
}
}
if (loading) {
return (
<div className="space-y-8">
<Skeleton className="h-[80px]" />
<Skeleton className="h-[400px]" />
</div>
)
}
if (error) {
return (
<div className="flex items-center justify-center h-64">
<p className="text-danger">{error}</p>
</div>
)
}
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">Landvex Control</h1>
<p className="text-sm text-text-secondary mt-0.5">
Corporate structure, ownership & compliance
</p>
</div>
</div>
{/* Tabs */}
<div className="flex items-center gap-1 bg-bg rounded-xl p-1 overflow-x-auto max-w-full">
{tabs.map((tab) => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors whitespace-nowrap flex items-center gap-2 ${
activeTab === tab.key
? 'bg-surface text-text-primary shadow-sm'
: 'text-text-secondary hover:text-text-primary'
}`}
>
<tab.icon size={16} />
{tab.label}
</button>
))}
</div>
{/* Entities Tab */}
{activeTab === 'entities' && (
<div className="space-y-6">
<div className="grid grid-cols-1 sm:grid-cols-3 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">
<Building2 size={18} />
</div>
<div>
<p className="text-2xl font-semibold text-text-primary">{entities.length}</p>
<p className="text-xs text-text-secondary">Entities</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">
<Globe size={18} />
</div>
<div>
<p className="text-2xl font-semibold text-text-primary">
{new Set(entities.map((e) => e.country)).size}
</p>
<p className="text-xs text-text-secondary">Countries</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">
<Users size={18} />
</div>
<div>
<p className="text-2xl font-semibold text-text-primary">
{entities.reduce((sum, e) => sum + e.employees, 0)}
</p>
<p className="text-xs text-text-secondary">Total Employees</p>
</div>
</div>
</Card>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
{entities.map((entity) => (
<Card key={entity.id}>
<div className="p-5 space-y-4">
<div className="flex items-start justify-between">
<div>
<h3 className="text-base font-semibold text-text-primary">{entity.name}</h3>
<p className="text-xs text-text-secondary">
{entity.org_number} {entity.city}, {entity.country}
</p>
</div>
<Badge variant={getStatusVariant(entity.status)}>{entity.status}</Badge>
</div>
<div className="grid grid-cols-2 gap-3 text-sm">
<div>
<p className="text-xs text-text-secondary">Type</p>
<p className="text-text-primary">{entity.type}</p>
</div>
<div>
<p className="text-xs text-text-secondary">CEO</p>
<p className="text-text-primary">{entity.ceo}</p>
</div>
<div>
<p className="text-xs text-text-secondary">Founded</p>
<p className="text-text-primary">{formatDate(entity.founded_at)}</p>
</div>
<div>
<p className="text-xs text-text-secondary">Employees</p>
<p className="text-text-primary">{entity.employees}</p>
</div>
</div>
<div>
<p className="text-xs font-medium text-text-secondary uppercase mb-2">Board</p>
<div className="space-y-2">
{entity.board_members.map((person) => (
<div key={person.id} className="flex items-center justify-between text-sm">
<div>
<p className="text-text-primary">{person.name}</p>
<p className="text-xs text-text-secondary">{person.role}</p>
</div>
<span className="text-xs text-text-secondary">{person.since}</span>
</div>
))}
</div>
</div>
<div className="flex items-center gap-2 pt-2 border-t border-border/40">
<Shield size={14} className={entity.compliance_status === 'compliant' ? 'text-success' : 'text-warning'} />
<span className="text-xs text-text-secondary">
Compliance: {entity.compliance_status}
</span>
</div>
</div>
</Card>
))}
</div>
</div>
)}
{/* Ownership Tab */}
{activeTab === 'ownership' && ownership && (
<div className="space-y-6">
<Card>
<CardHeader title="Ownership Structure" subtitle="Ultimate Beneficial Owner" />
<div className="p-5">
{ownership.ultimate_beneficial_owner && (
<div className="flex items-center gap-4 p-4 bg-bg rounded-xl mb-6">
<div className="w-12 h-12 rounded-full bg-primary/10 flex items-center justify-center text-primary text-lg font-semibold">
{ownership.ultimate_beneficial_owner.name.charAt(0)}
</div>
<div>
<p className="text-lg font-semibold text-text-primary">
{ownership.ultimate_beneficial_owner.name}
</p>
<p className="text-sm text-text-secondary">
{ownership.ultimate_beneficial_owner.ownership}% ownership {' '}
{ownership.ultimate_beneficial_owner.nationality}
</p>
</div>
</div>
)}
<div className="space-y-3">
{ownership.entities?.map((entity: any, i: number) => (
<div key={i} className="flex items-center gap-4">
<div className="w-8 h-8 rounded-lg bg-primary/10 flex items-center justify-center text-primary text-xs font-medium">
{entity.jurisdiction}
</div>
<div className="flex-1">
<p className="text-sm font-medium text-text-primary">{entity.name}</p>
<p className="text-xs text-text-secondary">
{entity.type} {entity.jurisdiction}
</p>
</div>
<div className="text-right">
<p className="text-sm font-medium text-text-primary">{entity.ownership}%</p>
<p className="text-xs text-text-secondary">{entity.owner}</p>
</div>
</div>
))}
</div>
</div>
</Card>
</div>
)}
{/* Documents Tab */}
{activeTab === 'documents' && (
<div className="space-y-6">
<Card>
<CardHeader title="Documents" subtitle="Corporate documents & agreements" />
<div className="overflow-x-auto">
<table className="w-full">
<thead className="border-b border-border">
<tr>
<th className="py-3 px-4 text-xs font-medium text-text-secondary uppercase text-left">Title</th>
<th className="py-3 px-4 text-xs font-medium text-text-secondary uppercase text-left">Type</th>
<th className="py-3 px-4 text-xs font-medium text-text-secondary uppercase text-left">Status</th>
<th className="py-3 px-4 text-xs font-medium text-text-secondary uppercase text-left">Signed By</th>
<th className="py-3 px-4 text-xs font-medium text-text-secondary uppercase text-right">Date</th>
</tr>
</thead>
<tbody>
{documents.map((doc) => (
<tr key={doc.id} className="border-b border-border/50">
<td className="py-3.5 px-4">
<div className="flex items-center gap-2">
<FileText size={16} className="text-text-secondary" />
<span className="text-sm text-text-primary">{doc.title}</span>
</div>
</td>
<td className="py-3.5 px-4 text-sm text-text-secondary">{doc.type}</td>
<td className="py-3.5 px-4">
<Badge variant={getStatusVariant(doc.status)}>{doc.status}</Badge>
</td>
<td className="py-3.5 px-4 text-sm text-text-secondary">
{doc.signed_by?.join(', ') || '-'}
</td>
<td className="py-3.5 px-4 text-right text-sm text-text-secondary">
{formatDate(doc.created_at)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
</div>
)}
{/* Compliance Tab */}
{activeTab === 'compliance' && (
<div className="space-y-6">
{complianceSummary && (
<div className="grid grid-cols-2 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">
<Shield size={18} />
</div>
<div>
<p className="text-2xl font-semibold text-text-primary">{complianceSummary.total}</p>
<p className="text-xs text-text-secondary">Total Items</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">
<CheckCircle size={18} />
</div>
<div>
<p className="text-2xl font-semibold text-text-primary">{complianceSummary.completed}</p>
<p className="text-xs text-text-secondary">Completed</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>
<div>
<p className="text-2xl font-semibold text-text-primary">{complianceSummary.in_progress}</p>
<p className="text-xs text-text-secondary">In Progress</p>
</div>
</div>
</Card>
<Card padding="md">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-danger-light flex items-center justify-center text-danger">
<AlertTriangle size={18} />
</div>
<div>
<p className="text-2xl font-semibold text-text-primary">{complianceSummary.overdue}</p>
<p className="text-xs text-text-secondary">Overdue</p>
</div>
</div>
</Card>
</div>
)}
<Card>
<CardHeader title="Compliance Items" subtitle="Regulatory requirements & deadlines" />
<div className="overflow-x-auto">
<table className="w-full">
<thead className="border-b border-border">
<tr>
<th className="py-3 px-4 text-xs font-medium text-text-secondary uppercase text-left">Item</th>
<th className="py-3 px-4 text-xs font-medium text-text-secondary uppercase text-left">Type</th>
<th className="py-3 px-4 text-xs font-medium text-text-secondary uppercase text-left">Status</th>
<th className="py-3 px-4 text-xs font-medium text-text-secondary uppercase text-left">Due Date</th>
<th className="py-3 px-4 text-xs font-medium text-text-secondary uppercase text-left">Responsible</th>
</tr>
</thead>
<tbody>
{compliance.map((item) => (
<tr key={item.id} className="border-b border-border/50">
<td className="py-3.5 px-4">
<div className="flex items-center gap-2">
{getComplianceIcon(item.status)}
<span className="text-sm text-text-primary">{item.title}</span>
</div>
</td>
<td className="py-3.5 px-4 text-sm text-text-secondary">{item.type}</td>
<td className="py-3.5 px-4">
<Badge variant={getStatusVariant(item.status)}>{item.status}</Badge>
</td>
<td className="py-3.5 px-4 text-sm text-text-primary">
{formatDate(item.due_date)}
</td>
<td className="py-3.5 px-4 text-sm text-text-secondary">{item.responsible}</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
</div>
)}
</div>
)
}
+482
View File
@@ -0,0 +1,482 @@
import { useEffect, useState } from 'react'
import { Card } from '@/components/ui/Card'
import { Badge } from '@/components/ui/Badge'
import { Skeleton } from '@/components/ui/Skeleton'
import { Button } from '@/components/ui/Button'
import { formatDate } from '@/lib/utils'
import { useAuthStore } from '@/stores/authStore'
import { ComposeModal } from '@/components/mail/ComposeModal'
import { Mail, MailOpen, RefreshCw, Inbox, ChevronDown, Send, Reply, Plus } from 'lucide-react'
interface EmailMessage {
uid: number
subject: string
from: string
to: string[]
date: string
body: string
preview: string
read: boolean
attachments: number
}
export function MailPage() {
const [messages, setMessages] = useState<EmailMessage[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [selectedMessage, setSelectedMessage] = useState<EmailMessage | null>(null)
const [unreadCount, setUnreadCount] = useState(0)
const [mailboxes, setMailboxes] = useState<string[]>([])
const [selectedMailbox, setSelectedMailbox] = useState<string>('all')
const [showMailboxDropdown, setShowMailboxDropdown] = useState(false)
const [showCompose, setShowCompose] = useState(false)
const [replyTo, setReplyTo] = useState<EmailMessage | undefined>(undefined)
const { token } = useAuthStore()
const { user } = useAuthStore()
const fetchMailboxes = async () => {
try {
if (!token) return
const headers: Record<string, string> = {
'Authorization': `Bearer ${token}`
}
const res = await fetch('/api/v1/mail/mailboxes', { headers })
if (res.ok) {
const data = await res.json()
if (data.ok && data.mailboxes) {
// Filter mailboxes based on user role
const email = user?.email || ''
const role = user?.role || ''
let filtered: string[]
// CEO (Erik) sees CEO mailboxes + shared
if (email.includes('erik') || role === 'ceo') {
filtered = data.mailboxes.filter((mailbox: string) => {
return mailbox.startsWith('erik@') ||
mailbox === 'info@landvex.com' ||
mailbox === 'invoice@landvex.com' ||
mailbox === 'hello@quixzoom.com' ||
mailbox === 'finance@quixzoom.com' ||
mailbox === 'cfo@aamos.systems' ||
mailbox.startsWith('recovery@') ||
mailbox.startsWith('social@')
})
}
// CTO (Johan) sees CTO mailboxes + shared
else if (email.includes('johan') || role === 'cto') {
filtered = data.mailboxes.filter((mailbox: string) => {
return mailbox.startsWith('johan@') ||
mailbox === 'cto@aamos.systems' ||
mailbox === 'info@aamos.systems' ||
mailbox === 'dev@hypbit.com' ||
mailbox.startsWith('recovery@') ||
mailbox.startsWith('social@')
})
}
// Default: show all
else {
filtered = data.mailboxes
}
setMailboxes(filtered)
}
}
} catch (err) {
console.error('Failed to fetch mailboxes:', err)
}
}
const fetchMessages = async () => {
setLoading(true)
setError('')
try {
if (!token) {
setError('Please log in to access mail')
setMessages([])
setUnreadCount(0)
return
}
const headers: Record<string, string> = {
'Authorization': `Bearer ${token}`
}
const [inboxRes, countRes] = await Promise.all([
fetch('/api/v1/mail/inbox?limit=50', { headers }),
fetch('/api/v1/mail/unread-count', { headers }),
])
const inboxData = await inboxRes.json()
if (inboxRes.status === 503) {
setError(inboxData.error || 'Mail integration not configured')
setMessages([])
setUnreadCount(0)
return
}
if (!inboxRes.ok) {
if (inboxRes.status === 401) {
setError('Please log in to access mail')
setMessages([])
setUnreadCount(0)
return
}
throw new Error(inboxData.error || 'Failed to fetch inbox')
}
if (inboxData.ok) {
setMessages(inboxData.messages || [])
}
if (countRes.ok) {
const countData = await countRes.json()
if (countData.ok) {
setUnreadCount(countData.count || 0)
}
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load mail')
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchMailboxes()
fetchMessages()
}, [])
const markAsRead = async (uid: number) => {
try {
if (!token) return
const headers: Record<string, string> = {
'Authorization': `Bearer ${token}`
}
await fetch(`/api/v1/mail/message/${uid}/read`, { method: 'POST', headers })
setMessages(prev => prev.map(m =>
m.uid === uid ? { ...m, read: true } : m
))
setUnreadCount(prev => Math.max(0, prev - 1))
} catch (err) {
console.error('Failed to mark as read:', err)
}
}
const openMessage = (msg: EmailMessage) => {
setSelectedMessage(msg)
if (!msg.read) {
markAsRead(msg.uid)
}
}
// Filter messages by selected mailbox
const filteredMessages = selectedMailbox === 'all'
? messages
: messages.filter(m => m.to.some(t => t.includes(selectedMailbox)))
if (loading) {
return (
<div className="space-y-4">
<Skeleton className="h-12" />
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-20" />
))}
</div>
)
}
if (error) {
const isConfigError = error.includes('not configured') || error.includes('IMAP')
const isAuthError = error.includes('log in') || error.includes('authorization') || error.includes('missing authorization')
return (
<div className="flex items-center justify-center h-64">
<div className="text-center max-w-md px-4">
{isConfigError ? (
<>
<MailOpen size={48} className="mx-auto text-text-tertiary mb-4" />
<h3 className="text-lg font-medium text-text-primary mb-2">Mail not configured</h3>
<p className="text-text-secondary mb-4">{error}</p>
<p className="text-sm text-text-tertiary">Contact your administrator to set up IMAP integration.</p>
</>
) : isAuthError ? (
<>
<Inbox size={48} className="mx-auto text-warning mb-4" />
<h3 className="text-lg font-medium text-text-primary mb-2">Authentication required</h3>
<p className="text-text-secondary mb-4">{error}</p>
<button
onClick={() => window.location.href = '/login'}
className="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-hover flex items-center gap-2 mx-auto"
>
Log in
</button>
</>
) : (
<>
<Inbox size={48} className="mx-auto text-danger mb-4" />
<p className="text-danger mb-4">{error}</p>
<button
onClick={fetchMessages}
className="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-hover flex items-center gap-2 mx-auto"
>
<RefreshCw size={16} />
Retry
</button>
</>
)}
</div>
</div>
)
}
if (selectedMessage) {
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<button
onClick={() => setSelectedMessage(null)}
className="p-2 hover:bg-surface rounded-lg"
>
Back
</button>
<Button
icon={<Reply size={16} />}
onClick={() => {
setReplyTo(selectedMessage)
setShowCompose(true)
}}
>
Svara
</Button>
</div>
<Card>
<div className="p-4 space-y-4">
<div className="flex items-start justify-between">
<div>
<h2 className="text-lg font-semibold">{selectedMessage.subject}</h2>
<p className="text-sm text-text-secondary">From: {selectedMessage.from}</p>
<p className="text-sm text-text-secondary">To: {selectedMessage.to.join(', ')}</p>
<p className="text-sm text-text-secondary">{formatDate(selectedMessage.date)}</p>
</div>
{!selectedMessage.read && (
<Badge variant="primary">New</Badge>
)}
</div>
<div className="border-t border-border-subtle pt-4">
<p className="text-text-primary whitespace-pre-wrap">{selectedMessage.body}</p>
</div>
</div>
</Card>
<ComposeModal
isOpen={showCompose}
onClose={() => {
setShowCompose(false)
setReplyTo(undefined)
}}
replyTo={replyTo ? {
uid: replyTo.uid,
subject: replyTo.subject,
from: replyTo.from,
body: replyTo.body,
} : undefined}
onSent={() => {
fetchMessages()
}}
/>
</div>
)
}
return (
<div className="space-y-4">
{/* Header with compose */}
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold text-text-primary">Mail</h1>
<Button
icon={<Send size={16} />}
onClick={() => {
setReplyTo(undefined)
setShowCompose(true)
}}
className="hidden sm:flex"
>
Nytt meddelande
</Button>
<button
onClick={() => {
setReplyTo(undefined)
setShowCompose(true)
}}
className="sm:hidden p-3 bg-primary text-white rounded-xl hover:bg-primary-hover active:scale-95 transition-all"
aria-label="Nytt meddelande"
>
<Send size={20} />
</button>
</div>
{/* Mailbox Selector */}
<div className="relative">
<button
onClick={() => setShowMailboxDropdown(!showMailboxDropdown)}
className="w-full flex items-center justify-between p-3 bg-surface rounded-lg border border-border hover:border-border-hover transition-colors"
>
<div className="flex items-center gap-2">
<Mail size={18} className="text-text-secondary" />
<span className="font-medium">
{selectedMailbox === 'all' ? 'All Mailboxes' : selectedMailbox}
</span>
</div>
<ChevronDown
size={18}
className={`text-text-secondary transition-transform ${showMailboxDropdown ? 'rotate-180' : ''}`}
/>
</button>
{showMailboxDropdown && (
<div className="absolute top-full left-0 right-0 mt-1 bg-surface border border-border rounded-lg shadow-lg z-50 max-h-64 overflow-y-auto">
<button
onClick={() => { setSelectedMailbox('all'); setShowMailboxDropdown(false) }}
className={`w-full text-left px-3 py-2 hover:bg-surface-hover transition-colors ${selectedMailbox === 'all' ? 'bg-accent/10 text-accent' : ''}`}
>
<div className="flex items-center gap-2">
<Inbox size={16} />
<span>All Mailboxes</span>
<Badge variant="default" className="ml-auto">{messages.length}</Badge>
</div>
</button>
{mailboxes.map((mailbox) => (
<button
key={mailbox}
onClick={() => { setSelectedMailbox(mailbox); setShowMailboxDropdown(false) }}
className={`w-full text-left px-3 py-2 hover:bg-surface-hover transition-colors ${selectedMailbox === mailbox ? 'bg-accent/10 text-accent' : ''}`}
>
<div className="flex items-center gap-2">
<Mail size={16} />
<span className="text-sm">{mailbox}</span>
</div>
</button>
))}
</div>
)}
</div>
{/* Stats */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Inbox size={18} className="text-text-secondary" />
<span className="text-sm text-text-secondary">
{filteredMessages.length} messages
</span>
</div>
{unreadCount > 0 && (
<Badge variant="primary">{unreadCount} unread</Badge>
)}
</div>
<button
onClick={fetchMessages}
className="p-2 hover:bg-surface rounded-lg transition-colors"
title="Refresh"
>
<RefreshCw size={18} className="text-text-secondary" />
</button>
</div>
{/* Mobile FAB for compose */}
<button
onClick={() => {
setReplyTo(undefined)
setShowCompose(true)
}}
className="fixed bottom-6 right-6 z-50 w-14 h-14 bg-primary text-white rounded-full shadow-lg flex items-center justify-center hover:bg-primary-hover active:scale-95 transition-all lg:hidden"
aria-label="Nytt meddelande"
>
<Plus size={24} />
</button>
{/* Message List */}
<div className="space-y-2">
{filteredMessages.length === 0 ? (
<div className="text-center py-12">
<MailOpen size={48} className="mx-auto text-text-tertiary mb-4" />
<p className="text-text-secondary">No messages</p>
</div>
) : (
filteredMessages.map((msg) => (
<button
key={msg.uid}
onClick={() => openMessage(msg)}
className={`w-full text-left p-4 rounded-lg border transition-colors hover:bg-surface-hover ${
msg.read
? 'bg-surface border-border-subtle'
: 'bg-surface border-border hover:border-border-hover'
}`}
>
<div className="flex items-start gap-3">
<div className="mt-1">
{msg.read ? (
<MailOpen size={18} className="text-text-tertiary" />
) : (
<Mail size={18} className="text-accent" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
{!msg.read && (
<Badge variant="primary" className="shrink-0">New</Badge>
)}
<span className={`font-medium truncate ${msg.read ? 'text-text-secondary' : 'text-text-primary'}`}>
{msg.from}
</span>
<span className="text-text-tertiary text-sm shrink-0">
{formatDate(msg.date)}
</span>
</div>
<p className={`font-medium truncate ${msg.read ? 'text-text-secondary' : 'text-text-primary'}`}>
{msg.subject}
</p>
<p className="text-sm text-text-tertiary truncate">
{msg.preview}
</p>
{msg.attachments > 0 && (
<div className="flex items-center gap-1 mt-1">
<span className="text-xs text-text-tertiary">
{msg.attachments} attachment{msg.attachments > 1 ? 's' : ''}
</span>
</div>
)}
</div>
</div>
</button>
))
)}
</div>
<ComposeModal
isOpen={showCompose}
onClose={() => {
setShowCompose(false)
setReplyTo(undefined)
}}
replyTo={replyTo ? {
uid: replyTo.uid,
subject: replyTo.subject,
from: replyTo.from,
body: replyTo.body,
} : undefined}
onSent={() => {
fetchMessages()
}}
/>
</div>
)
}
+218
View File
@@ -0,0 +1,218 @@
import { useState } from 'react'
import { Card, CardHeader } from '@/components/ui/Card'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { Mail, Server, Shield, Check, AlertCircle, KeyRound } from 'lucide-react'
interface MailConfig {
email: string
password: string
server: string
port: number
useTLS: boolean
}
export function MailSettingsPage() {
const [config, setConfig] = useState<MailConfig>({
email: 'erik@aamos.systems',
password: '',
server: 'mail.aamos.systems',
port: 993,
useTLS: true,
})
const [saving, setSaving] = useState(false)
const [testing, setTesting] = useState(false)
const [status, setStatus] = useState<'idle' | 'success' | 'error'>('idle')
const [message, setMessage] = useState('')
const handleSave = async () => {
setSaving(true)
setStatus('idle')
try {
const response = await fetch('/api/v1/mail/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config),
})
if (response.ok) {
setStatus('success')
setMessage('Mail configuration saved successfully')
} else {
setStatus('error')
setMessage('Failed to save configuration')
}
} catch (err) {
setStatus('error')
setMessage('Network error')
} finally {
setSaving(false)
}
}
const handleTest = async () => {
setTesting(true)
setStatus('idle')
try {
const response = await fetch('/api/v1/mail/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config),
})
const data = await response.json()
if (data.ok) {
setStatus('success')
setMessage(`Connection successful! ${data.messageCount || 0} unread messages.`)
} else {
setStatus('error')
setMessage(data.error || 'Connection failed')
}
} catch (err) {
setStatus('error')
setMessage('Network error')
} finally {
setTesting(false)
}
}
return (
<div className="max-w-2xl mx-auto space-y-6">
<div className="flex items-center gap-3">
<Mail size={24} className="text-primary" />
<div>
<h1 className="text-2xl font-bold">Mail Settings</h1>
<p className="text-text-secondary">Connect your IMAP inbox</p>
</div>
</div>
{status !== 'idle' && (
<div className={`p-4 rounded-xl flex items-center gap-3 ${
status === 'success' ? 'bg-success-light text-success' : 'bg-danger-light text-danger'
}`}>
{status === 'success' ? <Check size={20} /> : <AlertCircle size={20} />}
<p>{message}</p>
</div>
)}
<Card>
<CardHeader
title="IMAP Configuration"
subtitle="Enter your mail credentials"
/>
<div className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium mb-2">Email Address</label>
<select
className="w-full h-10 px-3 rounded-xl border border-border bg-surface text-text-primary"
value={config.email}
onChange={(e) => setConfig({ ...config, email: e.target.value })}
>
<option value="erik@aamos.systems">erik@aamos.systems</option>
<option value="erik@landvex.com">erik@landvex.com</option>
<option value="erik@wavult.com">erik@wavult.com</option>
<option value="erik@hypbit.com">erik@hypbit.com</option>
</select>
</div>
<div>
<label className="block text-sm font-medium mb-2">Password</label>
<Input
type="password"
placeholder="Enter your mail password"
value={config.password}
onChange={(e) => setConfig({ ...config, password: e.target.value })}
/>
<p className="text-xs text-text-secondary mt-1">
Don't remember your password? Contact your admin to reset it.
</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-2">Server</label>
<div className="relative">
<Server size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-tertiary" />
<Input
className="pl-10"
value={config.server}
onChange={(e) => setConfig({ ...config, server: e.target.value })}
/>
</div>
</div>
<div>
<label className="block text-sm font-medium mb-2">Port</label>
<Input
type="number"
value={config.port}
onChange={(e) => setConfig({ ...config, port: parseInt(e.target.value) || 993 })}
/>
</div>
</div>
<div className="flex items-center gap-3 p-4 bg-bg rounded-xl">
<Shield size={20} className="text-primary" />
<div className="flex-1">
<p className="text-sm font-medium">Use TLS/SSL</p>
<p className="text-xs text-text-secondary">Encrypt connection to mail server</p>
</div>
<button
onClick={() => setConfig({ ...config, useTLS: !config.useTLS })}
className={`w-12 h-7 rounded-full transition-colors ${
config.useTLS ? 'bg-primary' : 'bg-text-tertiary'
}`}
>
<div className={`w-5 h-5 rounded-full bg-white transition-transform ${
config.useTLS ? 'translate-x-6' : 'translate-x-1'
}`} />
</button>
</div>
</div>
</Card>
<Card>
<CardHeader
title="Forgot Password?"
subtitle="Reset your mail password"
/>
<div className="p-6">
<div className="flex items-start gap-4">
<KeyRound size={24} className="text-warning shrink-0" />
<div>
<p className="text-sm text-text-secondary mb-3">
If you've forgotten your mail password, you can reset it through the Mailu admin panel
or contact your system administrator.
</p>
<a
href="https://mail.aamos.systems/admin"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline text-sm"
>
Open Mailu Admin
</a>
</div>
</div>
</div>
</Card>
<div className="flex gap-4">
<Button
onClick={handleTest}
disabled={testing || !config.email || !config.password}
variant="secondary"
className="flex-1"
>
{testing ? 'Testing...' : 'Test Connection'}
</Button>
<Button
onClick={handleSave}
disabled={saving || !config.email || !config.password}
className="flex-1"
>
{saving ? 'Saving...' : 'Save Configuration'}
</Button>
</div>
</div>
)
}
+567
View File
@@ -0,0 +1,567 @@
import { useState, useEffect } from 'react'
import { Card } from '@/components/ui/Card'
import { Button } from '@/components/ui/Button'
import { Badge } from '@/components/ui/Badge'
import { Skeleton } from '@/components/ui/Skeleton'
import { useAuthStore } from '@/stores/authStore'
import {
Plus,
MoreHorizontal,
Calendar,
User,
Flag,
CheckCircle2,
Circle,
Clock,
X,
GripVertical,
} from 'lucide-react'
interface Task {
id: string
title: string
description: string
status: 'todo' | 'in_progress' | 'review' | 'done'
priority: 'low' | 'medium' | 'high' | 'urgent'
assignee?: string
due_date?: string
tags: string[]
created_at: string
}
interface Project {
id: string
name: string
description: string
status: string
progress: number
tasks: Task[]
members: string[]
created_at: string
}
const statusColumns = [
{ id: 'todo', label: 'Att göra', icon: Circle, color: 'bg-gray-100' },
{ id: 'in_progress', label: 'Pågående', icon: Clock, color: 'bg-blue-50' },
{ id: 'review', label: 'Granskning', icon: Flag, color: 'bg-yellow-50' },
{ id: 'done', label: 'Klart', icon: CheckCircle2, color: 'bg-green-50' },
]
const priorityColors = {
low: 'bg-gray-100 text-gray-700',
medium: 'bg-blue-100 text-blue-700',
high: 'bg-orange-100 text-orange-700',
urgent: 'bg-red-100 text-red-700',
}
const priorityLabels = {
low: 'Låg',
medium: 'Medium',
high: 'Hög',
urgent: 'Akut',
}
export function ProjectsPage() {
const [projects, setProjects] = useState<Project[]>([])
const [selectedProject, setSelectedProject] = useState<Project | null>(null)
const [tasks, setTasks] = useState<Task[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [showCreateTask, setShowCreateTask] = useState(false)
const [showCreateProject, setShowCreateProject] = useState(false)
const { token } = useAuthStore()
const [newTask, setNewTask] = useState({
title: '',
description: '',
priority: 'medium' as const,
status: 'todo' as const,
assignee: '',
due_date: '',
tags: '',
})
const [newProject, setNewProject] = useState({
name: '',
description: '',
})
const fetchProjects = async () => {
setLoading(true)
try {
const res = await fetch('/api/projects')
if (!res.ok) throw new Error('Failed to fetch projects')
const data = await res.json()
setProjects(data)
if (data.length > 0 && !selectedProject) {
setSelectedProject(data[0])
fetchTasks(data[0].id)
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load')
} finally {
setLoading(false)
}
}
const fetchTasks = async (projectId: string) => {
try {
const res = await fetch(`/api/issues?project_id=${projectId}`)
if (!res.ok) throw new Error('Failed to fetch tasks')
const data = await res.json()
// Map issues to tasks format
const mappedTasks: Task[] = data.map((issue: any) => ({
id: issue.id,
title: issue.summary || issue.title || 'Untitled',
description: issue.description || '',
status: mapIssueStatus(issue.status),
priority: mapIssuePriority(issue.priority),
assignee: issue.assignee,
due_date: issue.due_date,
tags: issue.tags || [],
created_at: issue.created_at,
}))
setTasks(mappedTasks)
} catch (err) {
console.error('Failed to fetch tasks:', err)
}
}
const mapIssueStatus = (status: string): Task['status'] => {
switch (status) {
case 'backlog':
case 'todo':
return 'todo'
case 'in_progress':
return 'in_progress'
case 'review':
return 'review'
case 'done':
case 'resolved':
case 'closed':
return 'done'
default:
return 'todo'
}
}
const mapIssuePriority = (priority: string): Task['priority'] => {
switch (priority) {
case 'low':
return 'low'
case 'medium':
return 'medium'
case 'high':
return 'high'
case 'urgent':
case 'critical':
return 'urgent'
default:
return 'medium'
}
}
useEffect(() => {
fetchProjects()
}, [])
const createTask = async () => {
if (!newTask.title || !selectedProject) return
try {
const res = await fetch('/api/issues', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
summary: newTask.title,
description: newTask.description,
priority: newTask.priority,
issue_type: 'task',
project_id: selectedProject.id,
}),
})
if (!res.ok) throw new Error('Failed to create')
setShowCreateTask(false)
setNewTask({
title: '',
description: '',
priority: 'medium',
status: 'todo',
assignee: '',
due_date: '',
tags: '',
})
fetchTasks(selectedProject.id)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create')
}
}
const createProject = async () => {
if (!newProject.name) return
try {
// Projects API might not exist yet, create locally
const project: Project = {
id: `proj-${Date.now()}`,
name: newProject.name,
description: newProject.description,
status: 'active',
progress: 0,
tasks: [],
members: [],
created_at: new Date().toISOString(),
}
setProjects([...projects, project])
setSelectedProject(project)
setShowCreateProject(false)
setNewProject({ name: '', description: '' })
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create')
}
}
const updateTaskStatus = async (taskId: string, newStatus: string) => {
try {
const res = await fetch(`/api/issues/${taskId}/status`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: newStatus }),
})
if (res.ok && selectedProject) {
fetchTasks(selectedProject.id)
}
} catch (err) {
console.error('Failed to update:', err)
}
}
const getTasksByStatus = (status: string) => {
return tasks.filter((task) => task.status === status)
}
if (loading) {
return (
<div className="space-y-8">
<Skeleton className="h-12" />
<div className="grid grid-cols-4 gap-4">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-[500px]" />
))}
</div>
</div>
)
}
return (
<div className="space-y-6">
{/* 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">Projekt</h1>
<p className="text-sm text-text-secondary mt-0.5">
{selectedProject ? selectedProject.name : 'Välj ett projekt'}
</p>
</div>
<div className="flex gap-2">
<Button
variant="secondary"
icon={<Plus size={16} />}
onClick={() => setShowCreateProject(true)}
>
Nytt projekt
</Button>
<Button
icon={<Plus size={16} />}
onClick={() => setShowCreateTask(true)}
disabled={!selectedProject}
>
Ny uppgift
</Button>
</div>
</div>
{/* Project Selector */}
{projects.length > 0 && (
<div className="flex gap-2 overflow-x-auto pb-2">
{projects.map((project) => (
<button
key={project.id}
onClick={() => {
setSelectedProject(project)
fetchTasks(project.id)
}}
className={`px-4 py-2 rounded-xl text-sm font-medium whitespace-nowrap transition-colors ${
selectedProject?.id === project.id
? 'bg-primary text-white'
: 'bg-surface text-text-secondary hover:bg-bg'
}`}
>
{project.name}
</button>
))}
</div>
)}
{/* Kanban Board */}
{selectedProject && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{statusColumns.map((column) => {
const columnTasks = getTasksByStatus(column.id)
const Icon = column.icon
return (
<div key={column.id} className="flex flex-col">
{/* Column Header */}
<div className={`flex items-center justify-between p-3 rounded-t-xl ${column.color}`}>
<div className="flex items-center gap-2">
<Icon size={16} className="text-text-secondary" />
<span className="font-medium text-sm">{column.label}</span>
</div>
<Badge variant="default" className="text-xs">
{columnTasks.length}
</Badge>
</div>
{/* Tasks */}
<div className="flex-1 bg-surface border border-t-0 rounded-b-xl p-2 space-y-2 min-h-[200px]">
{columnTasks.map((task) => (
<div
key={task.id}
className="p-3 bg-bg rounded-lg hover:shadow-md transition-shadow cursor-pointer group"
onClick={() => {
// Show task details modal
}}
>
<div className="flex items-start justify-between mb-2">
<p className="text-sm font-medium text-text-primary flex-1">
{task.title}
</p>
<button className="opacity-0 group-hover:opacity-100 p-1 hover:bg-surface rounded transition-opacity">
<MoreHorizontal size={14} className="text-text-secondary" />
</button>
</div>
{task.description && (
<p className="text-xs text-text-secondary mb-2 line-clamp-2">
{task.description}
</p>
)}
<div className="flex items-center justify-between">
<Badge
variant="default"
className={`text-xs ${priorityColors[task.priority]}`}
>
{priorityLabels[task.priority]}
</Badge>
{task.assignee && (
<div className="flex items-center gap-1 text-text-tertiary">
<User size={12} />
<span className="text-xs">{task.assignee}</span>
</div>
)}
</div>
{task.due_date && (
<div className="flex items-center gap-1 mt-2 text-text-tertiary">
<Calendar size={12} />
<span className="text-xs">
{new Date(task.due_date).toLocaleDateString('sv-SE')}
</span>
</div>
)}
{/* Quick status change */}
<div className="flex gap-1 mt-2 pt-2 border-t border-border">
{statusColumns
.filter((s) => s.id !== task.status)
.map((s) => (
<button
key={s.id}
onClick={(e) => {
e.stopPropagation()
updateTaskStatus(task.id, s.id)
}}
className="text-xs px-2 py-1 rounded bg-surface hover:bg-primary/10 text-text-secondary hover:text-primary transition-colors"
>
{s.label}
</button>
))}
</div>
</div>
))}
{columnTasks.length === 0 && (
<div className="text-center py-8 text-text-tertiary text-sm">
Inga uppgifter
</div>
)}
</div>
</div>
)
})}
</div>
)}
{/* Create Task Modal */}
{showCreateTask && selectedProject && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="w-full max-w-lg bg-surface rounded-2xl shadow-xl">
<div className="flex items-center justify-between p-4 border-b border-border">
<h2 className="text-lg font-semibold">Ny uppgift</h2>
<button
onClick={() => setShowCreateTask(false)}
className="p-2 hover:bg-bg rounded-lg"
>
<X size={18} />
</button>
</div>
<div className="p-4 space-y-4">
<div>
<label className="block text-sm font-medium text-text-secondary mb-1">
Titel
</label>
<input
type="text"
value={newTask.title}
onChange={(e) =>
setNewTask({ ...newTask, title: e.target.value })
}
className="w-full h-10 px-3 rounded-lg border bg-bg text-sm"
placeholder="Vad ska göras?"
/>
</div>
<div>
<label className="block text-sm font-medium text-text-secondary mb-1">
Beskrivning
</label>
<textarea
value={newTask.description}
onChange={(e) =>
setNewTask({ ...newTask, description: e.target.value })
}
className="w-full h-24 px-3 py-2 rounded-lg border bg-bg text-sm resize-none"
placeholder="Beskriv uppgiften..."
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-text-secondary mb-1">
Prioritet
</label>
<select
value={newTask.priority}
onChange={(e) =>
setNewTask({
...newTask,
priority: e.target.value as Task['priority'],
})
}
className="w-full h-10 px-3 rounded-lg border bg-bg text-sm"
>
<option value="low">Låg</option>
<option value="medium">Medium</option>
<option value="high">Hög</option>
<option value="urgent">Akut</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-text-secondary mb-1">
Tilldelad
</label>
<input
type="text"
value={newTask.assignee}
onChange={(e) =>
setNewTask({ ...newTask, assignee: e.target.value })
}
className="w-full h-10 px-3 rounded-lg border bg-bg text-sm"
placeholder="Namn"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-text-secondary mb-1">
Förfallodatum
</label>
<input
type="date"
value={newTask.due_date}
onChange={(e) =>
setNewTask({ ...newTask, due_date: e.target.value })
}
className="w-full h-10 px-3 rounded-lg border bg-bg text-sm"
/>
</div>
</div>
<div className="flex gap-3 p-4 border-t border-border">
<Button className="flex-1" onClick={createTask} disabled={!newTask.title}>
Skapa uppgift
</Button>
<Button variant="secondary" onClick={() => setShowCreateTask(false)}>
Avbryt
</Button>
</div>
</div>
</div>
)}
{/* Create Project Modal */}
{showCreateProject && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="w-full max-w-lg bg-surface rounded-2xl shadow-xl">
<div className="flex items-center justify-between p-4 border-b border-border">
<h2 className="text-lg font-semibold">Nytt projekt</h2>
<button
onClick={() => setShowCreateProject(false)}
className="p-2 hover:bg-bg rounded-lg"
>
<X size={18} />
</button>
</div>
<div className="p-4 space-y-4">
<div>
<label className="block text-sm font-medium text-text-secondary mb-1">
Namn
</label>
<input
type="text"
value={newProject.name}
onChange={(e) =>
setNewProject({ ...newProject, name: e.target.value })
}
className="w-full h-10 px-3 rounded-lg border bg-bg text-sm"
placeholder="Projektnamn"
/>
</div>
<div>
<label className="block text-sm font-medium text-text-secondary mb-1">
Beskrivning
</label>
<textarea
value={newProject.description}
onChange={(e) =>
setNewProject({ ...newProject, description: e.target.value })
}
className="w-full h-24 px-3 py-2 rounded-lg border bg-bg text-sm resize-none"
placeholder="Beskriv projektet..."
/>
</div>
</div>
<div className="flex gap-3 p-4 border-t border-border">
<Button
className="flex-1"
onClick={createProject}
disabled={!newProject.name}
>
Skapa projekt
</Button>
<Button variant="secondary" onClick={() => setShowCreateProject(false)}>
Avbryt
</Button>
</div>
</div>
</div>
)}
</div>
)
}
+645
View File
@@ -0,0 +1,645 @@
import { useEffect, useState } from 'react'
import { Card, CardHeader } from '@/components/ui/Card'
import { Skeleton } from '@/components/ui/Skeleton'
import { Badge } from '@/components/ui/Badge'
import { quixzoomApi } from '@/lib/api'
import { formatCurrency, formatDate } from '@/lib/utils'
import {
Users,
MapPin,
Wallet,
TrendingUp,
Globe,
CheckCircle,
Clock,
Camera,
Brain,
} from 'lucide-react'
interface Zoomer {
id: string
name: string
email: string
phone: string
status: string
country: string
city: string
joined_at: string
last_active: string
total_tasks: number
completed_tasks: number
rating: number
earnings: number
payout_method: string
verified: boolean
}
interface FieldData {
id: string
zoomer_id: string
zoomer_name: string
type: string
status: string
location: {
lat: number
lng: number
address: string
city: string
country: string
}
created_at: string
processed_at?: string
ai_result?: {
engine: string
confidence: number
detections: Array<{
label: string
confidence: number
}>
}
}
interface Payout {
id: string
zoomer_id: string
zoomer_name: string
amount: number
currency: string
status: string
method: string
period: string
created_at: string
tax: number
fee: number
net_amount: number
}
export function QuixzoomPage() {
const [activeTab, setActiveTab] = useState('zoomers')
const [zoomers, setZoomers] = useState<Zoomer[]>([])
const [fieldData, setFieldData] = useState<FieldData[]>([])
const [payouts, setPayouts] = useState<Payout[]>([])
const [insights, setInsights] = useState<any>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const tabs = [
{ key: 'zoomers', label: 'Zoomers', icon: Users },
{ key: 'field-data', label: 'Field Data', icon: Camera },
{ key: 'payouts', label: 'Payouts', icon: Wallet },
{ key: 'insights', label: 'Insights', icon: Brain },
]
useEffect(() => {
fetchData()
}, [])
async function fetchData() {
setLoading(true)
setError('')
try {
const [zoomersRes, fieldDataRes, payoutsRes, insightsRes] = await Promise.all([
quixzoomApi.zoomers(),
quixzoomApi.fieldData(),
quixzoomApi.payouts(),
quixzoomApi.insights(),
])
setZoomers(zoomersRes.zoomers || [])
setFieldData(fieldDataRes.fieldData || [])
setPayouts(payoutsRes.payouts || [])
setInsights(insightsRes.insights || null)
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to load data'
// Check if it's a configuration error
if (msg.includes('not configured')) {
setError(msg)
} else {
setError(msg)
}
} finally {
setLoading(false)
}
}
function getStatusVariant(status: string) {
switch (status) {
case 'active':
case 'completed':
case 'processed':
return 'success'
case 'pending':
case 'processing':
return 'warning'
case 'inactive':
case 'failed':
return 'danger'
default:
return 'default'
}
}
if (loading) {
return (
<div className="space-y-8">
<Skeleton className="h-[80px]" />
<Skeleton className="h-[400px]" />
</div>
)
}
if (error) {
const isConfigError = error.includes('not configured')
return (
<div className="flex items-center justify-center h-64">
<div className="text-center max-w-md">
{isConfigError ? (
<>
<Globe size={48} className="mx-auto text-text-tertiary mb-4" />
<h3 className="text-lg font-medium text-text-primary mb-2">quiXzoom not configured</h3>
<p className="text-text-secondary mb-4">{error}</p>
<p className="text-sm text-text-tertiary">Contact your administrator to set up quiXzoom integration.</p>
</>
) : (
<p className="text-danger">{error}</p>
)}
</div>
</div>
)
}
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">quiXzoom</h1>
<p className="text-sm text-text-secondary mt-0.5">
Field intelligence network and zoomer management
</p>
</div>
</div>
{/* Tabs */}
<div className="flex items-center gap-1 bg-bg rounded-xl p-1 overflow-x-auto max-w-full">
{tabs.map((tab) => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors whitespace-nowrap flex items-center gap-2 ${
activeTab === tab.key
? 'bg-surface text-text-primary shadow-sm'
: 'text-text-secondary hover:text-text-primary'
}`}
>
<tab.icon size={16} />
{tab.label}
</button>
))}
</div>
{/* Zoomers Tab */}
{activeTab === 'zoomers' && (
<div className="space-y-6">
<div className="grid grid-cols-1 sm:grid-cols-3 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">
<Users size={18} />
</div>
<div>
<p className="text-2xl font-semibold text-text-primary">{zoomers.length}</p>
<p className="text-xs text-text-secondary">Total Zoomers</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">
<CheckCircle size={18} />
</div>
<div>
<p className="text-2xl font-semibold text-text-primary">
{zoomers.filter((z) => z.status === 'active').length}
</p>
<p className="text-xs text-text-secondary">Active</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">
<Wallet size={18} />
</div>
<div>
<p className="text-2xl font-semibold text-text-primary">
{formatCurrency(zoomers.reduce((sum, z) => sum + z.earnings, 0))}
</p>
<p className="text-xs text-text-secondary">Total Earnings</p>
</div>
</div>
</Card>
</div>
<Card>
<CardHeader title="Zoomers" subtitle="All field intelligence agents" />
<div className="overflow-x-auto">
<table className="w-full">
<thead className="border-b border-border">
<tr>
<th className="py-3 px-4 text-xs font-medium text-text-secondary uppercase text-left">Name</th>
<th className="py-3 px-4 text-xs font-medium text-text-secondary uppercase text-left">Location</th>
<th className="py-3 px-4 text-xs font-medium text-text-secondary uppercase text-left">Status</th>
<th className="py-3 px-4 text-xs font-medium text-text-secondary uppercase text-right">Tasks</th>
<th className="py-3 px-4 text-xs font-medium text-text-secondary uppercase text-right">Rating</th>
<th className="py-3 px-4 text-xs font-medium text-text-secondary uppercase text-right">Earnings</th>
</tr>
</thead>
<tbody>
{zoomers.map((zoomer) => (
<tr key={zoomer.id} className="border-b border-border/50">
<td className="py-3.5 px-4">
<div>
<p className="text-sm font-medium text-text-primary">{zoomer.name}</p>
<p className="text-xs text-text-secondary">{zoomer.email}</p>
</div>
</td>
<td className="py-3.5 px-4">
<div className="flex items-center gap-1 text-sm text-text-primary">
<MapPin size={14} className="text-text-secondary" />
{zoomer.city}, {zoomer.country}
</div>
</td>
<td className="py-3.5 px-4">
<Badge variant={getStatusVariant(zoomer.status)}>{zoomer.status}</Badge>
</td>
<td className="py-3.5 px-4 text-right text-sm text-text-primary">
{zoomer.completed_tasks}/{zoomer.total_tasks}
</td>
<td className="py-3.5 px-4 text-right text-sm text-text-primary">
{zoomer.rating.toFixed(1)}
</td>
<td className="py-3.5 px-4 text-right text-sm font-medium text-text-primary">
{formatCurrency(zoomer.earnings)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
</div>
)}
{/* Field Data Tab */}
{activeTab === 'field-data' && (
<div className="space-y-6">
<div className="grid grid-cols-1 sm:grid-cols-3 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">
<Camera size={18} />
</div>
<div>
<p className="text-2xl font-semibold text-text-primary">{fieldData.length}</p>
<p className="text-xs text-text-secondary">Total Observations</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">
<CheckCircle size={18} />
</div>
<div>
<p className="text-2xl font-semibold text-text-primary">
{fieldData.filter((fd) => fd.status === 'processed').length}
</p>
<p className="text-xs text-text-secondary">Processed</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>
<div>
<p className="text-2xl font-semibold text-text-primary">
{fieldData.filter((fd) => fd.status === 'pending').length}
</p>
<p className="text-xs text-text-secondary">Pending</p>
</div>
</div>
</Card>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
{fieldData.map((fd) => (
<Card key={fd.id}>
<div className="p-5 space-y-3">
<div className="flex items-start justify-between">
<div>
<p className="text-sm font-medium text-text-primary">{fd.type}</p>
<p className="text-xs text-text-secondary">{fd.zoomer_name}</p>
</div>
<Badge variant={getStatusVariant(fd.status)}>{fd.status}</Badge>
</div>
<div className="flex items-center gap-1 text-xs text-text-secondary">
<MapPin size={14} />
{fd.location.address}, {fd.location.city}
</div>
{fd.ai_result && (
<div className="p-3 bg-bg rounded-lg space-y-2">
<div className="flex items-center gap-2">
<Brain size={14} className="text-primary" />
<span className="text-xs font-medium text-text-primary">
AI Analysis ({fd.ai_result.engine})
</span>
</div>
<div className="flex items-center gap-2">
<div className="flex-1 h-2 bg-surface-2 rounded-full">
<div
className="h-2 bg-primary rounded-full"
style={{ width: `${fd.ai_result.confidence * 100}%` }}
/>
</div>
<span className="text-xs text-text-secondary">
{(fd.ai_result.confidence * 100).toFixed(0)}%
</span>
</div>
{fd.ai_result.detections.map((det, i) => (
<div key={i} className="flex items-center justify-between text-xs">
<span className="text-text-secondary">{det.label}</span>
<span className="text-text-primary">{(det.confidence * 100).toFixed(0)}%</span>
</div>
))}
</div>
)}
<p className="text-xs text-text-secondary">{formatDate(fd.created_at)}</p>
</div>
</Card>
))}
</div>
</div>
)}
{/* Payouts Tab */}
{activeTab === 'payouts' && (
<div className="space-y-6">
<div className="grid grid-cols-1 sm:grid-cols-3 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">
<Wallet size={18} />
</div>
<div>
<p className="text-2xl font-semibold text-text-primary">{payouts.length}</p>
<p className="text-xs text-text-secondary">Total Payouts</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">
<CheckCircle size={18} />
</div>
<div>
<p className="text-2xl font-semibold text-text-primary">
{payouts.filter((p) => p.status === 'completed').length}
</p>
<p className="text-xs text-text-secondary">Completed</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">
<TrendingUp size={18} />
</div>
<div>
<p className="text-2xl font-semibold text-text-primary">
{formatCurrency(payouts.reduce((sum, p) => sum + p.amount, 0))}
</p>
<p className="text-xs text-text-secondary">Total Amount</p>
</div>
</div>
</Card>
</div>
<Card>
<CardHeader title="Payouts" subtitle="All zoomer payments" />
<div className="overflow-x-auto">
<table className="w-full">
<thead className="border-b border-border">
<tr>
<th className="py-3 px-4 text-xs font-medium text-text-secondary uppercase text-left">Zoomer</th>
<th className="py-3 px-4 text-xs font-medium text-text-secondary uppercase text-left">Period</th>
<th className="py-3 px-4 text-xs font-medium text-text-secondary uppercase text-left">Status</th>
<th className="py-3 px-4 text-xs font-medium text-text-secondary uppercase text-left">Method</th>
<th className="py-3 px-4 text-xs font-medium text-text-secondary uppercase text-right">Amount</th>
<th className="py-3 px-4 text-xs font-medium text-text-secondary uppercase text-right">Net</th>
</tr>
</thead>
<tbody>
{payouts.map((payout) => (
<tr key={payout.id} className="border-b border-border/50">
<td className="py-3.5 px-4 text-sm text-text-primary">{payout.zoomer_name}</td>
<td className="py-3.5 px-4 text-sm text-text-primary">{payout.period}</td>
<td className="py-3.5 px-4">
<Badge variant={getStatusVariant(payout.status)}>{payout.status}</Badge>
</td>
<td className="py-3.5 px-4 text-sm text-text-primary">{payout.method}</td>
<td className="py-3.5 px-4 text-right text-sm text-text-primary">
{formatCurrency(payout.amount)} {payout.currency}
</td>
<td className="py-3.5 px-4 text-right text-sm font-medium text-success">
{formatCurrency(payout.net_amount)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
</div>
)}
{/* Insights Tab */}
{activeTab === 'insights' && insights && (
<div className="space-y-6">
<div className="grid grid-cols-2 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">
<Camera size={18} />
</div>
<div>
<p className="text-2xl font-semibold text-text-primary">
{insights.total_observations?.toLocaleString()}
</p>
<p className="text-xs text-text-secondary">Observations</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">
<Users size={18} />
</div>
<div>
<p className="text-2xl font-semibold text-text-primary">
{insights.active_zoomers}
</p>
<p className="text-xs text-text-secondary">Active Zoomers</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">
<Globe size={18} />
</div>
<div>
<p className="text-2xl font-semibold text-text-primary">
{insights.cities_covered}
</p>
<p className="text-xs text-text-secondary">Cities</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">
<Globe size={18} />
</div>
<div>
<p className="text-2xl font-semibold text-text-primary">
{insights.countries_covered}
</p>
<p className="text-xs text-text-secondary">Countries</p>
</div>
</div>
</Card>
</div>
{/* Urban Intelligence Indexes */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
{insights.urban_sanitation_index && (
<Card>
<CardHeader
title="Urban Sanitation Index"
subtitle={`Score: ${insights.urban_sanitation_index.score}/10`}
/>
<div className="p-5 space-y-3">
<div className="flex items-center gap-2">
<div className="flex-1 h-3 bg-surface-2 rounded-full">
<div
className="h-3 bg-primary rounded-full"
style={{ width: `${(insights.urban_sanitation_index.score / 10) * 100}%` }}
/>
</div>
<span className="text-sm font-medium text-text-primary">
{insights.urban_sanitation_index.score}
</span>
</div>
<div className="space-y-2">
{insights.urban_sanitation_index.cities?.map((city: any, i: number) => (
<div key={i} className="flex items-center justify-between text-sm">
<span className="text-text-secondary">{city.city}</span>
<div className="flex items-center gap-2">
<div className="w-24 h-2 bg-surface-2 rounded-full">
<div
className="h-2 bg-primary rounded-full"
style={{ width: `${(city.score / 10) * 100}%` }}
/>
</div>
<span className="text-text-primary">{city.score}</span>
</div>
</div>
))}
</div>
</div>
</Card>
)}
{insights.reality_gap_index && (
<Card>
<CardHeader
title="Reality Gap Index"
subtitle={`Score: ${insights.reality_gap_index.score}/10`}
/>
<div className="p-5 space-y-3">
<div className="flex items-center gap-2">
<div className="flex-1 h-3 bg-surface-2 rounded-full">
<div
className="h-3 bg-danger rounded-full"
style={{ width: `${(insights.reality_gap_index.score / 10) * 100}%` }}
/>
</div>
<span className="text-sm font-medium text-text-primary">
{insights.reality_gap_index.score}
</span>
</div>
<p className="text-xs text-text-secondary">
Trend: {insights.reality_gap_index.trend}
</p>
</div>
</Card>
)}
{insights.reality_contradiction_score && (
<Card>
<CardHeader
title="Reality Contradiction Score"
subtitle={`Score: ${insights.reality_contradiction_score.score}/10`}
/>
<div className="p-5 space-y-3">
<div className="flex items-center gap-2">
<div className="flex-1 h-3 bg-surface-2 rounded-full">
<div
className="h-3 bg-warning rounded-full"
style={{ width: `${(insights.reality_contradiction_score.score / 10) * 100}%` }}
/>
</div>
<span className="text-sm font-medium text-text-primary">
{insights.reality_contradiction_score.score}
</span>
</div>
<p className="text-xs text-text-secondary">
Trend: {insights.reality_contradiction_score.trend}
</p>
</div>
</Card>
)}
{insights.implementation_gap_score && (
<Card>
<CardHeader
title="Implementation Gap Score"
subtitle={`Score: ${insights.implementation_gap_score.score}/10`}
/>
<div className="p-5 space-y-3">
<div className="flex items-center gap-2">
<div className="flex-1 h-3 bg-surface-2 rounded-full">
<div
className="h-3 bg-success rounded-full"
style={{ width: `${(insights.implementation_gap_score.score / 10) * 100}%` }}
/>
</div>
<span className="text-sm font-medium text-text-primary">
{insights.implementation_gap_score.score}
</span>
</div>
<p className="text-xs text-text-secondary">
Trend: {insights.implementation_gap_score.trend}
</p>
</div>
</Card>
)}
</div>
</div>
)}
</div>
)
}
+371
View File
@@ -0,0 +1,371 @@
import { useEffect, useState } from 'react'
import { Card } from '@/components/ui/Card'
import { Skeleton } from '@/components/ui/Skeleton'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/Button'
import {
Instagram,
Twitter,
Facebook,
Linkedin,
Globe,
Plus,
Users,
MessageSquare,
Heart,
Share2,
RefreshCw,
ExternalLink,
} from 'lucide-react'
interface SocialAccount {
id: string
platform: string
account_name: string
display_name: string
followers: number
following: number
posts: number
profile_url: string
avatar_url: string
is_connected: boolean
last_synced: string
}
interface SocialPost {
id: string
platform: string
content: string
media_url?: string
likes: number
comments: number
shares: number
reach: number
posted_at: string
status: string
}
interface SocialStats {
total_followers: number
total_posts: number
total_engagement: number
accounts: number
}
const platformIcons: Record<string, React.ReactNode> = {
instagram: <Instagram size={20} />,
twitter: <Twitter size={20} />,
facebook: <Facebook size={20} />,
linkedin: <Linkedin size={20} />,
tiktok: <Globe size={20} />,
}
const platformColors: Record<string, string> = {
instagram: 'bg-gradient-to-br from-purple-500 to-pink-500',
twitter: 'bg-blue-500',
facebook: 'bg-blue-600',
linkedin: 'bg-blue-700',
tiktok: 'bg-black',
}
export function SocialMediaPage() {
const [accounts, setAccounts] = useState<SocialAccount[]>([])
const [posts, setPosts] = useState<SocialPost[]>([])
const [stats, setStats] = useState<SocialStats | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [activeTab, setActiveTab] = useState('accounts')
useEffect(() => {
fetchData()
}, [])
async function fetchData() {
setLoading(true)
setError('')
try {
// Hämta konton
const accountsRes = await fetch('/api/v1/social/accounts')
const accountsData = await accountsRes.json()
if (accountsData.ok) {
setAccounts(accountsData.accounts || [])
}
// Hämta statistik
const statsRes = await fetch('/api/v1/social/stats')
const statsData = await statsRes.json()
if (statsData.ok) {
setStats(statsData.stats)
}
// Hämta inlägg
const postsRes = await fetch('/api/v1/social/posts')
const postsData = await postsRes.json()
if (postsData.ok) {
setPosts(postsData.posts || [])
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load data')
} finally {
setLoading(false)
}
}
if (loading) {
return (
<div className="space-y-8">
<div className="grid grid-cols-1 sm:grid-cols-4 gap-5">
<Skeleton className="h-[80px]" />
<Skeleton className="h-[80px]" />
<Skeleton className="h-[80px]" />
<Skeleton className="h-[80px]" />
</div>
<Skeleton className="h-[400px]" />
</div>
)
}
if (error) {
return (
<div className="flex items-center justify-center h-64">
<p className="text-danger">{error}</p>
</div>
)
}
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">Social Media</h1>
<p className="text-sm text-text-secondary mt-0.5">
Hantera alla sociala media-konton
</p>
</div>
<Button icon={<Plus size={16} />}>Add Account</Button>
</div>
{/* Stats */}
<div className="grid grid-cols-2 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">
<Users size={18} />
</div>
<div>
<p className="text-2xl font-semibold text-text-primary">
{stats?.total_followers?.toLocaleString() || '0'}
</p>
<p className="text-xs text-text-secondary">Total Followers</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">
<MessageSquare size={18} />
</div>
<div>
<p className="text-2xl font-semibold text-text-primary">
{stats?.total_posts?.toLocaleString() || '0'}
</p>
<p className="text-xs text-text-secondary">Total Posts</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">
<Heart size={18} />
</div>
<div>
<p className="text-2xl font-semibold text-text-primary">
{stats?.total_engagement?.toLocaleString() || '0'}
</p>
<p className="text-xs text-text-secondary">Engagement</p>
</div>
</div>
</Card>
<Card padding="md">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-info-light flex items-center justify-center text-info">
<Globe size={18} />
</div>
<div>
<p className="text-2xl font-semibold text-text-primary">
{stats?.accounts || 0}
</p>
<p className="text-xs text-text-secondary">Accounts</p>
</div>
</div>
</Card>
</div>
{/* Tabs */}
<div className="flex items-center gap-1 bg-bg rounded-xl p-1 overflow-x-auto max-w-full">
{['accounts', 'posts', 'analytics'].map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors whitespace-nowrap ${
activeTab === tab
? 'bg-surface text-text-primary shadow-sm'
: 'text-text-secondary hover:text-text-primary'
}`}
>
{tab.charAt(0).toUpperCase() + tab.slice(1)}
</button>
))}
</div>
{/* Accounts Tab */}
{activeTab === 'accounts' && (
<div className="space-y-4">
{accounts.length === 0 ? (
<Card className="p-8 text-center">
<Globe size={48} className="mx-auto text-text-tertiary mb-4" />
<h3 className="text-lg font-medium text-text-primary mb-2">
Inga konton kopplade
</h3>
<p className="text-sm text-text-secondary mb-4">
Lägg till dina sociala media-konton för att se statistik och hantera inlägg
</p>
<Button icon={<Plus size={16} />}>Add First Account</Button>
</Card>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
{accounts.map((account) => (
<Card key={account.id} className="relative">
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<div className={`w-12 h-12 rounded-xl ${platformColors[account.platform] || 'bg-gray-500'} flex items-center justify-center text-white`}>
{platformIcons[account.platform] || <Globe size={20} />}
</div>
<div>
<h3 className="font-medium text-text-primary">
{account.display_name || account.account_name}
</h3>
<p className="text-xs text-text-secondary">@{account.account_name}</p>
</div>
</div>
<Badge variant={account.is_connected ? 'success' : 'default'}>
{account.is_connected ? 'Connected' : 'Disconnected'}
</Badge>
</div>
<div className="grid grid-cols-3 gap-4 mb-4">
<div className="text-center">
<p className="text-lg font-semibold text-text-primary">
{account.followers.toLocaleString()}
</p>
<p className="text-xs text-text-secondary">Followers</p>
</div>
<div className="text-center">
<p className="text-lg font-semibold text-text-primary">
{account.following.toLocaleString()}
</p>
<p className="text-xs text-text-secondary">Following</p>
</div>
<div className="text-center">
<p className="text-lg font-semibold text-text-primary">
{account.posts.toLocaleString()}
</p>
<p className="text-xs text-text-secondary">Posts</p>
</div>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-text-tertiary">
Last synced: {account.last_synced ? new Date(account.last_synced).toLocaleDateString() : 'Never'}
</span>
<div className="flex items-center gap-2">
<button
className="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-bg text-text-secondary"
onClick={() => window.open(account.profile_url, '_blank')}
>
<ExternalLink size={16} />
</button>
<button className="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-bg text-text-secondary">
<RefreshCw size={16} />
</button>
</div>
</div>
</Card>
))}
</div>
)}
</div>
)}
{/* Posts Tab */}
{activeTab === 'posts' && (
<div className="space-y-4">
{posts.length === 0 ? (
<Card className="p-8 text-center">
<MessageSquare size={48} className="mx-auto text-text-tertiary mb-4" />
<h3 className="text-lg font-medium text-text-primary mb-2">
Inga inlägg än
</h3>
<p className="text-sm text-text-secondary">
Inlägg visas här när du har kopplat konton
</p>
</Card>
) : (
<div className="space-y-4">
{posts.map((post) => (
<Card key={post.id}>
<div className="flex items-start gap-4">
<div className={`w-10 h-10 rounded-lg ${platformColors[post.platform] || 'bg-gray-500'} flex items-center justify-center text-white flex-shrink-0`}>
{platformIcons[post.platform] || <Globe size={16} />}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm text-text-primary mb-2">{post.content}</p>
{post.media_url && (
<img
src={post.media_url}
alt="Post media"
className="rounded-lg max-h-48 object-cover mb-3"
/>
)}
<div className="flex items-center gap-4 text-xs text-text-secondary">
<span className="flex items-center gap-1">
<Heart size={14} />
{post.likes}
</span>
<span className="flex items-center gap-1">
<MessageSquare size={14} />
{post.comments}
</span>
<span className="flex items-center gap-1">
<Share2 size={14} />
{post.shares}
</span>
<span>{post.reach.toLocaleString()} reach</span>
<span></span>
<span>{new Date(post.posted_at).toLocaleDateString()}</span>
</div>
</div>
</div>
</Card>
))}
</div>
)}
</div>
)}
{/* Analytics Tab */}
{activeTab === 'analytics' && (
<Card className="p-8 text-center">
<RefreshCw size={48} className="mx-auto text-text-tertiary mb-4" />
<h3 className="text-lg font-medium text-text-primary mb-2">
Analytics kommer snart
</h3>
<p className="text-sm text-text-secondary">
Detaljerad analys av dina sociala media-kanaler
</p>
</Card>
)}
</div>
)
}
+51
View File
@@ -0,0 +1,51 @@
import { motion } from 'framer-motion';
import { TicketWidget } from '@/components/dashboard/TicketWidget';
import { ProjectWidget } from '@/components/dashboard/ProjectWidget';
import { ServiceHealthWidget } from '@/components/dashboard/ServiceHealthWidget';
import { MarketingWidget } from '@/components/dashboard/MarketingWidget';
import { SLAWidget } from '@/components/dashboard/SLAWidget';
export function UnifiedDashboardPage() {
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold">Dashboard</h1>
<p className="text-gray-500">Real-time overview of all operations</p>
</div>
{/* Widgets Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<TicketWidget />
<ProjectWidget />
<ServiceHealthWidget />
<MarketingWidget />
<SLAWidget />
{/* Quick Actions */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.5 }}
className="bg-white border rounded-xl p-6"
>
<h3 className="font-semibold mb-4">Quick Actions</h3>
<div className="space-y-2">
<a href="/support" className="flex items-center gap-3 p-3 bg-blue-50 rounded-lg hover:bg-blue-100 transition-colors">
<span className="text-blue-600">+</span>
<span className="text-sm font-medium">New Support Ticket</span>
</a>
<a href="/projects" className="flex items-center gap-3 p-3 bg-purple-50 rounded-lg hover:bg-purple-100 transition-colors">
<span className="text-purple-600">+</span>
<span className="text-sm font-medium">New Project Issue</span>
</a>
<a href="/marketing" className="flex items-center gap-3 p-3 bg-pink-50 rounded-lg hover:bg-pink-100 transition-colors">
<span className="text-pink-600">+</span>
<span className="text-sm font-medium">New Campaign</span>
</a>
</div>
</motion.div>
</div>
</div>
);
}
+21
View File
@@ -0,0 +1,21 @@
export { AlvaPage } from './AlvaPage';
export { MailPage } from './MailPage';
export { MarketingPage } from './MarketingPage';
export { SupportPage } from './SupportPage';
export { ProjectsPage } from './ProjectsPage';
export { DashboardPage } from './DashboardPage';
export { FinancePage } from './FinancePage';
export { HRPage } from './HRPage';
export { LegalPage } from './LegalPage';
export { CRMPage } from './CRMPage';
export { SalesPage } from './SalesPage';
export { AutomationPage } from './AutomationPage';
export { AMOSControlPage } from './AMOSControlPage';
export { QuixzoomPage } from './QuixzoomPage';
export { LandvexPage } from './LandvexPage';
export { CompliancePage } from './CompliancePage';
export { AccountingPage } from './AccountingPage';
export { SocialMediaPage } from './SocialMediaPage';
export { BriefingPage } from './BriefingPage';
export { ProfilePage } from './ProfilePage';
export { LoginPage } from './LoginPage';