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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
Reference in New Issue
Block a user