BOC v1.0: RS256 auth, Ledger integration, Prometheus metrics, CI/CD, backup
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import { AppShell } from '@/components/layout/AppShell'
|
||||
import { LoginPage } from '@/pages/LoginPage'
|
||||
import { DashboardPage } from '@/pages/DashboardPage'
|
||||
import { CRMPage } from '@/pages/CRMPage'
|
||||
import { SalesPage } from '@/pages/SalesPage'
|
||||
import { FinancePage } from '@/pages/FinancePage'
|
||||
import { HRPage } from '@/pages/HRPage'
|
||||
import { LegalPage } from '@/pages/LegalPage'
|
||||
import { MarketingPage } from '@/pages/MarketingPage'
|
||||
import { SupportPage } from '@/pages/SupportPage'
|
||||
import { AutomationPage } from '@/pages/AutomationPage'
|
||||
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const { isAuthenticated } = useAuthStore()
|
||||
return isAuthenticated ? <>{children}</> : <Navigate to="/login" replace />
|
||||
}
|
||||
|
||||
function PublicRoute({ children }: { children: React.ReactNode }) {
|
||||
const { isAuthenticated } = useAuthStore()
|
||||
return !isAuthenticated ? <>{children}</> : <Navigate to="/" replace />
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<PublicRoute><LoginPage /></PublicRoute>} />
|
||||
<Route element={<ProtectedRoute><AppShell /></ProtectedRoute>}>
|
||||
<Route path="/" element={<DashboardPage />} />
|
||||
<Route path="/crm" element={<CRMPage />} />
|
||||
<Route path="/sales" element={<SalesPage />} />
|
||||
<Route path="/finance" element={<FinancePage />} />
|
||||
<Route path="/hr" element={<HRPage />} />
|
||||
<Route path="/legal" element={<LegalPage />} />
|
||||
<Route path="/marketing" element={<MarketingPage />} />
|
||||
<Route path="/support" element={<SupportPage />} />
|
||||
<Route path="/automation" element={<AutomationPage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { motion } from 'framer-motion'
|
||||
import { Card, CardHeader } from '@/components/ui/Card'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import { formatRelativeTime } from '@/lib/utils'
|
||||
import {
|
||||
TrendingUp,
|
||||
Users,
|
||||
FileText,
|
||||
FileCheck,
|
||||
UserPlus,
|
||||
} from 'lucide-react'
|
||||
|
||||
const activities = [
|
||||
{
|
||||
id: '1',
|
||||
type: 'deal' as const,
|
||||
title: 'Enterprise deal closed',
|
||||
description: 'Acme Corp signed €85,000 contract',
|
||||
time: new Date(Date.now() - 1000 * 60 * 15).toISOString(),
|
||||
user: 'Sarah Chen',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: 'customer' as const,
|
||||
title: 'New customer onboarded',
|
||||
description: 'TechStart AB joined as a premium client',
|
||||
time: new Date(Date.now() - 1000 * 60 * 45).toISOString(),
|
||||
user: 'Marcus Lind',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
type: 'invoice' as const,
|
||||
title: 'Invoice #INV-2024-0089 paid',
|
||||
description: '€12,400 received from Nordic Solutions',
|
||||
time: new Date(Date.now() - 1000 * 60 * 60 * 2).toISOString(),
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
type: 'contract' as const,
|
||||
title: 'Contract renewed',
|
||||
description: 'Global Industries extended for 2 years',
|
||||
time: new Date(Date.now() - 1000 * 60 * 60 * 4).toISOString(),
|
||||
user: 'Elena Rossi',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
type: 'employee' as const,
|
||||
title: 'New team member',
|
||||
description: 'Johan Berg joined the Engineering team',
|
||||
time: new Date(Date.now() - 1000 * 60 * 60 * 6).toISOString(),
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
type: 'deal' as const,
|
||||
title: 'Deal moved to negotiation',
|
||||
description: 'MegaCorp €120K proposal under review',
|
||||
time: new Date(Date.now() - 1000 * 60 * 60 * 8).toISOString(),
|
||||
user: 'Sarah Chen',
|
||||
},
|
||||
]
|
||||
|
||||
const typeConfig = {
|
||||
deal: { icon: TrendingUp, color: 'primary' as const, label: 'Deal' },
|
||||
customer: { icon: Users, color: 'success' as const, label: 'Customer' },
|
||||
invoice: { icon: FileText, color: 'warning' as const, label: 'Invoice' },
|
||||
contract: { icon: FileCheck, color: 'primary' as const, label: 'Contract' },
|
||||
employee: { icon: UserPlus, color: 'success' as const, label: 'HR' },
|
||||
}
|
||||
|
||||
export function ActivityFeed() {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader title="Recent Activity" subtitle="Latest updates across your organization" />
|
||||
<div className="space-y-0">
|
||||
{activities.map((activity, i) => {
|
||||
const config = typeConfig[activity.type]
|
||||
const Icon = config.icon
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={activity.id}
|
||||
initial={{ opacity: 0, x: -8 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.15, delay: i * 0.03 }}
|
||||
className="flex items-start gap-3 py-3.5 border-b border-border/40 last:border-0"
|
||||
>
|
||||
<div className={`w-9 h-9 rounded-xl bg-${config.color}-light flex items-center justify-center shrink-0`}>
|
||||
<Icon size={16} className={`text-${config.color}`} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium text-text-primary truncate">
|
||||
{activity.title}
|
||||
</p>
|
||||
<Badge variant={config.color} size="sm">{config.label}</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-text-secondary mt-0.5">{activity.description}</p>
|
||||
<div className="flex items-center gap-2 mt-1.5">
|
||||
<span className="text-[11px] text-text-secondary/60">
|
||||
{formatRelativeTime(activity.time)}
|
||||
</span>
|
||||
{activity.user && (
|
||||
<>
|
||||
<span className="text-[11px] text-text-secondary/40">·</span>
|
||||
<span className="text-[11px] text-text-secondary/60">{activity.user}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { motion } from 'framer-motion'
|
||||
import { TrendingUp, TrendingDown, Minus } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface KPICardProps {
|
||||
label: string
|
||||
value: string
|
||||
change: number
|
||||
changeLabel: string
|
||||
icon: React.ReactNode
|
||||
index?: number
|
||||
}
|
||||
|
||||
export function KPICard({ label, value, change, changeLabel, icon, index = 0 }: KPICardProps) {
|
||||
const isPositive = change > 0
|
||||
const isNeutral = change === 0
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.18, delay: index * 0.04, ease: 'easeOut' }}
|
||||
className="bg-surface rounded-[20px] card-shadow p-6 md:p-7"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-text-secondary">{label}</p>
|
||||
<p className="text-2xl md:text-3xl font-semibold text-text-primary tracking-tight">
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-10 h-10 rounded-xl bg-bg flex items-center justify-center text-text-secondary">
|
||||
{icon}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-full',
|
||||
isPositive && 'bg-success-light text-success',
|
||||
isNeutral && 'bg-bg text-text-secondary',
|
||||
!isPositive && !isNeutral && 'bg-danger-light text-danger'
|
||||
)}
|
||||
>
|
||||
{isPositive ? (
|
||||
<TrendingUp size={12} />
|
||||
) : isNeutral ? (
|
||||
<Minus size={12} />
|
||||
) : (
|
||||
<TrendingDown size={12} />
|
||||
)}
|
||||
{Math.abs(change)}%
|
||||
</span>
|
||||
<span className="text-xs text-text-secondary">{changeLabel}</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
AreaChart,
|
||||
Area,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts'
|
||||
import { Card, CardHeader } from '@/components/ui/Card'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const data = [
|
||||
{ month: 'Jan', revenue: 42000, target: 45000 },
|
||||
{ month: 'Feb', revenue: 48000, target: 46000 },
|
||||
{ month: 'Mar', revenue: 51000, target: 48000 },
|
||||
{ month: 'Apr', revenue: 47000, target: 50000 },
|
||||
{ month: 'May', revenue: 56000, target: 52000 },
|
||||
{ month: 'Jun', revenue: 62000, target: 55000 },
|
||||
{ month: 'Jul', revenue: 58000, target: 57000 },
|
||||
{ month: 'Aug', revenue: 67000, target: 60000 },
|
||||
{ month: 'Sep', revenue: 71000, target: 63000 },
|
||||
{ month: 'Oct', revenue: 69000, target: 66000 },
|
||||
{ month: 'Nov', revenue: 78000, target: 70000 },
|
||||
{ month: 'Dec', revenue: 85000, target: 75000 },
|
||||
]
|
||||
|
||||
const tabs = [
|
||||
{ key: 'revenue', label: 'Revenue' },
|
||||
{ key: 'mrr', label: 'MRR' },
|
||||
{ key: 'arr', label: 'ARR' },
|
||||
]
|
||||
|
||||
function formatK(value: number): string {
|
||||
return `€${(value / 1000).toFixed(0)}k`
|
||||
}
|
||||
|
||||
export function RevenueChart() {
|
||||
const [activeTab, setActiveTab] = useState('revenue')
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Revenue Overview"
|
||||
subtitle="Monthly revenue vs target"
|
||||
action={
|
||||
<div className="flex items-center gap-1 bg-bg rounded-xl p-1">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={cn(
|
||||
'px-3 py-1.5 text-xs font-medium rounded-lg transition-colors',
|
||||
activeTab === tab.key
|
||||
? 'bg-surface text-text-primary shadow-sm'
|
||||
: 'text-text-secondary hover:text-text-primary'
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<div className="h-[280px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={data} margin={{ top: 5, right: 5, left: -10, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="colorRevenue" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#2563EB" stopOpacity={0.08} />
|
||||
<stop offset="95%" stopColor="#2563EB" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<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={formatK}
|
||||
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 || '#2563EB' }}
|
||||
/>
|
||||
<span className="text-text-secondary">{p.name}:</span>
|
||||
<span className="font-semibold text-text-primary">
|
||||
€{Number(p.value).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="revenue"
|
||||
stroke="#2563EB"
|
||||
strokeWidth={2}
|
||||
fill="url(#colorRevenue)"
|
||||
dot={false}
|
||||
activeDot={{ r: 4, strokeWidth: 0, fill: '#2563EB' }}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="target"
|
||||
stroke="#9CA3AF"
|
||||
strokeWidth={1.5}
|
||||
strokeDasharray="4 4"
|
||||
fill="none"
|
||||
dot={false}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Outlet } from 'react-router-dom'
|
||||
import { Sidebar } from './Sidebar'
|
||||
import { Header } from './Header'
|
||||
import { useUIStore } from '@/stores/uiStore'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function AppShell() {
|
||||
const { sidebarOpen } = useUIStore()
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-bg">
|
||||
<Sidebar />
|
||||
<div
|
||||
className={cn(
|
||||
'transition-all duration-200 ease-out',
|
||||
sidebarOpen ? 'lg:ml-[240px]' : 'lg:ml-[72px]'
|
||||
)}
|
||||
>
|
||||
<Header />
|
||||
<main className="p-6 lg:p-10">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import { useState } from 'react'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import { useUIStore } from '@/stores/uiStore'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import {
|
||||
Menu,
|
||||
Search,
|
||||
Bell,
|
||||
LogOut,
|
||||
User,
|
||||
Settings,
|
||||
ChevronDown,
|
||||
} from 'lucide-react'
|
||||
import { getInitials, formatRelativeTime } from '@/lib/utils'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const notifications = [
|
||||
{
|
||||
id: '1',
|
||||
title: 'Nytt avtal tecknat',
|
||||
description: 'Acme Corp har signerat enterprise-avtalet',
|
||||
time: new Date(Date.now() - 1000 * 60 * 15).toISOString(),
|
||||
read: false,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: 'Faktura förfallen',
|
||||
description: 'Faktura #INV-2024-0042 är 3 dagar försenad',
|
||||
time: new Date(Date.now() - 1000 * 60 * 60 * 2).toISOString(),
|
||||
read: false,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: 'Semester beviljad',
|
||||
description: 'Din semesteransökan har godkänts av HR',
|
||||
time: new Date(Date.now() - 1000 * 60 * 60 * 5).toISOString(),
|
||||
read: true,
|
||||
},
|
||||
]
|
||||
|
||||
export function Header() {
|
||||
const { user, logout } = useAuthStore()
|
||||
const { toggleSidebar } = useUIStore()
|
||||
const [notifOpen, setNotifOpen] = useState(false)
|
||||
const [profileOpen, setProfileOpen] = useState(false)
|
||||
|
||||
const greeting = () => {
|
||||
const hour = new Date().getHours()
|
||||
if (hour < 12) return 'Good morning'
|
||||
if (hour < 17) return 'Good afternoon'
|
||||
return 'Good evening'
|
||||
}
|
||||
|
||||
const unreadCount = notifications.filter((n) => !n.read).length
|
||||
|
||||
return (
|
||||
<header className="h-16 bg-surface border-b border-border/60 flex items-center justify-between px-6 lg:px-10 sticky top-0 z-30">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={toggleSidebar}
|
||||
className="lg:hidden w-9 h-9 flex items-center justify-center rounded-xl hover:bg-bg text-text-secondary transition-colors"
|
||||
>
|
||||
<Menu size={18} />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-base font-semibold text-text-primary">
|
||||
{greeting()}{user ? `, ${user.name || 'Erik Svensson'}` : ', Erik Svensson'}
|
||||
</h1>
|
||||
<p className="text-xs text-text-secondary hidden sm:block">
|
||||
{new Date().toLocaleDateString('sv-SE', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Search */}
|
||||
<div className="hidden md:flex items-center h-9 px-3.5 rounded-xl bg-bg border border-border/60 text-text-secondary">
|
||||
<Search size={15} />
|
||||
<span className="ml-2 text-sm">Search...</span>
|
||||
<kbd className="ml-6 px-1.5 py-0.5 text-[10px] font-medium bg-surface rounded border border-border">
|
||||
⌘K
|
||||
</kbd>
|
||||
</div>
|
||||
|
||||
{/* Notifications */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => {
|
||||
setNotifOpen(!notifOpen)
|
||||
setProfileOpen(false)
|
||||
}}
|
||||
className="relative w-9 h-9 flex items-center justify-center rounded-xl hover:bg-bg text-text-secondary transition-colors"
|
||||
>
|
||||
<Bell size={17} />
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute top-1.5 right-1.5 w-2 h-2 bg-danger rounded-full" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{notifOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setNotifOpen(false)} />
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 4, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 4, scale: 0.98 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="absolute right-0 top-full mt-2 w-80 bg-surface rounded-[18px] dropdown-shadow border border-border/60 z-50 overflow-hidden"
|
||||
>
|
||||
<div className="px-4 py-3 border-b border-border/60 flex items-center justify-between">
|
||||
<span className="text-sm font-semibold text-text-primary">Notifications</span>
|
||||
<span className="text-xs text-primary font-medium cursor-pointer">Mark all read</span>
|
||||
</div>
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
{notifications.map((n) => (
|
||||
<div
|
||||
key={n.id}
|
||||
className={cn(
|
||||
'px-4 py-3 hover:bg-bg/60 transition-colors cursor-pointer',
|
||||
!n.read && 'bg-primary-light/30'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={cn(
|
||||
'w-2 h-2 rounded-full mt-1.5 shrink-0',
|
||||
n.read ? 'bg-transparent' : 'bg-primary'
|
||||
)} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-text-primary">{n.title}</p>
|
||||
<p className="text-xs text-text-secondary mt-0.5">{n.description}</p>
|
||||
<p className="text-[11px] text-text-secondary/60 mt-1">{formatRelativeTime(n.time)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Profile */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => {
|
||||
setProfileOpen(!profileOpen)
|
||||
setNotifOpen(false)
|
||||
}}
|
||||
className="flex items-center gap-2 pl-1 pr-2 h-9 rounded-xl hover:bg-bg transition-colors"
|
||||
>
|
||||
<div className="w-7 h-7 rounded-full bg-primary/10 text-primary flex items-center justify-center text-xs font-semibold">
|
||||
{user ? getInitials(user.name) : '?'}
|
||||
</div>
|
||||
<ChevronDown size={14} className="text-text-secondary" />
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{profileOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setProfileOpen(false)} />
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 4, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 4, scale: 0.98 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="absolute right-0 top-full mt-2 w-56 bg-surface rounded-[18px] dropdown-shadow border border-border/60 z-50 overflow-hidden"
|
||||
>
|
||||
<div className="px-4 py-3 border-b border-border/60">
|
||||
<p className="text-sm font-semibold text-text-primary">{user?.name || 'User'}</p>
|
||||
<p className="text-xs text-text-secondary">{user?.email || 'user@amos.com'}</p>
|
||||
</div>
|
||||
<div className="py-1">
|
||||
<button className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-text-secondary hover:bg-bg transition-colors">
|
||||
<User size={15} />
|
||||
Profile
|
||||
</button>
|
||||
<button className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-text-secondary hover:bg-bg transition-colors">
|
||||
<Settings size={15} />
|
||||
Settings
|
||||
</button>
|
||||
<div className="border-t border-border/60 mt-1 pt-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
logout()
|
||||
setProfileOpen(false)
|
||||
}}
|
||||
className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-danger hover:bg-danger-light transition-colors"
|
||||
>
|
||||
<LogOut size={15} />
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { NavLink, useLocation } from 'react-router-dom'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { useUIStore } from '@/stores/uiStore'
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Users,
|
||||
TrendingUp,
|
||||
Wallet,
|
||||
Briefcase,
|
||||
FileText,
|
||||
Megaphone,
|
||||
HeadphonesIcon,
|
||||
Zap,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const navItems = [
|
||||
{ path: '/', label: 'Dashboard', icon: LayoutDashboard },
|
||||
{ path: '/crm', label: 'CRM', icon: Users },
|
||||
{ path: '/sales', label: 'Sales', icon: TrendingUp },
|
||||
{ path: '/finance', label: 'Finance', icon: Wallet },
|
||||
{ path: '/hr', label: 'HR', icon: Briefcase },
|
||||
{ path: '/legal', label: 'Legal', icon: FileText },
|
||||
{ path: '/marketing', label: 'Marketing', icon: Megaphone },
|
||||
{ path: '/support', label: 'Support', icon: HeadphonesIcon },
|
||||
{ path: '/automation', label: 'Automation', icon: Zap },
|
||||
]
|
||||
|
||||
export function Sidebar() {
|
||||
const { sidebarOpen, toggleSidebar } = useUIStore()
|
||||
const location = useLocation()
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile overlay */}
|
||||
<AnimatePresence>
|
||||
{sidebarOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="fixed inset-0 bg-black/20 z-40 lg:hidden"
|
||||
onClick={toggleSidebar}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<motion.aside
|
||||
initial={false}
|
||||
animate={{ width: sidebarOpen ? 240 : 72 }}
|
||||
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||
className={cn(
|
||||
'fixed left-0 top-0 h-full bg-sidebar z-50 flex flex-col',
|
||||
'border-r border-border/60',
|
||||
sidebarOpen ? 'px-4' : 'px-3'
|
||||
)}
|
||||
>
|
||||
{/* Logo */}
|
||||
<div className="h-16 flex items-center justify-between shrink-0">
|
||||
<div className="flex items-center gap-2.5 overflow-hidden">
|
||||
<div className="w-8 h-8 rounded-lg bg-primary flex items-center justify-center shrink-0">
|
||||
<svg width="16" height="16" viewBox="0 0 32 32" fill="none">
|
||||
<path d="M10 22L16 10L22 22H10Z" stroke="white" strokeWidth="2.5" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{sidebarOpen && (
|
||||
<motion.span
|
||||
initial={{ opacity: 0, x: -8 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -8 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="text-sm font-semibold text-text-primary whitespace-nowrap"
|
||||
>
|
||||
AMOS
|
||||
</motion.span>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
<button
|
||||
onClick={toggleSidebar}
|
||||
className="hidden lg:flex w-7 h-7 items-center justify-center rounded-lg hover:bg-bg text-text-secondary transition-colors"
|
||||
>
|
||||
{sidebarOpen ? <ChevronLeft size={14} /> : <ChevronRight size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 py-4 space-y-0.5 overflow-y-auto">
|
||||
{navItems.map((item) => {
|
||||
const isActive = location.pathname === item.path ||
|
||||
(item.path !== '/' && location.pathname.startsWith(item.path))
|
||||
const Icon = item.icon
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
onClick={() => {
|
||||
if (window.innerWidth < 1024) toggleSidebar()
|
||||
}}
|
||||
className={cn(
|
||||
'flex items-center gap-3 h-10 rounded-xl px-3 transition-colors duration-150 relative',
|
||||
'hover:bg-primary-light',
|
||||
isActive && 'bg-primary-light text-primary',
|
||||
!isActive && 'text-text-secondary'
|
||||
)}
|
||||
>
|
||||
{isActive && (
|
||||
<motion.div
|
||||
layoutId="sidebar-active"
|
||||
className="absolute left-0 top-1/2 -translate-y-1/2 w-0.5 h-5 bg-primary rounded-full"
|
||||
transition={{ duration: 0.18, ease: 'easeOut' }}
|
||||
/>
|
||||
)}
|
||||
<Icon size={18} strokeWidth={isActive ? 2 : 1.5} />
|
||||
<AnimatePresence>
|
||||
{sidebarOpen && (
|
||||
<motion.span
|
||||
initial={{ opacity: 0, x: -8 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -8 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className={cn(
|
||||
'text-sm font-medium whitespace-nowrap',
|
||||
isActive ? 'text-primary' : 'text-text-secondary'
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</motion.span>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</NavLink>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
</motion.aside>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface BadgeProps {
|
||||
children: React.ReactNode
|
||||
variant?: 'default' | 'success' | 'warning' | 'danger' | 'primary'
|
||||
size?: 'sm' | 'md'
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function Badge({ children, variant = 'default', size = 'sm', className }: BadgeProps) {
|
||||
const variants = {
|
||||
default: 'bg-bg text-text-secondary',
|
||||
success: 'bg-success-light text-success',
|
||||
warning: 'bg-warning-light text-warning',
|
||||
danger: 'bg-danger-light text-danger',
|
||||
primary: 'bg-primary-light text-primary',
|
||||
}
|
||||
|
||||
const sizes = {
|
||||
sm: 'px-2 py-0.5 text-[11px]',
|
||||
md: 'px-2.5 py-1 text-xs',
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-full font-medium',
|
||||
variants[variant],
|
||||
sizes[size],
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'secondary' | 'ghost' | 'danger'
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
loading?: boolean
|
||||
icon?: React.ReactNode
|
||||
}
|
||||
|
||||
export function Button({
|
||||
children,
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
loading = false,
|
||||
icon,
|
||||
className,
|
||||
disabled,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
const variants = {
|
||||
primary: 'bg-primary text-white hover:bg-primary-hover shadow-sm',
|
||||
secondary: 'bg-bg text-text-primary border border-border hover:bg-white',
|
||||
ghost: 'bg-transparent text-text-secondary hover:bg-primary-light hover:text-primary',
|
||||
danger: 'bg-danger text-white hover:opacity-90',
|
||||
}
|
||||
|
||||
const sizes = {
|
||||
sm: 'h-8 px-3 text-xs',
|
||||
md: 'h-10 px-4 text-sm',
|
||||
lg: 'h-12 px-6 text-sm',
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center gap-2 rounded-[14px] font-medium transition-colors duration-150',
|
||||
'focus:outline-none focus:ring-2 focus:ring-primary/20',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
'active:scale-[0.98]',
|
||||
variants[variant],
|
||||
sizes[size],
|
||||
className
|
||||
)}
|
||||
disabled={disabled || loading}
|
||||
{...props}
|
||||
>
|
||||
{loading ? (
|
||||
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
) : (
|
||||
icon
|
||||
)}
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
import { motion } from 'framer-motion'
|
||||
|
||||
interface CardProps {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
hover?: boolean
|
||||
padding?: 'sm' | 'md' | 'lg'
|
||||
}
|
||||
|
||||
export function Card({ children, className, hover = false, padding = 'lg' }: CardProps) {
|
||||
const paddingClasses = {
|
||||
sm: 'p-4',
|
||||
md: 'p-5',
|
||||
lg: 'p-6 md:p-8',
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.18, ease: 'easeOut' }}
|
||||
className={cn(
|
||||
'bg-surface rounded-[20px] card-shadow',
|
||||
paddingClasses[padding],
|
||||
hover && 'transition-shadow duration-200 hover:shadow-hover cursor-pointer',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
interface CardHeaderProps {
|
||||
title: string
|
||||
subtitle?: string
|
||||
action?: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function CardHeader({ title, subtitle, action, className }: CardHeaderProps) {
|
||||
return (
|
||||
<div className={cn('flex items-start justify-between mb-6', className)}>
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-text-primary">{title}</h3>
|
||||
{subtitle && (
|
||||
<p className="text-sm text-text-secondary mt-0.5">{subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
{action && <div>{action}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon?: React.ReactNode
|
||||
title: string
|
||||
description?: string
|
||||
action?: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function EmptyState({ icon, title, description, action, className }: EmptyStateProps) {
|
||||
return (
|
||||
<div className={cn('flex flex-col items-center justify-center py-16 text-center', className)}>
|
||||
{icon && (
|
||||
<div className="mb-4 text-text-secondary/40">
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
<h3 className="text-sm font-medium text-text-primary">{title}</h3>
|
||||
{description && (
|
||||
<p className="mt-1 text-sm text-text-secondary max-w-sm">{description}</p>
|
||||
)}
|
||||
{action && <div className="mt-4">{action}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
import { forwardRef } from 'react'
|
||||
|
||||
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string
|
||||
error?: string
|
||||
icon?: React.ReactNode
|
||||
}
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
({ label, error, icon, className, ...props }, ref) => {
|
||||
return (
|
||||
<div className="w-full">
|
||||
{label && (
|
||||
<label className="block text-sm font-medium text-text-primary mb-1.5">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<div className="relative">
|
||||
{icon && (
|
||||
<div className="absolute left-3.5 top-1/2 -translate-y-1/2 text-text-secondary">
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'w-full h-11 rounded-[14px] border bg-surface text-text-primary text-sm',
|
||||
'placeholder:text-text-secondary/50',
|
||||
'focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary/30',
|
||||
'transition-all duration-150',
|
||||
icon ? 'pl-10 pr-4' : 'px-4',
|
||||
error && 'border-danger focus:ring-danger/20 focus:border-danger/30',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="mt-1.5 text-xs text-danger">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
Input.displayName = 'Input'
|
||||
@@ -0,0 +1,32 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface SkeletonProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function Skeleton({ className }: SkeletonProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'animate-pulse rounded-[14px] bg-text-secondary/8',
|
||||
className
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function SkeletonText({ lines = 1, className }: { lines?: number; className?: string }) {
|
||||
return (
|
||||
<div className={cn('space-y-2', className)}>
|
||||
{Array.from({ length: lines }).map((_, i) => (
|
||||
<Skeleton
|
||||
key={i}
|
||||
className={cn(
|
||||
'h-4',
|
||||
i === lines - 1 && lines > 1 ? 'w-3/4' : 'w-full'
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useState } from 'react'
|
||||
|
||||
interface SwitchProps {
|
||||
checked?: boolean
|
||||
onChange?: (checked: boolean) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function Switch({ checked = false, onChange, className }: SwitchProps) {
|
||||
const [isOn, setIsOn] = useState(checked)
|
||||
|
||||
const toggle = () => {
|
||||
const newValue = !isOn
|
||||
setIsOn(newValue)
|
||||
onChange?.(newValue)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={toggle}
|
||||
className={cn(
|
||||
'w-9 h-5 rounded-full relative transition-colors duration-200',
|
||||
isOn ? 'bg-success' : 'bg-text-secondary/20',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'absolute top-0.5 w-4 h-4 rounded-full bg-white shadow-sm transition-transform duration-200',
|
||||
isOn ? 'translate-x-4.5' : 'translate-x-0.5'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface TableProps {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function Table({ children, className }: TableProps) {
|
||||
return (
|
||||
<div className="w-full overflow-x-auto">
|
||||
<table className={cn('w-full', className)}>
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function TableHead({ children, className }: TableProps) {
|
||||
return (
|
||||
<thead className={cn('border-b border-border', className)}>
|
||||
{children}
|
||||
</thead>
|
||||
)
|
||||
}
|
||||
|
||||
export function TableBody({ children, className }: TableProps) {
|
||||
return <tbody className={className}>{children}</tbody>
|
||||
}
|
||||
|
||||
export function TableRow({ children, className }: TableProps) {
|
||||
return (
|
||||
<tr className={cn('border-b border-border/50 transition-colors hover:bg-bg/50', className)}>
|
||||
{children}
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
interface TableCellProps {
|
||||
children?: React.ReactNode
|
||||
className?: string
|
||||
align?: 'left' | 'center' | 'right'
|
||||
colSpan?: number
|
||||
}
|
||||
|
||||
export function TableHeader({ children, className, align = 'left', colSpan }: TableCellProps) {
|
||||
const alignClass = {
|
||||
left: 'text-left',
|
||||
center: 'text-center',
|
||||
right: 'text-right',
|
||||
}
|
||||
|
||||
return (
|
||||
<th
|
||||
colSpan={colSpan}
|
||||
className={cn(
|
||||
'py-3 px-4 text-xs font-medium text-text-secondary uppercase tracking-wider',
|
||||
alignClass[align],
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</th>
|
||||
)
|
||||
}
|
||||
|
||||
export function TableCell({ children, className, align = 'left', colSpan }: TableCellProps) {
|
||||
const alignClass = {
|
||||
left: 'text-left',
|
||||
center: 'text-center',
|
||||
right: 'text-right',
|
||||
}
|
||||
|
||||
return (
|
||||
<td
|
||||
colSpan={colSpan}
|
||||
className={cn(
|
||||
'py-3.5 px-4 text-sm text-text-primary',
|
||||
alignClass[align],
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</td>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import { authApi } from '@/lib/api'
|
||||
|
||||
export function useAuth() {
|
||||
const { token, user, isAuthenticated, login, logout, setUser } = useAuthStore()
|
||||
|
||||
useEffect(() => {
|
||||
if (token && !user) {
|
||||
authApi.me()
|
||||
.then((data) => setUser(data.user as unknown as Parameters<typeof setUser>[0]))
|
||||
.catch(() => logout())
|
||||
}
|
||||
}, [token, user, setUser, logout])
|
||||
|
||||
return { token, user, isAuthenticated, login, logout }
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--color-bg: #FAFAF8;
|
||||
--color-surface: #FFFFFF;
|
||||
--color-sidebar: #FFFFFF;
|
||||
--color-text-primary: #111827;
|
||||
--color-text-secondary: #6B7280;
|
||||
--color-border: rgba(0,0,0,0.06);
|
||||
--color-primary: #2563EB;
|
||||
--color-success: #16A34A;
|
||||
--color-warning: #D97706;
|
||||
--color-danger: #DC2626;
|
||||
--color-primary-hover: #1D4ED8;
|
||||
--color-primary-light: rgba(37, 99, 235, 0.08);
|
||||
--color-success-light: rgba(22, 163, 74, 0.08);
|
||||
--color-warning-light: rgba(217, 119, 6, 0.08);
|
||||
--color-danger-light: rgba(220, 38, 38, 0.08);
|
||||
|
||||
--font-sans: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
|
||||
--radius-card: 20px;
|
||||
--radius-button: 14px;
|
||||
--radius-input: 14px;
|
||||
--radius-dropdown: 18px;
|
||||
|
||||
--shadow-card: 0 2px 10px rgba(0,0,0,0.04);
|
||||
--shadow-dropdown: 0 4px 20px rgba(0,0,0,0.08);
|
||||
--shadow-hover: 0 4px 16px rgba(0,0,0,0.06);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
font-family: var(--font-sans);
|
||||
background-color: var(--color-bg);
|
||||
color: var(--color-text-primary);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--color-bg);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
* {
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.card-shadow {
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.dropdown-shadow {
|
||||
box-shadow: var(--shadow-dropdown);
|
||||
}
|
||||
|
||||
.hover-shadow {
|
||||
box-shadow: var(--shadow-hover);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
const BASE_URL = '/api/v1'
|
||||
|
||||
function getToken(): string | null {
|
||||
// Try Zustand store first, then localStorage fallback
|
||||
try {
|
||||
const stored = localStorage.getItem('amos-auth')
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored)
|
||||
if (parsed.state?.token) {
|
||||
return parsed.state.token
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return localStorage.getItem('amos_token')
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
method: string,
|
||||
endpoint: string,
|
||||
body?: unknown
|
||||
): Promise<T> {
|
||||
const url = `${BASE_URL}${endpoint}`
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
const token = getToken()
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({}))
|
||||
throw new Error(error.message || `HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(endpoint: string) => request<T>('GET', endpoint),
|
||||
post: <T>(endpoint: string, body?: unknown) => request<T>('POST', endpoint, body),
|
||||
put: <T>(endpoint: string, body?: unknown) => request<T>('PUT', endpoint, body),
|
||||
delete: <T>(endpoint: string) => request<T>('DELETE', endpoint),
|
||||
}
|
||||
|
||||
// Auth
|
||||
export interface LoginResponse {
|
||||
ok: boolean
|
||||
token: string
|
||||
expires_in: number
|
||||
algorithm: string
|
||||
}
|
||||
|
||||
export interface MeResponse {
|
||||
user: {
|
||||
sub: string
|
||||
email: string
|
||||
roles: string[]
|
||||
}
|
||||
}
|
||||
|
||||
export const authApi = {
|
||||
login: (email: string, password: string) =>
|
||||
api.post<LoginResponse>('/auth/login', { email, password }),
|
||||
me: () => api.get<MeResponse>('/auth/me'),
|
||||
}
|
||||
|
||||
// CRM
|
||||
export const crmApi = {
|
||||
customers: () => api.get<{ customers: unknown[]; total: number }>('/crm/customers'),
|
||||
leads: () => api.get<{ leads: unknown[] }>('/crm/leads'),
|
||||
pipeline: () => api.get<{ stages: unknown[] }>('/crm/pipeline'),
|
||||
}
|
||||
|
||||
// Sales
|
||||
export const salesApi = {
|
||||
deals: () => api.get<{ deals: unknown[]; total: number }>('/sales/deals'),
|
||||
products: () => api.get<{ products: unknown[] }>('/sales/products'),
|
||||
mrr: () => api.get<{ mrr: unknown[] }>('/sales/mrr'),
|
||||
arr: () => api.get<{ arr: unknown[] }>('/sales/arr'),
|
||||
}
|
||||
|
||||
// Finance
|
||||
export const financeApi = {
|
||||
balance: () => api.get<unknown>('/finance/balance'),
|
||||
income: () => api.get<unknown>('/finance/income'),
|
||||
moms: () => api.get<unknown>('/finance/moms'),
|
||||
accounts: () => api.get<{ accounts: unknown[] }>('/finance/accounts'),
|
||||
invoices: () => api.get<{ invoices: unknown[] }>('/finance/invoices'),
|
||||
cashflow: () => api.get<{ cashflow: unknown[] }>('/finance/cashflow'),
|
||||
}
|
||||
|
||||
// HR
|
||||
export const hrApi = {
|
||||
employees: () => api.get<{ employees: unknown[] }>('/hr/employees'),
|
||||
leaves: () => api.get<{ leaves: unknown[] }>('/hr/leaves'),
|
||||
}
|
||||
|
||||
// Legal
|
||||
export const legalApi = {
|
||||
contracts: () => api.get<{ contracts: unknown[] }>('/legal/contracts'),
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { clsx, type ClassValue } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
export function formatCurrency(value: number, currency = 'SEK'): string {
|
||||
return new Intl.NumberFormat('sv-SE', {
|
||||
style: 'currency',
|
||||
currency,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(value)
|
||||
}
|
||||
|
||||
export function formatNumber(value: number): string {
|
||||
return new Intl.NumberFormat('sv-SE').format(value)
|
||||
}
|
||||
|
||||
export function formatDate(date: string | Date): string {
|
||||
return new Intl.DateTimeFormat('sv-SE', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
}).format(new Date(date))
|
||||
}
|
||||
|
||||
export function formatRelativeTime(date: string | Date): string {
|
||||
const now = new Date()
|
||||
const then = new Date(date)
|
||||
const diffMs = now.getTime() - then.getTime()
|
||||
const diffMins = Math.floor(diffMs / 60000)
|
||||
const diffHours = Math.floor(diffMs / 3600000)
|
||||
const diffDays = Math.floor(diffMs / 86400000)
|
||||
|
||||
if (diffMins < 1) return 'Just now'
|
||||
if (diffMins < 60) return `${diffMins}m ago`
|
||||
if (diffHours < 24) return `${diffHours}h ago`
|
||||
if (diffDays < 7) return `${diffDays}d ago`
|
||||
return formatDate(date)
|
||||
}
|
||||
|
||||
export function getInitials(name: string): string {
|
||||
return name
|
||||
.split(' ')
|
||||
.map((n) => n[0])
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
.slice(0, 2)
|
||||
}
|
||||
|
||||
export function generateId(): string {
|
||||
return Math.random().toString(36).substring(2, 11)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</StrictMode>
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
import type { User } from '@/types'
|
||||
|
||||
interface AuthState {
|
||||
token: string | null
|
||||
user: User | null
|
||||
isAuthenticated: boolean
|
||||
isLoading: boolean
|
||||
login: (token: string, user: User) => void
|
||||
logout: () => void
|
||||
setUser: (user: User) => void
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
token: null,
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
login: (token, user) => {
|
||||
localStorage.setItem('amos_token', token)
|
||||
set({ token, user, isAuthenticated: true })
|
||||
},
|
||||
logout: () => {
|
||||
localStorage.removeItem('amos_token')
|
||||
set({ token: null, user: null, isAuthenticated: false })
|
||||
},
|
||||
setUser: (user) => set({ user }),
|
||||
}),
|
||||
{
|
||||
name: 'amos-auth',
|
||||
partialize: (state) => ({ token: state.token, user: state.user, isAuthenticated: state.isAuthenticated }),
|
||||
}
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
import { create } from 'zustand'
|
||||
|
||||
interface UIState {
|
||||
sidebarOpen: boolean
|
||||
toggleSidebar: () => void
|
||||
setSidebarOpen: (open: boolean) => void
|
||||
}
|
||||
|
||||
export const useUIStore = create<UIState>((set) => ({
|
||||
sidebarOpen: true,
|
||||
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
|
||||
setSidebarOpen: (open) => set({ sidebarOpen: open }),
|
||||
}))
|
||||
@@ -0,0 +1,156 @@
|
||||
export interface User {
|
||||
id: string
|
||||
email: string
|
||||
name: string
|
||||
avatar?: string
|
||||
role: string
|
||||
}
|
||||
|
||||
export interface Customer {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
company: string
|
||||
status: 'active' | 'inactive' | 'lead'
|
||||
revenue: number
|
||||
lastContact: string
|
||||
}
|
||||
|
||||
export interface Lead {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
company: string
|
||||
source: string
|
||||
score: number
|
||||
status: 'new' | 'contacted' | 'qualified' | 'lost'
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface PipelineStage {
|
||||
id: string
|
||||
name: string
|
||||
order: number
|
||||
deals: number
|
||||
value: number
|
||||
}
|
||||
|
||||
export interface Deal {
|
||||
id: string
|
||||
name: string
|
||||
company: string
|
||||
value: number
|
||||
stage: string
|
||||
probability: number
|
||||
expectedClose: string
|
||||
owner: string
|
||||
}
|
||||
|
||||
export interface Product {
|
||||
id: string
|
||||
name: string
|
||||
sku: string
|
||||
price: number
|
||||
recurring: boolean
|
||||
active: boolean
|
||||
}
|
||||
|
||||
export interface MRRData {
|
||||
month: string
|
||||
mrr: number
|
||||
arr: number
|
||||
}
|
||||
|
||||
export interface BalanceSheet {
|
||||
assets: number
|
||||
liabilities: number
|
||||
equity: number
|
||||
date: string
|
||||
}
|
||||
|
||||
export interface IncomeStatement {
|
||||
revenue: number
|
||||
expenses: number
|
||||
netIncome: number
|
||||
period: string
|
||||
}
|
||||
|
||||
export interface MOMSReport {
|
||||
period: string
|
||||
outputVat: number
|
||||
inputVat: number
|
||||
payable: number
|
||||
}
|
||||
|
||||
export interface Account {
|
||||
id: string
|
||||
name: string
|
||||
number: string
|
||||
type: string
|
||||
balance: number
|
||||
}
|
||||
|
||||
export interface Invoice {
|
||||
id: string
|
||||
number: string
|
||||
customer: string
|
||||
amount: number
|
||||
status: 'draft' | 'sent' | 'paid' | 'overdue'
|
||||
dueDate: string
|
||||
}
|
||||
|
||||
export interface Cashflow {
|
||||
period: string
|
||||
inflow: number
|
||||
outflow: number
|
||||
net: number
|
||||
}
|
||||
|
||||
export interface Employee {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
department: string
|
||||
role: string
|
||||
status: 'active' | 'on_leave' | 'terminated'
|
||||
startDate: string
|
||||
}
|
||||
|
||||
export interface Leave {
|
||||
id: string
|
||||
employeeId: string
|
||||
employeeName: string
|
||||
type: 'vacation' | 'sick' | 'parental' | 'other'
|
||||
startDate: string
|
||||
endDate: string
|
||||
status: 'pending' | 'approved' | 'rejected'
|
||||
days: number
|
||||
}
|
||||
|
||||
export interface Contract {
|
||||
id: string
|
||||
title: string
|
||||
counterparty: string
|
||||
type: string
|
||||
status: 'draft' | 'review' | 'signed' | 'expired'
|
||||
startDate: string
|
||||
endDate?: string
|
||||
value?: number
|
||||
}
|
||||
|
||||
export interface KPIData {
|
||||
label: string
|
||||
value: string
|
||||
change: number
|
||||
changeLabel: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
export interface ActivityItem {
|
||||
id: string
|
||||
type: 'deal' | 'customer' | 'invoice' | 'contract' | 'employee'
|
||||
title: string
|
||||
description: string
|
||||
time: string
|
||||
user?: string
|
||||
}
|
||||
Reference in New Issue
Block a user