BOC v1.0: RS256 auth, Ledger integration, Prometheus metrics, CI/CD, backup

This commit is contained in:
Bernt
2026-07-28 23:08:32 +00:00
parent 0b4f160af1
commit af874040ca
11541 changed files with 1654104 additions and 1103 deletions
+207
View File
@@ -0,0 +1,207 @@
import { Card, CardHeader } from '@/components/ui/Card'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/Button'
import {
Zap,
Plus,
Play,
Pause,
CheckCircle2,
GitBranch,
Mail,
MessageSquare,
FileText,
UserPlus,
} from 'lucide-react'
const workflows = [
{
id: '1',
name: 'New Lead Notification',
description: 'Send Slack notification when a new lead is created',
status: 'active' as const,
trigger: 'Lead Created',
actions: ['Send Email', 'Slack Message'],
lastRun: '2 min ago',
runs: 1240,
},
{
id: '2',
name: 'Invoice Reminder',
description: 'Send reminder 3 days before invoice due date',
status: 'active' as const,
trigger: 'Schedule',
actions: ['Send Email'],
lastRun: '1 hour ago',
runs: 856,
},
{
id: '3',
name: 'Customer Onboarding',
description: 'Welcome email series for new customers',
status: 'active' as const,
trigger: 'Customer Created',
actions: ['Send Email', 'Create Task', 'Add to CRM'],
lastRun: '15 min ago',
runs: 342,
},
{
id: '4',
name: 'Deal Stage Alert',
description: 'Notify sales team when deal reaches negotiation',
status: 'paused' as const,
trigger: 'Deal Updated',
actions: ['Send Email', 'Slack Message'],
lastRun: '3 days ago',
runs: 89,
},
{
id: '5',
name: 'Employee Offboarding',
description: 'Automated offboarding checklist',
status: 'active' as const,
trigger: 'Employee Terminated',
actions: ['Create Task', 'Send Email', 'Update HR'],
lastRun: '1 week ago',
runs: 12,
},
]
const actionIcons: Record<string, React.ReactNode> = {
'Send Email': <Mail size={12} />,
'Slack Message': <MessageSquare size={12} />,
'Create Task': <FileText size={12} />,
'Add to CRM': <UserPlus size={12} />,
'Update HR': <UserPlus size={12} />,
}
export function AutomationPage() {
const activeWorkflows = workflows.filter((w) => w.status === 'active').length
const totalRuns = workflows.reduce((sum, w) => sum + w.runs, 0)
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">Automation</h1>
<p className="text-sm text-text-secondary mt-0.5">Workflows and automated tasks</p>
</div>
<Button icon={<Plus size={16} />}>New Workflow</Button>
</div>
{/* Quick Stats */}
<div className="grid grid-cols-2 lg: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">
<Zap size={18} />
</div>
<div>
<p className="text-2xl font-semibold text-text-primary">{workflows.length}</p>
<p className="text-xs text-text-secondary">Workflows</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">
<Play size={18} />
</div>
<div>
<p className="text-2xl font-semibold text-text-primary">{activeWorkflows}</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">
<Pause size={18} />
</div>
<div>
<p className="text-2xl font-semibold text-text-primary">{workflows.length - activeWorkflows}</p>
<p className="text-xs text-text-secondary">Paused</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">
<CheckCircle2 size={18} />
</div>
<div>
<p className="text-2xl font-semibold text-text-primary">{totalRuns.toLocaleString()}</p>
<p className="text-xs text-text-secondary">Total Runs</p>
</div>
</div>
</Card>
</div>
{/* Workflows */}
<Card>
<CardHeader title="Workflows" subtitle="Automated processes and triggers" />
<div className="space-y-3">
{workflows.map((workflow) => (
<div
key={workflow.id}
className="flex items-center justify-between p-4 rounded-xl bg-bg/50 hover:bg-bg transition-colors"
>
<div className="flex items-center gap-4">
<div className={`w-10 h-10 rounded-xl flex items-center justify-center ${
workflow.status === 'active' ? 'bg-success-light text-success' : 'bg-warning-light text-warning'
}`}>
{workflow.status === 'active' ? <Play size={18} /> : <Pause size={18} />}
</div>
<div>
<div className="flex items-center gap-2">
<span className="font-medium text-text-primary">{workflow.name}</span>
<Badge
variant={workflow.status === 'active' ? 'success' : 'warning'}
size="sm"
>
{workflow.status}
</Badge>
</div>
<p className="text-xs text-text-secondary mt-0.5">{workflow.description}</p>
<div className="flex items-center gap-3 mt-2">
<div className="flex items-center gap-1.5">
<GitBranch size={12} className="text-text-secondary/60" />
<span className="text-[11px] text-text-secondary">{workflow.trigger}</span>
</div>
<div className="flex items-center gap-1">
{workflow.actions.map((action, i) => (
<span
key={i}
className="inline-flex items-center gap-1 px-1.5 py-0.5 bg-bg rounded text-[10px] text-text-secondary"
>
{actionIcons[action]}
{action}
</span>
))}
</div>
</div>
</div>
</div>
<div className="flex items-center gap-6 text-right">
<div>
<p className="text-sm font-medium text-text-primary">{workflow.runs.toLocaleString()}</p>
<p className="text-[11px] text-text-secondary">runs</p>
</div>
<div>
<p className="text-sm font-medium text-text-primary">{workflow.lastRun}</p>
<p className="text-[11px] text-text-secondary">last run</p>
</div>
<div className="flex items-center">
<div className="w-9 h-5 rounded-full bg-success/20 relative cursor-pointer">
<div className="absolute right-0.5 top-0.5 w-4 h-4 rounded-full bg-success" />
</div>
</div>
</div>
</div>
))}
</div>
</Card>
</div>
)
}
+381
View File
@@ -0,0 +1,381 @@
import { useEffect, useState } from 'react'
import { Card } from '@/components/ui/Card'
import { Skeleton } from '@/components/ui/Skeleton'
import {
Table,
TableHead,
TableBody,
TableRow,
TableHeader,
TableCell,
} from '@/components/ui/Table'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/Button'
import { EmptyState } from '@/components/ui/EmptyState'
import { formatDate } from '@/lib/utils'
import { crmApi } from '@/lib/api'
import {
Users,
Search,
Plus,
Filter,
Mail,
Building2,
MoreHorizontal,
} from 'lucide-react'
interface Customer {
id: string
name: string
email: string
phone?: string
company: string
org_number?: string
status: string
source?: string
tags?: string[] | null
assigned_to?: string | null
created_at: string
updated_at: string
}
interface Lead {
id: string
name: string
email: string
phone?: string
company: string
org_number?: string
status: string
source?: string
tags?: string[] | null
assigned_to?: string | null
created_at: string
updated_at: string
}
interface PipelineStage {
id: string
name: string
order: number
deals: number
value: number
}
const tabs = ['Customers', 'Leads', 'Pipeline']
export function CRMPage() {
const [activeTab, setActiveTab] = useState('Customers')
const [searchQuery, setSearchQuery] = useState('')
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [customers, setCustomers] = useState<Customer[]>([])
const [customerTotal, setCustomerTotal] = useState(0)
const [leads, setLeads] = useState<Lead[]>([])
const [pipelineStages, setPipelineStages] = useState<PipelineStage[]>([])
useEffect(() => {
async function fetchData() {
setLoading(true)
setError('')
try {
const [customersRes, leadsRes, pipelineRes] = await Promise.all([
crmApi.customers(),
crmApi.leads(),
crmApi.pipeline(),
])
setCustomers(customersRes.customers as Customer[])
setCustomerTotal(customersRes.total)
setLeads(leadsRes.leads as Lead[])
const p = pipelineRes as { stages?: PipelineStage[]; pipeline?: PipelineStage[] }
setPipelineStages(p.stages || p.pipeline || [])
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load CRM data')
} finally {
setLoading(false)
}
}
fetchData()
}, [])
const filteredCustomers = customers.filter(
(c) =>
c.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
(c.company || '').toLowerCase().includes(searchQuery.toLowerCase())
)
const filteredLeads = leads.filter(
(l) =>
l.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
(l.company || '').toLowerCase().includes(searchQuery.toLowerCase())
)
const totalPipelineValue = pipelineStages.reduce((sum, s) => sum + (s.value || 0), 0)
if (loading) {
return (
<div className="space-y-8">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-5">
<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">CRM</h1>
<p className="text-sm text-text-secondary mt-0.5">Manage customers, leads, and pipeline</p>
</div>
<Button icon={<Plus size={16} />}>Add Contact</Button>
</div>
{/* Quick Stats */}
<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">{customerTotal}</p>
<p className="text-xs text-text-secondary">Total Customers</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">
<Mail size={18} />
</div>
<div>
<p className="text-2xl font-semibold text-text-primary">{leads.length}</p>
<p className="text-xs text-text-secondary">Active Leads</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">
<Building2 size={18} />
</div>
<div>
<p className="text-2xl font-semibold text-text-primary">
{new Intl.NumberFormat('sv-SE', { style: 'currency', currency: 'SEK', maximumFractionDigits: 0 }).format(totalPipelineValue)}
</p>
<p className="text-xs text-text-secondary">Pipeline Value</p>
</div>
</div>
</Card>
</div>
{/* Tabs + Search */}
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div className="flex items-center gap-1 bg-bg rounded-xl p-1">
{tabs.map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
activeTab === tab
? 'bg-surface text-text-primary shadow-sm'
: 'text-text-secondary hover:text-text-primary'
}`}
>
{tab}
</button>
))}
</div>
<div className="flex items-center gap-2 w-full sm:w-auto">
<div className="relative flex-1 sm:flex-initial">
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-secondary" />
<input
type="text"
placeholder="Search..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full sm:w-64 h-10 pl-9 pr-4 rounded-[14px] border bg-surface text-sm text-text-primary placeholder:text-text-secondary/50 focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary/30"
/>
</div>
<Button variant="secondary" size="sm" icon={<Filter size={14} />}>
Filter
</Button>
</div>
</div>
{/* Tab Content */}
{activeTab === 'Customers' && (
<Card>
<Table>
<TableHead>
<TableRow>
<TableHeader>Customer</TableHeader>
<TableHeader>Company</TableHeader>
<TableHeader>Status</TableHeader>
<TableHeader>Phone</TableHeader>
<TableHeader>Created</TableHeader>
<TableHeader align="right"></TableHeader>
</TableRow>
</TableHead>
<TableBody>
{filteredCustomers.map((customer) => (
<TableRow key={customer.id}>
<TableCell>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-primary/10 text-primary flex items-center justify-center text-xs font-semibold">
{customer.name.charAt(0)}
</div>
<div>
<p className="font-medium text-text-primary">{customer.name}</p>
<p className="text-xs text-text-secondary">{customer.email}</p>
</div>
</div>
</TableCell>
<TableCell>{customer.company || '—'}</TableCell>
<TableCell>
<Badge
variant={
customer.status === 'active'
? 'success'
: customer.status === 'lead'
? 'primary'
: 'default'
}
>
{customer.status}
</Badge>
</TableCell>
<TableCell>{customer.phone || '—'}</TableCell>
<TableCell>{formatDate(customer.created_at)}</TableCell>
<TableCell align="right">
<button className="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-bg text-text-secondary">
<MoreHorizontal size={16} />
</button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{filteredCustomers.length === 0 && (
<EmptyState
icon={<Users size={32} />}
title="No customers found"
description="Try adjusting your search or filters"
/>
)}
</Card>
)}
{activeTab === 'Leads' && (
<Card>
<Table>
<TableHead>
<TableRow>
<TableHeader>Lead</TableHeader>
<TableHeader>Company</TableHeader>
<TableHeader>Source</TableHeader>
<TableHeader>Status</TableHeader>
<TableHeader>Created</TableHeader>
<TableHeader align="right"></TableHeader>
</TableRow>
</TableHead>
<TableBody>
{filteredLeads.map((lead) => (
<TableRow key={lead.id}>
<TableCell>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-primary/10 text-primary flex items-center justify-center text-xs font-semibold">
{lead.name.charAt(0)}
</div>
<div>
<p className="font-medium text-text-primary">{lead.name}</p>
<p className="text-xs text-text-secondary">{lead.email}</p>
</div>
</div>
</TableCell>
<TableCell>{lead.company || '—'}</TableCell>
<TableCell>{lead.source || '—'}</TableCell>
<TableCell>
<Badge
variant={
lead.status === 'qualified'
? 'success'
: lead.status === 'contacted'
? 'primary'
: lead.status === 'lost'
? 'danger'
: 'default'
}
>
{lead.status}
</Badge>
</TableCell>
<TableCell>{formatDate(lead.created_at)}</TableCell>
<TableCell align="right">
<button className="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-bg text-text-secondary">
<MoreHorizontal size={16} />
</button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{filteredLeads.length === 0 && (
<EmptyState
icon={<Mail size={32} />}
title="No leads found"
description="Try adjusting your search or filters"
/>
)}
</Card>
)}
{activeTab === 'Pipeline' && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
{pipelineStages.length === 0 && (
<div className="col-span-full text-center text-text-secondary py-12">
No pipeline stages found
</div>
)}
{pipelineStages.map((stage) => (
<Card key={stage.id} hover>
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-semibold text-text-primary">{stage.name}</h3>
<Badge variant="default">{stage.deals} deals</Badge>
</div>
<p className="text-2xl font-semibold text-text-primary">
{stage.value > 0
? new Intl.NumberFormat('sv-SE', { style: 'currency', currency: 'SEK', maximumFractionDigits: 0 }).format(stage.value)
: '—'}
</p>
<div className="mt-4 h-1.5 bg-bg rounded-full overflow-hidden">
<div
className="h-full bg-primary rounded-full transition-all"
style={{
width: `${totalPipelineValue > 0 ? (stage.value / totalPipelineValue) * 100 : 0}%`,
}}
/>
</div>
</Card>
))}
</div>
)}
</div>
)
}
+214
View File
@@ -0,0 +1,214 @@
import { useEffect, useState } from 'react'
import { KPICard } from '@/components/KPICard'
import { RevenueChart } from '@/components/RevenueChart'
import { ActivityFeed } from '@/components/ActivityFeed'
import { Card, CardHeader } from '@/components/ui/Card'
import { Skeleton } from '@/components/ui/Skeleton'
import {
Table,
TableHead,
TableBody,
TableRow,
TableHeader,
TableCell,
} from '@/components/ui/Table'
import { Badge } from '@/components/ui/Badge'
import { formatCurrency, formatDate } from '@/lib/utils'
import { financeApi, salesApi, crmApi } from '@/lib/api'
import {
DollarSign,
Users,
TrendingUp,
ShoppingCart,
ArrowUpRight,
} from 'lucide-react'
interface Deal {
id: string
name: string
company: string
value: number
stage: string
probability: number
expectedClose: string
owner: string
}
export function DashboardPage() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [totalAssets, setTotalAssets] = useState(0)
const [mrr, setMrr] = useState(0)
const [customerCount, setCustomerCount] = useState(0)
const [deals, setDeals] = useState<Deal[]>([])
const [dealTotal, setDealTotal] = useState(0)
useEffect(() => {
async function fetchData() {
setLoading(true)
setError('')
try {
const [balanceRes, mrrRes, customersRes, dealsRes] = await Promise.all([
financeApi.balance(),
salesApi.mrr(),
crmApi.customers(),
salesApi.deals(),
])
const b = balanceRes as { total_assets?: number }
setTotalAssets(b.total_assets || 0)
const m = mrrRes as unknown as { mrr?: number }
setMrr(m.mrr || 0)
setCustomerCount(customersRes.total || 0)
const d = dealsRes as { deals: Deal[]; total: number }
setDeals(d.deals || [])
setDealTotal(d.total || 0)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load dashboard data')
} finally {
setLoading(false)
}
}
fetchData()
}, [])
const kpis = [
{
label: 'Total Assets',
value: formatCurrency(totalAssets),
change: 0,
changeLabel: 'vs last month',
icon: <DollarSign size={18} />,
},
{
label: 'Active Customers',
value: String(customerCount),
change: 0,
changeLabel: 'vs last month',
icon: <Users size={18} />,
},
{
label: 'MRR',
value: formatCurrency(mrr),
change: 0,
changeLabel: 'vs last month',
icon: <TrendingUp size={18} />,
},
{
label: 'Open Deals',
value: String(dealTotal),
change: 0,
changeLabel: 'vs last month',
icon: <ShoppingCart size={18} />,
},
]
if (loading) {
return (
<div className="space-y-10">
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-5">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-[140px]" />
))}
</div>
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
<Skeleton className="h-[320px] xl:col-span-2" />
<Skeleton className="h-[320px]" />
</div>
<Skeleton className="h-[300px]" />
</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-10">
{/* KPI Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-5">
{kpis.map((kpi, i) => (
<KPICard key={kpi.label} {...kpi} index={i} />
))}
</div>
{/* Revenue Chart + Activity */}
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
<div className="xl:col-span-2">
<RevenueChart />
</div>
<div>
<ActivityFeed />
</div>
</div>
{/* Recent Deals */}
<Card>
<CardHeader
title="Recent Deals"
subtitle="Latest deals across your pipeline"
action={
<button className="text-sm text-primary font-medium flex items-center gap-1 hover:underline">
View all <ArrowUpRight size={14} />
</button>
}
/>
<Table>
<TableHead>
<TableRow>
<TableHeader>Deal</TableHeader>
<TableHeader>Company</TableHeader>
<TableHeader align="right">Value</TableHeader>
<TableHeader>Stage</TableHeader>
<TableHeader align="right">Probability</TableHeader>
<TableHeader>Close Date</TableHeader>
</TableRow>
</TableHead>
<TableBody>
{deals.length === 0 && (
<TableRow>
<TableCell colSpan={6} className="text-center text-text-secondary py-8">
No deals found
</TableCell>
</TableRow>
)}
{deals.map((deal) => (
<TableRow key={deal.id}>
<TableCell>
<span className="font-medium">{deal.name}</span>
</TableCell>
<TableCell>{deal.company}</TableCell>
<TableCell align="right">{formatCurrency(deal.value)}</TableCell>
<TableCell>
<Badge
variant={
deal.stage === 'Closed Won'
? 'success'
: deal.stage === 'Negotiation'
? 'primary'
: 'default'
}
>
{deal.stage}
</Badge>
</TableCell>
<TableCell align="right">{deal.probability}%</TableCell>
<TableCell>{formatDate(deal.expectedClose)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
</div>
)
}
+580
View File
@@ -0,0 +1,580 @@
import { useEffect, useState } from 'react'
import { Card, CardHeader } from '@/components/ui/Card'
import { Skeleton } from '@/components/ui/Skeleton'
import {
Table,
TableHead,
TableBody,
TableRow,
TableHeader,
TableCell,
} from '@/components/ui/Table'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/Button'
import { formatCurrency, formatDate } from '@/lib/utils'
import { financeApi } from '@/lib/api'
import {
Tooltip,
ResponsiveContainer,
PieChart,
Pie,
Cell,
} from 'recharts'
import {
Wallet,
TrendingUp,
TrendingDown,
Plus,
Search,
Filter,
MoreHorizontal,
ArrowUpRight,
ArrowDownRight,
} from 'lucide-react'
interface BalanceAccount {
account: string
amount: number
}
interface BalanceData {
assets: BalanceAccount[]
liabilities: BalanceAccount[]
equity: BalanceAccount[]
total_assets: number
total_liabilities: number
total_equity: number
period: string
}
interface IncomeData {
revenue: BalanceAccount[]
expenses: BalanceAccount[]
total_revenue: number
total_expenses: number
net_income: number
period: string
}
interface MOMSData {
moms_in: number
moms_ut: number
moms_att_betala: number
period: string
}
interface AccountItem {
id: string
name: string
balance: number
type: string
}
interface InvoiceItem {
id: string
customer: string
amount: number
status: string
due_date: string
}
interface CashflowData {
inflow: number
outflow: number
net: number
period: string
}
const tabs = ['Balance', 'Income', 'MOMS', 'Accounts', 'Invoices', 'Cashflow']
const COLORS = ['#2563EB', '#16A34A', '#D97706', '#DC2626', '#9CA3AF']
export function FinancePage() {
const [activeTab, setActiveTab] = useState('Balance')
const [searchQuery, setSearchQuery] = useState('')
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [balance, setBalance] = useState<BalanceData | null>(null)
const [income, setIncome] = useState<IncomeData | null>(null)
const [moms, setMoms] = useState<MOMSData | null>(null)
const [accounts, setAccounts] = useState<AccountItem[]>([])
const [invoices, setInvoices] = useState<InvoiceItem[]>([])
const [cashflow, setCashflow] = useState<CashflowData | null>(null)
useEffect(() => {
async function fetchData() {
setLoading(true)
setError('')
try {
const [balanceRes, incomeRes, momsRes, accountsRes, invoicesRes, cashflowRes] = await Promise.all([
financeApi.balance(),
financeApi.income(),
financeApi.moms(),
financeApi.accounts(),
financeApi.invoices(),
financeApi.cashflow(),
])
setBalance(balanceRes as BalanceData)
setIncome(incomeRes as IncomeData)
setMoms(momsRes as MOMSData)
setAccounts((accountsRes as { accounts: AccountItem[] }).accounts || [])
setInvoices((invoicesRes as { invoices: InvoiceItem[] }).invoices || [])
setCashflow(cashflowRes as unknown as CashflowData)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load finance data')
} finally {
setLoading(false)
}
}
fetchData()
}, [])
const filteredAccounts = accounts.filter(
(a) =>
a.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
a.id.toLowerCase().includes(searchQuery.toLowerCase())
)
const filteredInvoices = invoices.filter(
(i) =>
i.customer.toLowerCase().includes(searchQuery.toLowerCase()) ||
i.id.toLowerCase().includes(searchQuery.toLowerCase())
)
const expenseBreakdown = income?.expenses.map((e, i) => ({
name: e.account,
value: e.amount,
color: COLORS[i % COLORS.length],
})) || []
if (loading) {
return (
<div className="space-y-8">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-5">
<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">Finance</h1>
<p className="text-sm text-text-secondary mt-0.5">Financial reports and accounting</p>
</div>
<Button icon={<Plus size={16} />}>New Invoice</Button>
</div>
{/* Quick Stats */}
<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">{formatCurrency(balance?.total_assets || 0)}</p>
<p className="text-xs text-text-secondary">Total Assets</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">
<TrendingUp size={18} />
</div>
<div>
<p className="text-2xl font-semibold text-text-primary">{formatCurrency(income?.total_revenue || 0)}</p>
<p className="text-xs text-text-secondary">YTD Revenue</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">
<TrendingDown size={18} />
</div>
<div>
<p className="text-2xl font-semibold text-text-primary">{formatCurrency(income?.total_expenses || 0)}</p>
<p className="text-xs text-text-secondary">YTD Expenses</p>
</div>
</div>
</Card>
</div>
{/* Tabs + Search */}
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div className="flex items-center gap-1 bg-bg rounded-xl p-1 overflow-x-auto max-w-full">
{tabs.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}
</button>
))}
</div>
<div className="flex items-center gap-2 w-full sm:w-auto">
<div className="relative flex-1 sm:flex-initial">
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-secondary" />
<input
type="text"
placeholder="Search..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full sm:w-64 h-10 pl-9 pr-4 rounded-[14px] border bg-surface text-sm text-text-primary placeholder:text-text-secondary/50 focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary/30"
/>
</div>
<Button variant="secondary" size="sm" icon={<Filter size={14} />}>
Filter
</Button>
</div>
</div>
{/* Tab Content */}
{activeTab === 'Balance' && (
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-5">
<Card>
<div className="flex items-center justify-between mb-3">
<span className="text-sm text-text-secondary">Assets</span>
<ArrowUpRight size={14} className="text-success" />
</div>
<p className="text-2xl font-semibold text-text-primary">{formatCurrency(balance?.total_assets || 0)}</p>
</Card>
<Card>
<div className="flex items-center justify-between mb-3">
<span className="text-sm text-text-secondary">Liabilities</span>
<ArrowDownRight size={14} className="text-danger" />
</div>
<p className="text-2xl font-semibold text-text-primary">{formatCurrency(balance?.total_liabilities || 0)}</p>
</Card>
<Card>
<div className="flex items-center justify-between mb-3">
<span className="text-sm text-text-secondary">Equity</span>
<ArrowUpRight size={14} className="text-success" />
</div>
<p className="text-2xl font-semibold text-text-primary">{formatCurrency(balance?.total_equity || 0)}</p>
</Card>
</div>
<Card>
<CardHeader title="Balance Sheet" subtitle={`Period: ${balance?.period || ''}`} />
<div className="space-y-6">
<div>
<h4 className="text-sm font-semibold text-text-primary mb-3">Assets</h4>
<div className="space-y-2">
{balance?.assets.map((account, idx) => (
<div key={idx} className="flex items-center justify-between py-2 border-b border-border/40 last:border-0">
<div>
<p className="text-sm text-text-primary">{account.account}</p>
</div>
<p className="text-sm font-medium text-text-primary">{formatCurrency(account.amount)}</p>
</div>
))}
{(!balance?.assets || balance.assets.length === 0) && (
<p className="text-text-secondary text-sm">No asset accounts</p>
)}
</div>
</div>
<div>
<h4 className="text-sm font-semibold text-text-primary mb-3">Liabilities</h4>
<div className="space-y-2">
{balance?.liabilities.map((account, idx) => (
<div key={idx} className="flex items-center justify-between py-2 border-b border-border/40 last:border-0">
<div>
<p className="text-sm text-text-primary">{account.account}</p>
</div>
<p className="text-sm font-medium text-text-primary">{formatCurrency(account.amount)}</p>
</div>
))}
{(!balance?.liabilities || balance.liabilities.length === 0) && (
<p className="text-text-secondary text-sm">No liability accounts</p>
)}
</div>
</div>
<div>
<h4 className="text-sm font-semibold text-text-primary mb-3">Equity</h4>
<div className="space-y-2">
{balance?.equity.map((account, idx) => (
<div key={idx} className="flex items-center justify-between py-2 border-b border-border/40 last:border-0">
<div>
<p className="text-sm text-text-primary">{account.account}</p>
</div>
<p className="text-sm font-medium text-text-primary">{formatCurrency(account.amount)}</p>
</div>
))}
{(!balance?.equity || balance.equity.length === 0) && (
<p className="text-text-secondary text-sm">No equity accounts</p>
)}
</div>
</div>
</div>
</Card>
</div>
)}
{activeTab === 'Income' && (
<div className="space-y-6">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-5">
<Card className="lg:col-span-2">
<CardHeader title="Income Statement" subtitle={`Period: ${income?.period || ''}`} />
<div className="space-y-4">
<div className="grid grid-cols-3 gap-4">
<div className="p-4 bg-bg rounded-xl">
<p className="text-xs text-text-secondary mb-1">Revenue</p>
<p className="text-lg font-semibold text-text-primary">{formatCurrency(income?.total_revenue || 0)}</p>
</div>
<div className="p-4 bg-bg rounded-xl">
<p className="text-xs text-text-secondary mb-1">Expenses</p>
<p className="text-lg font-semibold text-text-primary">{formatCurrency(income?.total_expenses || 0)}</p>
</div>
<div className="p-4 bg-bg rounded-xl">
<p className="text-xs text-text-secondary mb-1">Net Income</p>
<p className="text-lg font-semibold text-success">{formatCurrency(income?.net_income || 0)}</p>
</div>
</div>
<div className="space-y-2">
<h4 className="text-sm font-semibold text-text-primary">Revenue Accounts</h4>
{income?.revenue.map((r, idx) => (
<div key={idx} className="flex items-center justify-between py-2 border-b border-border/40 last:border-0">
<p className="text-sm text-text-primary">{r.account}</p>
<p className="text-sm font-medium text-text-primary">{formatCurrency(r.amount)}</p>
</div>
))}
</div>
<div className="space-y-2">
<h4 className="text-sm font-semibold text-text-primary">Expense Accounts</h4>
{income?.expenses.map((e, idx) => (
<div key={idx} className="flex items-center justify-between py-2 border-b border-border/40 last:border-0">
<p className="text-sm text-text-primary">{e.account}</p>
<p className="text-sm font-medium text-text-primary">{formatCurrency(e.amount)}</p>
</div>
))}
</div>
</div>
</Card>
<Card>
<CardHeader title="Expense Breakdown" subtitle="By category" />
{expenseBreakdown.length > 0 ? (
<>
<div className="h-[220px]">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={expenseBreakdown}
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={90}
paddingAngle={4}
dataKey="value"
>
{expenseBreakdown.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip
content={({ active, payload }) => {
if (!active || !payload?.length) return null
const data = payload[0].payload as { name: string; value: number }
return (
<div className="bg-surface rounded-xl dropdown-shadow border border-border/60 px-3 py-2">
<p className="text-sm font-medium text-text-primary">{data.name}</p>
<p className="text-xs text-text-secondary">{formatCurrency(data.value)}</p>
</div>
)
}}
/>
</PieChart>
</ResponsiveContainer>
</div>
<div className="space-y-2 mt-2">
{expenseBreakdown.map((item) => (
<div key={item.name} className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
<div className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: item.color }} />
<span className="text-text-secondary">{item.name}</span>
</div>
<span className="font-medium text-text-primary">{formatCurrency(item.value)}</span>
</div>
))}
</div>
</>
) : (
<div className="h-[200px] flex items-center justify-center text-text-secondary">
No expense data
</div>
)}
</Card>
</div>
</div>
)}
{activeTab === 'MOMS' && (
<Card>
<CardHeader title="MOMS Report" subtitle={`Period: ${moms?.period || ''}`} />
<div className="grid grid-cols-1 md:grid-cols-3 gap-5 mb-6">
<div className="p-4 bg-bg rounded-xl">
<p className="text-xs text-text-secondary mb-1">MOMS In (Input VAT)</p>
<p className="text-lg font-semibold text-text-primary">{formatCurrency(moms?.moms_in || 0)}</p>
</div>
<div className="p-4 bg-bg rounded-xl">
<p className="text-xs text-text-secondary mb-1">MOMS Ut (Output VAT)</p>
<p className="text-lg font-semibold text-text-primary">{formatCurrency(moms?.moms_ut || 0)}</p>
</div>
<div className="p-4 bg-bg rounded-xl">
<p className="text-xs text-text-secondary mb-1">MOMS att betala</p>
<p className="text-lg font-semibold text-success">{formatCurrency(moms?.moms_att_betala || 0)}</p>
</div>
</div>
</Card>
)}
{activeTab === 'Accounts' && (
<Card>
<CardHeader title="Chart of Accounts" subtitle="General ledger accounts" />
<Table>
<TableHead>
<TableRow>
<TableHeader>Account</TableHeader>
<TableHeader>Number</TableHeader>
<TableHeader>Type</TableHeader>
<TableHeader align="right">Balance</TableHeader>
<TableHeader align="right"></TableHeader>
</TableRow>
</TableHead>
<TableBody>
{filteredAccounts.map((account) => (
<TableRow key={account.id}>
<TableCell>
<span className="font-medium">{account.name}</span>
</TableCell>
<TableCell>
<code className="text-xs bg-bg px-2 py-0.5 rounded-md">{account.id}</code>
</TableCell>
<TableCell>
<Badge variant={account.type === 'asset' ? 'success' : account.type === 'equity' ? 'primary' : 'warning'}>
{account.type}
</Badge>
</TableCell>
<TableCell align="right">{formatCurrency(account.balance)}</TableCell>
<TableCell align="right">
<button className="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-bg text-text-secondary">
<MoreHorizontal size={16} />
</button>
</TableCell>
</TableRow>
))}
{filteredAccounts.length === 0 && (
<TableRow>
<TableCell colSpan={5} className="text-center text-text-secondary py-8">
No accounts found
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</Card>
)}
{activeTab === 'Invoices' && (
<Card>
<CardHeader title="Invoices" subtitle="All invoices" />
<Table>
<TableHead>
<TableRow>
<TableHeader>Number</TableHeader>
<TableHeader>Customer</TableHeader>
<TableHeader align="right">Amount</TableHeader>
<TableHeader>Status</TableHeader>
<TableHeader>Due Date</TableHeader>
<TableHeader align="right"></TableHeader>
</TableRow>
</TableHead>
<TableBody>
{filteredInvoices.map((invoice) => (
<TableRow key={invoice.id}>
<TableCell>
<span className="font-medium">{invoice.id}</span>
</TableCell>
<TableCell>{invoice.customer}</TableCell>
<TableCell align="right">{formatCurrency(invoice.amount)}</TableCell>
<TableCell>
<Badge
variant={
invoice.status === 'paid'
? 'success'
: invoice.status === 'overdue'
? 'danger'
: invoice.status === 'sent' || invoice.status === 'pending'
? 'primary'
: 'default'
}
>
{invoice.status}
</Badge>
</TableCell>
<TableCell>{formatDate(invoice.due_date)}</TableCell>
<TableCell align="right">
<button className="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-bg text-text-secondary">
<MoreHorizontal size={16} />
</button>
</TableCell>
</TableRow>
))}
{filteredInvoices.length === 0 && (
<TableRow>
<TableCell colSpan={6} className="text-center text-text-secondary py-8">
No invoices found
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</Card>
)}
{activeTab === 'Cashflow' && (
<Card>
<CardHeader title="Cash Flow" subtitle={`Period: ${cashflow?.period || ''}`} />
<div className="grid grid-cols-1 md:grid-cols-3 gap-5 mb-6">
<div className="p-4 bg-bg rounded-xl">
<p className="text-xs text-text-secondary mb-1">Inflow</p>
<p className="text-lg font-semibold text-success">{formatCurrency(cashflow?.inflow || 0)}</p>
</div>
<div className="p-4 bg-bg rounded-xl">
<p className="text-xs text-text-secondary mb-1">Outflow</p>
<p className="text-lg font-semibold text-danger">{formatCurrency(cashflow?.outflow || 0)}</p>
</div>
<div className="p-4 bg-bg rounded-xl">
<p className="text-xs text-text-secondary mb-1">Net</p>
<p className="text-lg font-semibold text-text-primary">{formatCurrency(cashflow?.net || 0)}</p>
</div>
</div>
</Card>
)}
</div>
)
}
+344
View File
@@ -0,0 +1,344 @@
import { useEffect, useState } from 'react'
import { Card } from '@/components/ui/Card'
import { Skeleton } from '@/components/ui/Skeleton'
import {
Table,
TableHead,
TableBody,
TableRow,
TableHeader,
TableCell,
} from '@/components/ui/Table'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/Button'
import { formatDate } from '@/lib/utils'
import { hrApi } from '@/lib/api'
import {
Users,
Calendar,
Plus,
Search,
Filter,
MoreHorizontal,
Clock,
} from 'lucide-react'
interface Employee {
id: string
name: string
email: string
department: string
role: string
status: string
startDate: string
}
interface Leave {
id: string
employeeId: string
employeeName: string
type: string
startDate: string
endDate: string
status: string
days: number
}
const tabs = ['Employees', 'Leaves']
const departmentColors: Record<string, string> = {
Sales: 'primary',
Engineering: 'success',
Finance: 'warning',
HR: 'primary',
Legal: 'default',
}
export function HRPage() {
const [activeTab, setActiveTab] = useState('Employees')
const [searchQuery, setSearchQuery] = useState('')
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [employees, setEmployees] = useState<Employee[]>([])
const [leaves, setLeaves] = useState<Leave[]>([])
useEffect(() => {
async function fetchData() {
setLoading(true)
setError('')
try {
const [employeesRes, leavesRes] = await Promise.all([
hrApi.employees(),
hrApi.leaves(),
])
setEmployees((employeesRes as { employees: Employee[] }).employees || [])
setLeaves((leavesRes as { leaves: Leave[] }).leaves || [])
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load HR data')
} finally {
setLoading(false)
}
}
fetchData()
}, [])
const activeEmployees = employees.filter((e) => e.status === 'active').length
const onLeave = employees.filter((e) => e.status === 'on_leave').length
const pendingLeaves = leaves.filter((l) => l.status === 'pending').length
const filteredEmployees = employees.filter(
(e) =>
e.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
e.department.toLowerCase().includes(searchQuery.toLowerCase()) ||
e.role.toLowerCase().includes(searchQuery.toLowerCase())
)
const filteredLeaves = leaves.filter(
(l) =>
l.employeeName.toLowerCase().includes(searchQuery.toLowerCase()) ||
l.type.toLowerCase().includes(searchQuery.toLowerCase())
)
if (loading) {
return (
<div className="space-y-8">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-5">
<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">HR</h1>
<p className="text-sm text-text-secondary mt-0.5">Employees and leave management</p>
</div>
<Button icon={<Plus size={16} />}>Add Employee</Button>
</div>
{/* Quick Stats */}
<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">{activeEmployees}</p>
<p className="text-xs text-text-secondary">Active Employees</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">{onLeave}</p>
<p className="text-xs text-text-secondary">On Leave</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">
<Calendar size={18} />
</div>
<div>
<p className="text-2xl font-semibold text-text-primary">{pendingLeaves}</p>
<p className="text-xs text-text-secondary">Pending Requests</p>
</div>
</div>
</Card>
</div>
{/* Tabs + Search */}
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div className="flex items-center gap-1 bg-bg rounded-xl p-1">
{tabs.map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
activeTab === tab
? 'bg-surface text-text-primary shadow-sm'
: 'text-text-secondary hover:text-text-primary'
}`}
>
{tab}
</button>
))}
</div>
<div className="flex items-center gap-2 w-full sm:w-auto">
<div className="relative flex-1 sm:flex-initial">
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-secondary" />
<input
type="text"
placeholder="Search..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full sm:w-64 h-10 pl-9 pr-4 rounded-[14px] border bg-surface text-sm text-text-primary placeholder:text-text-secondary/50 focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary/30"
/>
</div>
<Button variant="secondary" size="sm" icon={<Filter size={14} />}>
Filter
</Button>
</div>
</div>
{/* Tab Content */}
{activeTab === 'Employees' && (
<Card>
<Table>
<TableHead>
<TableRow>
<TableHeader>Employee</TableHeader>
<TableHeader>Department</TableHeader>
<TableHeader>Role</TableHeader>
<TableHeader>Status</TableHeader>
<TableHeader>Start Date</TableHeader>
<TableHeader align="right"></TableHeader>
</TableRow>
</TableHead>
<TableBody>
{filteredEmployees.map((employee) => (
<TableRow key={employee.id}>
<TableCell>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-primary/10 text-primary flex items-center justify-center text-xs font-semibold">
{employee.name.split(' ').map((n) => n[0]).join('')}
</div>
<div>
<p className="font-medium text-text-primary">{employee.name}</p>
<p className="text-xs text-text-secondary">{employee.email}</p>
</div>
</div>
</TableCell>
<TableCell>
<Badge variant={(departmentColors[employee.department] as 'primary' | 'success' | 'warning' | 'default') || 'default'}>
{employee.department}
</Badge>
</TableCell>
<TableCell>{employee.role}</TableCell>
<TableCell>
<Badge
variant={
employee.status === 'active'
? 'success'
: employee.status === 'on_leave'
? 'warning'
: 'danger'
}
>
{employee.status.replace('_', ' ')}
</Badge>
</TableCell>
<TableCell>{formatDate(employee.startDate)}</TableCell>
<TableCell align="right">
<button className="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-bg text-text-secondary">
<MoreHorizontal size={16} />
</button>
</TableCell>
</TableRow>
))}
{filteredEmployees.length === 0 && (
<TableRow>
<TableCell colSpan={6} className="text-center text-text-secondary py-8">
No employees found
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</Card>
)}
{activeTab === 'Leaves' && (
<Card>
<Table>
<TableHead>
<TableRow>
<TableHeader>Employee</TableHeader>
<TableHeader>Type</TableHeader>
<TableHeader>Period</TableHeader>
<TableHeader align="right">Days</TableHeader>
<TableHeader>Status</TableHeader>
<TableHeader align="right"></TableHeader>
</TableRow>
</TableHead>
<TableBody>
{filteredLeaves.map((leave) => (
<TableRow key={leave.id}>
<TableCell>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-primary/10 text-primary flex items-center justify-center text-xs font-semibold">
{leave.employeeName.split(' ').map((n) => n[0]).join('')}
</div>
<span className="font-medium text-text-primary">{leave.employeeName}</span>
</div>
</TableCell>
<TableCell>
<Badge variant={leave.type === 'vacation' ? 'primary' : leave.type === 'sick' ? 'warning' : 'default'}>
{leave.type}
</Badge>
</TableCell>
<TableCell>
<span className="text-sm text-text-primary">
{formatDate(leave.startDate)} {formatDate(leave.endDate)}
</span>
</TableCell>
<TableCell align="right">
<span className="font-medium">{leave.days}</span>
</TableCell>
<TableCell>
<Badge
variant={
leave.status === 'approved'
? 'success'
: leave.status === 'pending'
? 'warning'
: 'danger'
}
>
{leave.status}
</Badge>
</TableCell>
<TableCell align="right">
<button className="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-bg text-text-secondary">
<MoreHorizontal size={16} />
</button>
</TableCell>
</TableRow>
))}
{filteredLeaves.length === 0 && (
<TableRow>
<TableCell colSpan={6} className="text-center text-text-secondary py-8">
No leave requests found
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</Card>
)}
</div>
)
}
+263
View File
@@ -0,0 +1,263 @@
import { useEffect, useState } from 'react'
import { Card } from '@/components/ui/Card'
import { Skeleton } from '@/components/ui/Skeleton'
import {
Table,
TableHead,
TableBody,
TableRow,
TableHeader,
TableCell,
} from '@/components/ui/Table'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/Button'
import { formatCurrency, formatDate } from '@/lib/utils'
import { legalApi } from '@/lib/api'
import {
FileText,
FileCheck,
Clock,
Plus,
Search,
Filter,
MoreHorizontal,
} from 'lucide-react'
interface Contract {
id: string
title: string
counterparty: string
type: string
status: string
startDate: string
endDate?: string
value?: number
}
const tabs = ['All', 'Signed', 'Review', 'Draft', 'Expired']
export function LegalPage() {
const [activeTab, setActiveTab] = useState('All')
const [searchQuery, setSearchQuery] = useState('')
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [contracts, setContracts] = useState<Contract[]>([])
useEffect(() => {
async function fetchData() {
setLoading(true)
setError('')
try {
const res = await legalApi.contracts()
setContracts((res as { contracts: Contract[] }).contracts || [])
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load contracts')
} finally {
setLoading(false)
}
}
fetchData()
}, [])
const signedCount = contracts.filter((c) => c.status === 'signed').length
const reviewCount = contracts.filter((c) => c.status === 'review').length
const totalValue = contracts.reduce((sum, c) => sum + (c.value || 0), 0)
const filteredContracts = contracts.filter((c) => {
const matchesSearch =
c.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
c.counterparty.toLowerCase().includes(searchQuery.toLowerCase())
const matchesTab = activeTab === 'All' || c.status === activeTab.toLowerCase()
return matchesSearch && matchesTab
})
if (loading) {
return (
<div className="space-y-8">
<div className="grid grid-cols-2 lg: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">Legal</h1>
<p className="text-sm text-text-secondary mt-0.5">Contracts and legal documents</p>
</div>
<Button icon={<Plus size={16} />}>New Contract</Button>
</div>
{/* Quick Stats */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-5">
<Card padding="md">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-primary-light flex items-center justify-center text-primary">
<FileText size={18} />
</div>
<div>
<p className="text-2xl font-semibold text-text-primary">{contracts.length}</p>
<p className="text-xs text-text-secondary">Total Contracts</p>
</div>
</div>
</Card>
<Card padding="md">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-success-light flex items-center justify-center text-success">
<FileCheck size={18} />
</div>
<div>
<p className="text-2xl font-semibold text-text-primary">{signedCount}</p>
<p className="text-xs text-text-secondary">Signed</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">{reviewCount}</p>
<p className="text-xs text-text-secondary">Under Review</p>
</div>
</div>
</Card>
<Card padding="md">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-primary-light flex items-center justify-center text-primary">
<FileText size={18} />
</div>
<div>
<p className="text-2xl font-semibold text-text-primary">{formatCurrency(totalValue)}</p>
<p className="text-xs text-text-secondary">Total Value</p>
</div>
</div>
</Card>
</div>
{/* Tabs + Search */}
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div className="flex items-center gap-1 bg-bg rounded-xl p-1">
{tabs.map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
activeTab === tab
? 'bg-surface text-text-primary shadow-sm'
: 'text-text-secondary hover:text-text-primary'
}`}
>
{tab}
</button>
))}
</div>
<div className="flex items-center gap-2 w-full sm:w-auto">
<div className="relative flex-1 sm:flex-initial">
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-secondary" />
<input
type="text"
placeholder="Search..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full sm:w-64 h-10 pl-9 pr-4 rounded-[14px] border bg-surface text-sm text-text-primary placeholder:text-text-secondary/50 focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary/30"
/>
</div>
<Button variant="secondary" size="sm" icon={<Filter size={14} />}>
Filter
</Button>
</div>
</div>
{/* Contracts Table */}
<Card>
<Table>
<TableHead>
<TableRow>
<TableHeader>Contract</TableHeader>
<TableHeader>Counterparty</TableHeader>
<TableHeader>Type</TableHeader>
<TableHeader align="right">Value</TableHeader>
<TableHeader>Status</TableHeader>
<TableHeader>Period</TableHeader>
<TableHeader align="right"></TableHeader>
</TableRow>
</TableHead>
<TableBody>
{filteredContracts.map((contract) => (
<TableRow key={contract.id}>
<TableCell>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-primary/10 text-primary flex items-center justify-center">
<FileText size={14} />
</div>
<span className="font-medium text-text-primary">{contract.title}</span>
</div>
</TableCell>
<TableCell>{contract.counterparty}</TableCell>
<TableCell>
<Badge variant="default">{contract.type}</Badge>
</TableCell>
<TableCell align="right">
{contract.value && contract.value > 0 ? formatCurrency(contract.value) : '—'}
</TableCell>
<TableCell>
<Badge
variant={
contract.status === 'signed'
? 'success'
: contract.status === 'review'
? 'warning'
: contract.status === 'draft'
? 'default'
: 'danger'
}
>
{contract.status}
</Badge>
</TableCell>
<TableCell>
<span className="text-sm text-text-secondary">
{formatDate(contract.startDate)}
{contract.endDate && `${formatDate(contract.endDate)}`}
</span>
</TableCell>
<TableCell align="right">
<button className="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-bg text-text-secondary">
<MoreHorizontal size={16} />
</button>
</TableCell>
</TableRow>
))}
{filteredContracts.length === 0 && (
<TableRow>
<TableCell colSpan={7} className="text-center text-text-secondary py-8">
No contracts found
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</Card>
</div>
)
}
+120
View File
@@ -0,0 +1,120 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { motion } from 'framer-motion'
import { Eye, EyeOff, Mail, Lock } from 'lucide-react'
import { Input } from '@/components/ui/Input'
import { Button } from '@/components/ui/Button'
import { useAuthStore } from '@/stores/authStore'
import { authApi } from '@/lib/api'
export function LoginPage() {
const navigate = useNavigate()
const { login } = useAuthStore()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setLoading(true)
try {
const res = await authApi.login(email, password)
if (!res.ok) {
setError('Invalid credentials')
return
}
// Save token FIRST so authApi.me() can use it
localStorage.setItem('amos_token', res.token)
// Fetch user info
const me = await authApi.me()
login(res.token, {
id: me.user.sub,
email: me.user.email,
name: 'Erik Svensson',
role: me.user.roles?.[0] || 'user',
})
navigate('/')
} catch (err) {
setError(err instanceof Error ? err.message : 'Login failed')
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen bg-bg flex items-center justify-center p-4">
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, ease: 'easeOut' }}
className="w-full max-w-[400px]"
>
{/* Logo */}
<div className="flex items-center justify-center mb-10">
<div className="w-12 h-12 rounded-xl bg-primary flex items-center justify-center">
<svg width="24" height="24" viewBox="0 0 32 32" fill="none">
<path d="M10 22L16 10L22 22H10Z" stroke="white" strokeWidth="2.5" strokeLinejoin="round" />
</svg>
</div>
</div>
<div className="text-center mb-8">
<h1 className="text-2xl font-semibold text-text-primary">Welcome back</h1>
<p className="text-sm text-text-secondary mt-1.5">Sign in to your AMOS account</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<Input
label="Email"
type="email"
placeholder="you@company.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
icon={<Mail size={16} />}
required
/>
<div className="relative">
<Input
label="Password"
type={showPassword ? 'text' : 'password'}
placeholder="Enter your password"
value={password}
onChange={(e) => setPassword(e.target.value)}
icon={<Lock size={16} />}
required
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3.5 top-[38px] text-text-secondary hover:text-text-primary transition-colors"
>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
{error && (
<motion.p
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
className="text-sm text-danger"
>
{error}
</motion.p>
)}
<Button type="submit" size="lg" loading={loading} className="w-full">
Sign in
</Button>
</form>
</motion.div>
</div>
)
}
+139
View File
@@ -0,0 +1,139 @@
import { Card, CardHeader } from '@/components/ui/Card'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/Button'
import {
Megaphone,
Plus,
BarChart3,
Target,
Mail,
TrendingUp,
Users,
} from 'lucide-react'
const campaigns = [
{ id: '1', name: 'Q4 Product Launch', status: 'active' as const, channel: 'Email', reach: 12500, engagement: 8.2, conversions: 340 },
{ id: '2', name: 'Holiday Special', status: 'scheduled' as const, channel: 'Social', reach: 0, engagement: 0, conversions: 0 },
{ id: '3', name: 'Customer Retention', status: 'active' as const, channel: 'Email', reach: 8400, engagement: 12.5, conversions: 520 },
{ id: '4', name: 'Partner Webinar', status: 'completed' as const, channel: 'Webinar', reach: 3200, engagement: 45.0, conversions: 180 },
]
export function MarketingPage() {
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">Marketing</h1>
<p className="text-sm text-text-secondary mt-0.5">Campaigns and marketing metrics</p>
</div>
<Button icon={<Plus size={16} />}>New Campaign</Button>
</div>
{/* Quick Stats */}
<div className="grid grid-cols-2 lg: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">
<Megaphone size={18} />
</div>
<div>
<p className="text-2xl font-semibold text-text-primary">4</p>
<p className="text-xs text-text-secondary">Campaigns</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">24.1K</p>
<p className="text-xs text-text-secondary">Total Reach</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">
<Target size={18} />
</div>
<div>
<p className="text-2xl font-semibold text-text-primary">10.4%</p>
<p className="text-xs text-text-secondary">Avg. Engagement</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">
<TrendingUp size={18} />
</div>
<div>
<p className="text-2xl font-semibold text-text-primary">1,040</p>
<p className="text-xs text-text-secondary">Conversions</p>
</div>
</div>
</Card>
</div>
{/* Campaigns */}
<Card>
<CardHeader title="Campaigns" subtitle="Active and recent marketing campaigns" />
<div className="space-y-4">
{campaigns.map((campaign) => (
<div
key={campaign.id}
className="flex items-center justify-between p-4 rounded-xl bg-bg/50 hover:bg-bg transition-colors"
>
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-xl bg-primary-light flex items-center justify-center text-primary">
{campaign.channel === 'Email' ? <Mail size={18} /> : <BarChart3 size={18} />}
</div>
<div>
<div className="flex items-center gap-2">
<span className="font-medium text-text-primary">{campaign.name}</span>
<Badge
variant={
campaign.status === 'active'
? 'success'
: campaign.status === 'scheduled'
? 'primary'
: 'default'
}
size="sm"
>
{campaign.status}
</Badge>
</div>
<p className="text-xs text-text-secondary mt-0.5">{campaign.channel}</p>
</div>
</div>
<div className="flex items-center gap-8 text-right">
<div>
<p className="text-sm font-medium text-text-primary">
{campaign.reach > 0 ? campaign.reach.toLocaleString() : '—'}
</p>
<p className="text-[11px] text-text-secondary">Reach</p>
</div>
<div>
<p className="text-sm font-medium text-text-primary">
{campaign.engagement > 0 ? `${campaign.engagement}%` : '—'}
</p>
<p className="text-[11px] text-text-secondary">Engagement</p>
</div>
<div>
<p className="text-sm font-medium text-text-primary">
{campaign.conversions > 0 ? campaign.conversions.toLocaleString() : '—'}
</p>
<p className="text-[11px] text-text-secondary">Conversions</p>
</div>
</div>
</div>
))}
</div>
</Card>
</div>
)
}
+407
View File
@@ -0,0 +1,407 @@
import { useEffect, useState } from 'react'
import { Card, CardHeader } from '@/components/ui/Card'
import { Skeleton } from '@/components/ui/Skeleton'
import {
Table,
TableHead,
TableBody,
TableRow,
TableHeader,
TableCell,
} from '@/components/ui/Table'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/Button'
import { formatCurrency } from '@/lib/utils'
import { salesApi } from '@/lib/api'
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
LineChart,
Line,
} from 'recharts'
import {
TrendingUp,
Package,
DollarSign,
Plus,
Search,
Filter,
MoreHorizontal,
} from 'lucide-react'
interface Deal {
id: string
name: string
company: string
value: number
stage: string
probability: number
expectedClose: string
owner: string
}
interface Product {
id: string
name: string
sku: string
price: number
recurring: boolean
active: boolean
}
interface MRRItem {
month: string
mrr: number
arr: number
}
interface ARRItem {
quarter: string
arr: number
}
const tabs = ['Deals', 'Products', 'MRR', 'ARR']
export function SalesPage() {
const [activeTab, setActiveTab] = useState('Deals')
const [searchQuery, setSearchQuery] = useState('')
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [deals, setDeals] = useState<Deal[]>([])
const [products, setProducts] = useState<Product[]>([])
const [mrrData, setMrrData] = useState<MRRItem[]>([])
const [arrData, setArrData] = useState<ARRItem[]>([])
const [mrrValue, setMrrValue] = useState(0)
useEffect(() => {
async function fetchData() {
setLoading(true)
setError('')
try {
const [dealsRes, productsRes, mrrRes, arrRes] = await Promise.all([
salesApi.deals(),
salesApi.products(),
salesApi.mrr(),
salesApi.arr(),
])
setDeals((dealsRes as { deals: Deal[] }).deals || [])
setProducts((productsRes as { products: Product[] }).products || [])
const m = mrrRes as unknown as { mrr: number; currency?: string }
setMrrValue(m.mrr || 0)
// If the API returns a single MRR value, we can't chart it — leave chart empty
setMrrData([])
const _a = arrRes as unknown as { arr: number; currency?: string }
void _a
setArrData([])
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load sales data')
} finally {
setLoading(false)
}
}
fetchData()
}, [])
const totalDeals = deals.reduce((sum, d) => sum + (d.value || 0), 0)
const wonDeals = deals.filter((d) => d.stage === 'Closed Won').reduce((sum, d) => sum + (d.value || 0), 0)
const activeProducts = products.filter((p) => p.active).length
if (loading) {
return (
<div className="space-y-8">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-5">
<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">Sales</h1>
<p className="text-sm text-text-secondary mt-0.5">Deals, products, and revenue metrics</p>
</div>
<Button icon={<Plus size={16} />}>New Deal</Button>
</div>
{/* Quick Stats */}
<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">
<DollarSign size={18} />
</div>
<div>
<p className="text-2xl font-semibold text-text-primary">{formatCurrency(totalDeals)}</p>
<p className="text-xs text-text-secondary">Total Pipeline</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">
<TrendingUp size={18} />
</div>
<div>
<p className="text-2xl font-semibold text-text-primary">{formatCurrency(wonDeals)}</p>
<p className="text-xs text-text-secondary">Closed Won</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">
<Package size={18} />
</div>
<div>
<p className="text-2xl font-semibold text-text-primary">{activeProducts}</p>
<p className="text-xs text-text-secondary">Active Products</p>
</div>
</div>
</Card>
</div>
{/* Tabs + Search */}
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div className="flex items-center gap-1 bg-bg rounded-xl p-1">
{tabs.map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
activeTab === tab
? 'bg-surface text-text-primary shadow-sm'
: 'text-text-secondary hover:text-text-primary'
}`}
>
{tab}
</button>
))}
</div>
<div className="flex items-center gap-2 w-full sm:w-auto">
<div className="relative flex-1 sm:flex-initial">
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-secondary" />
<input
type="text"
placeholder="Search..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full sm:w-64 h-10 pl-9 pr-4 rounded-[14px] border bg-surface text-sm text-text-primary placeholder:text-text-secondary/50 focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary/30"
/>
</div>
<Button variant="secondary" size="sm" icon={<Filter size={14} />}>
Filter
</Button>
</div>
</div>
{/* Tab Content */}
{activeTab === 'Deals' && (
<Card>
<Table>
<TableHead>
<TableRow>
<TableHeader>Deal</TableHeader>
<TableHeader>Company</TableHeader>
<TableHeader align="right">Value</TableHeader>
<TableHeader>Stage</TableHeader>
<TableHeader align="right">Probability</TableHeader>
<TableHeader>Owner</TableHeader>
<TableHeader align="right"></TableHeader>
</TableRow>
</TableHead>
<TableBody>
{deals.length === 0 && (
<TableRow>
<TableCell colSpan={7} className="text-center text-text-secondary py-8">
No deals found
</TableCell>
</TableRow>
)}
{deals.map((deal) => (
<TableRow key={deal.id}>
<TableCell>
<span className="font-medium">{deal.name}</span>
</TableCell>
<TableCell>{deal.company}</TableCell>
<TableCell align="right">{formatCurrency(deal.value)}</TableCell>
<TableCell>
<Badge
variant={
deal.stage === 'Closed Won'
? 'success'
: deal.stage === 'Negotiation'
? 'primary'
: 'default'
}
>
{deal.stage}
</Badge>
</TableCell>
<TableCell align="right">{deal.probability}%</TableCell>
<TableCell>{deal.owner}</TableCell>
<TableCell align="right">
<button className="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-bg text-text-secondary">
<MoreHorizontal size={16} />
</button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
)}
{activeTab === 'Products' && (
<Card>
<Table>
<TableHead>
<TableRow>
<TableHeader>Product</TableHeader>
<TableHeader>SKU</TableHeader>
<TableHeader align="right">Price</TableHeader>
<TableHeader>Type</TableHeader>
<TableHeader>Status</TableHeader>
<TableHeader align="right"></TableHeader>
</TableRow>
</TableHead>
<TableBody>
{products.length === 0 && (
<TableRow>
<TableCell colSpan={6} className="text-center text-text-secondary py-8">
No products found
</TableCell>
</TableRow>
)}
{products.map((product) => (
<TableRow key={product.id}>
<TableCell>
<span className="font-medium">{product.name}</span>
</TableCell>
<TableCell>
<code className="text-xs bg-bg px-2 py-0.5 rounded-md">{product.sku}</code>
</TableCell>
<TableCell align="right">{formatCurrency(product.price)}</TableCell>
<TableCell>
<Badge variant={product.recurring ? 'primary' : 'default'}>
{product.recurring ? 'Recurring' : 'One-time'}
</Badge>
</TableCell>
<TableCell>
<Badge variant={product.active ? 'success' : 'danger'}>
{product.active ? 'Active' : 'Inactive'}
</Badge>
</TableCell>
<TableCell align="right">
<button className="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-bg text-text-secondary">
<MoreHorizontal size={16} />
</button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
)}
{activeTab === 'MRR' && (
<Card>
<CardHeader title="Monthly Recurring Revenue" subtitle={`Current MRR: ${formatCurrency(mrrValue)}`} />
{mrrData.length > 0 ? (
<div className="h-[320px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={mrrData} margin={{ top: 5, right: 5, left: -10, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(0,0,0,0.04)" vertical={false} />
<XAxis dataKey="month" axisLine={false} tickLine={false} tick={{ fontSize: 11, fill: '#9CA3AF' }} dy={8} />
<YAxis axisLine={false} tickLine={false} tick={{ fontSize: 11, fill: '#9CA3AF' }} tickFormatter={(v) => `${(v / 1000).toFixed(0)}k`} dx={-5} />
<Tooltip
content={({ active, payload, label }) => {
if (!active || !payload?.length) return null
return (
<div className="bg-surface rounded-xl dropdown-shadow border border-border/60 px-4 py-3">
<p className="text-xs font-medium text-text-secondary mb-2">{label}</p>
{payload.map((p, i) => (
<div key={i} className="flex items-center gap-2 text-sm">
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: p.color }} />
<span className="text-text-secondary">{p.name}:</span>
<span className="font-semibold text-text-primary">{formatCurrency(Number(p.value))}</span>
</div>
))}
</div>
)
}}
/>
<Bar dataKey="mrr" fill="#2563EB" radius={[4, 4, 0, 0]} />
<Bar dataKey="arr" fill="#16A34A" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
) : (
<div className="h-[200px] flex items-center justify-center text-text-secondary">
No MRR history data available
</div>
)}
</Card>
)}
{activeTab === 'ARR' && (
<Card>
<CardHeader title="Annual Recurring Revenue" subtitle="ARR growth by quarter" />
{arrData.length > 0 ? (
<div className="h-[320px]">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={arrData} margin={{ top: 5, right: 5, left: -10, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(0,0,0,0.04)" vertical={false} />
<XAxis dataKey="quarter" axisLine={false} tickLine={false} tick={{ fontSize: 11, fill: '#9CA3AF' }} dy={8} />
<YAxis axisLine={false} tickLine={false} tick={{ fontSize: 11, fill: '#9CA3AF' }} tickFormatter={(v) => `${(v / 1000).toFixed(0)}k`} dx={-5} />
<Tooltip
content={({ active, payload, label }) => {
if (!active || !payload?.length) return null
return (
<div className="bg-surface rounded-xl dropdown-shadow border border-border/60 px-4 py-3">
<p className="text-xs font-medium text-text-secondary mb-1">{label}</p>
<p className="text-sm font-semibold text-text-primary">
{formatCurrency(Number(payload[0].value))}
</p>
</div>
)
}}
/>
<Line type="monotone" dataKey="arr" stroke="#2563EB" strokeWidth={2} dot={{ r: 4, strokeWidth: 0, fill: '#2563EB' }} activeDot={{ r: 6 }} />
</LineChart>
</ResponsiveContainer>
</div>
) : (
<div className="h-[200px] flex items-center justify-center text-text-secondary">
No ARR history data available
</div>
)}
</Card>
)}
</div>
)
}
+217
View File
@@ -0,0 +1,217 @@
import { useState } from 'react'
import { Card } from '@/components/ui/Card'
import {
Table,
TableHead,
TableBody,
TableRow,
TableHeader,
TableCell,
} from '@/components/ui/Table'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/Button'
import { formatRelativeTime } from '@/lib/utils'
import {
HeadphonesIcon,
Clock,
CheckCircle2,
AlertCircle,
Plus,
Search,
Filter,
MoreHorizontal,
} from 'lucide-react'
const tickets = [
{ id: 'SUP-2024-001', subject: 'Login issues after password reset', customer: 'Acme Corp', priority: 'high' as const, status: 'open' as const, assigned: 'Johan Berg', created: new Date(Date.now() - 1000 * 60 * 30).toISOString() },
{ id: 'SUP-2024-002', subject: 'API rate limit questions', customer: 'Nordic Solutions', priority: 'medium' as const, status: 'in_progress' as const, assigned: 'Johan Berg', created: new Date(Date.now() - 1000 * 60 * 120).toISOString() },
{ id: 'SUP-2024-003', subject: 'Feature request: bulk export', customer: 'TechStart AB', priority: 'low' as const, status: 'open' as const, assigned: 'Unassigned', created: new Date(Date.now() - 1000 * 60 * 60 * 4).toISOString() },
{ id: 'SUP-2024-004', subject: 'Billing discrepancy on invoice #89', customer: 'Global Industries', priority: 'high' as const, status: 'in_progress' as const, assigned: 'Elena Rossi', created: new Date(Date.now() - 1000 * 60 * 60 * 6).toISOString() },
{ id: 'SUP-2024-005', subject: 'Integration documentation outdated', customer: 'ScandiTech', priority: 'medium' as const, status: 'resolved' as const, assigned: 'Johan Berg', created: new Date(Date.now() - 1000 * 60 * 60 * 24).toISOString() },
{ id: 'SUP-2024-006', subject: 'Mobile app crash on iOS', customer: 'MegaCorp', priority: 'high' as const, status: 'open' as const, assigned: 'Unassigned', created: new Date(Date.now() - 1000 * 60 * 60 * 2).toISOString() },
]
const tabs = ['All', 'Open', 'In Progress', 'Resolved']
export function SupportPage() {
const [activeTab, setActiveTab] = useState('All')
const [searchQuery, setSearchQuery] = useState('')
const openCount = tickets.filter((t) => t.status === 'open').length
const inProgressCount = tickets.filter((t) => t.status === 'in_progress').length
const resolvedCount = tickets.filter((t) => t.status === 'resolved').length
const _avgResponseTime = '2.4h'
void _avgResponseTime
const filteredTickets = tickets.filter((t) => {
const matchesSearch =
t.subject.toLowerCase().includes(searchQuery.toLowerCase()) ||
t.customer.toLowerCase().includes(searchQuery.toLowerCase()) ||
t.id.toLowerCase().includes(searchQuery.toLowerCase())
const matchesTab = activeTab === 'All' || t.status === activeTab.toLowerCase().replace(' ', '_')
return matchesSearch && matchesTab
})
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">Support</h1>
<p className="text-sm text-text-secondary mt-0.5">Customer support tickets</p>
</div>
<Button icon={<Plus size={16} />}>New Ticket</Button>
</div>
{/* Quick Stats */}
<div className="grid grid-cols-2 lg: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">
<HeadphonesIcon size={18} />
</div>
<div>
<p className="text-2xl font-semibold text-text-primary">{tickets.length}</p>
<p className="text-xs text-text-secondary">Total Tickets</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">
<AlertCircle size={18} />
</div>
<div>
<p className="text-2xl font-semibold text-text-primary">{openCount}</p>
<p className="text-xs text-text-secondary">Open</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">
<Clock size={18} />
</div>
<div>
<p className="text-2xl font-semibold text-text-primary">{inProgressCount}</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-success-light flex items-center justify-center text-success">
<CheckCircle2 size={18} />
</div>
<div>
<p className="text-2xl font-semibold text-text-primary">{resolvedCount}</p>
<p className="text-xs text-text-secondary">Resolved</p>
</div>
</div>
</Card>
</div>
{/* Tabs + Search */}
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div className="flex items-center gap-1 bg-bg rounded-xl p-1">
{tabs.map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
activeTab === tab
? 'bg-surface text-text-primary shadow-sm'
: 'text-text-secondary hover:text-text-primary'
}`}
>
{tab}
</button>
))}
</div>
<div className="flex items-center gap-2 w-full sm:w-auto">
<div className="relative flex-1 sm:flex-initial">
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-secondary" />
<input
type="text"
placeholder="Search tickets..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full sm:w-64 h-10 pl-9 pr-4 rounded-[14px] border bg-surface text-sm text-text-primary placeholder:text-text-secondary/50 focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary/30"
/>
</div>
<Button variant="secondary" size="sm" icon={<Filter size={14} />}>
Filter
</Button>
</div>
</div>
{/* Tickets Table */}
<Card>
<Table>
<TableHead>
<TableRow>
<TableHeader>Ticket</TableHeader>
<TableHeader>Subject</TableHeader>
<TableHeader>Customer</TableHeader>
<TableHeader>Priority</TableHeader>
<TableHeader>Status</TableHeader>
<TableHeader>Assigned</TableHeader>
<TableHeader>Created</TableHeader>
<TableHeader align="right"></TableHeader>
</TableRow>
</TableHead>
<TableBody>
{filteredTickets.map((ticket) => (
<TableRow key={ticket.id}>
<TableCell>
<span className="font-medium text-sm">{ticket.id}</span>
</TableCell>
<TableCell>
<span className="text-sm text-text-primary">{ticket.subject}</span>
</TableCell>
<TableCell>{ticket.customer}</TableCell>
<TableCell>
<Badge
variant={
ticket.priority === 'high'
? 'danger'
: ticket.priority === 'medium'
? 'warning'
: 'default'
}
size="sm"
>
{ticket.priority}
</Badge>
</TableCell>
<TableCell>
<Badge
variant={
ticket.status === 'resolved'
? 'success'
: ticket.status === 'in_progress'
? 'primary'
: 'warning'
}
size="sm"
>
{ticket.status.replace('_', ' ')}
</Badge>
</TableCell>
<TableCell>{ticket.assigned}</TableCell>
<TableCell>
<span className="text-xs text-text-secondary">{formatRelativeTime(ticket.created)}</span>
</TableCell>
<TableCell align="right">
<button className="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-bg text-text-secondary">
<MoreHorizontal size={16} />
</button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
</div>
)
}