408 lines
15 KiB
TypeScript
408 lines
15 KiB
TypeScript
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>
|
|
)
|
|
}
|