BOC v1.0: RS256 auth, Ledger integration, Prometheus metrics, CI/CD, backup
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user