187 lines
9.0 KiB
JavaScript
187 lines
9.0 KiB
JavaScript
import { useState, useEffect } from 'react'
|
|
import { LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts'
|
|
import { Download, RefreshCw, AlertCircle, Loader2 } from 'lucide-react'
|
|
import api from '../services/api'
|
|
|
|
export default function Analytics() {
|
|
const [data, setData] = useState(null)
|
|
const [logs, setLogs] = useState([])
|
|
const [pagination, setPagination] = useState({ total: 0, page: 1, page_size: 20, total_pages: 1, has_prev: false, has_next: false })
|
|
const [page, setPage] = useState(1)
|
|
const [pageSize, setPageSize] = useState(20)
|
|
const [loading, setLoading] = useState(true)
|
|
|
|
useEffect(() => {
|
|
fetchData()
|
|
}, [page, pageSize])
|
|
|
|
const fetchData = async () => {
|
|
setLoading(true)
|
|
try {
|
|
const [summaryRes, logsRes] = await Promise.all([
|
|
api.get('/api/user/analytics/summary'),
|
|
api.get(`/api/user/analytics/logs?page=${page}&page_size=${pageSize}`)
|
|
])
|
|
setData(summaryRes.data)
|
|
setLogs(logsRes.data.logs)
|
|
setPagination(logsRes.data.pagination || { total: 0, page: 1, page_size: pageSize, total_pages: 1, has_prev: false, has_next: false })
|
|
} catch (err) {
|
|
console.error("Failed to fetch analytics", err)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
if (loading || !data) {
|
|
return (
|
|
<div className="flex justify-center items-center min-h-[50vh]">
|
|
<Loader2 className="animate-spin text-accent" size={32} />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col gap-8">
|
|
|
|
{/* Header */}
|
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
|
<div>
|
|
<h2 className="text-2xl font-black text-text-primary tracking-tight m-0 mb-1">Usage & Analytics</h2>
|
|
<p className="text-text-secondary text-sm font-semibold m-0">
|
|
Monitor API performance and OTP delivery rates over time.
|
|
</p>
|
|
</div>
|
|
<div className="flex gap-3">
|
|
<button onClick={fetchData} className="neu-btn px-4 py-2 gap-2 flex items-center">
|
|
<RefreshCw size={16} className={loading ? 'animate-spin' : ''} /> Refresh
|
|
</button>
|
|
<button className="neu-btn px-4 py-2 gap-2 text-text-primary flex items-center hover:text-accent">
|
|
<Download size={16} /> Export CSV
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Main Charts */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
|
|
|
{/* OTPs Sent Line Chart */}
|
|
<div className="neu-raised rounded-2xl p-6 lg:p-8">
|
|
<h3 className="text-base font-black text-text-primary mb-6">Volume Sent (Last 7 Days)</h3>
|
|
<div className="h-[280px] w-full">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<LineChart data={data.chart_data} margin={{ top: 5, right: 0, left: -20, bottom: 0 }}>
|
|
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="var(--color-shadow-dark)" opacity={0.2} />
|
|
<XAxis dataKey="date" axisLine={false} tickLine={false} tick={{ fill: 'var(--color-text-muted)', fontSize: 12, fontWeight: 700 }} dy={10} />
|
|
<YAxis axisLine={false} tickLine={false} tick={{ fill: 'var(--color-text-muted)', fontSize: 12, fontWeight: 700 }} />
|
|
<Tooltip
|
|
contentStyle={{ backgroundColor: 'var(--color-base)', borderRadius: '12px', border: 'none', boxShadow: '8px 8px 16px var(--color-shadow-dark)' }}
|
|
itemStyle={{ fontWeight: 800 }}
|
|
labelStyle={{ color: 'var(--color-text-muted)', marginBottom: '4px', fontWeight: 700, fontSize: '12px' }}
|
|
/>
|
|
<Line type="monotone" name="Total Sent" dataKey="total" stroke="var(--color-accent)" strokeWidth={4} dot={{ r: 4, fill: 'var(--color-accent)', strokeWidth: 2, stroke: 'var(--color-base)' }} activeDot={{ r: 6, stroke: 'var(--color-base)', strokeWidth: 3 }} />
|
|
</LineChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Success vs Failed Bar Chart */}
|
|
<div className="neu-raised rounded-2xl p-6 lg:p-8">
|
|
<h3 className="text-base font-black text-text-primary mb-6">Delivery Status Breakdown</h3>
|
|
<div className="h-[280px] w-full">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<BarChart data={data.chart_data} margin={{ top: 5, right: 0, left: -20, bottom: 0 }}>
|
|
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="var(--color-shadow-dark)" opacity={0.2} />
|
|
<XAxis dataKey="date" axisLine={false} tickLine={false} tick={{ fill: 'var(--color-text-muted)', fontSize: 12, fontWeight: 700 }} dy={10} />
|
|
<YAxis axisLine={false} tickLine={false} tick={{ fill: 'var(--color-text-muted)', fontSize: 12, fontWeight: 700 }} />
|
|
<Tooltip
|
|
cursor={{ fill: 'rgba(0,0,0,0.03)' }}
|
|
contentStyle={{ backgroundColor: 'var(--color-base)', borderRadius: '12px', border: 'none', boxShadow: '8px 8px 16px var(--color-shadow-dark)' }}
|
|
itemStyle={{ fontWeight: 800 }}
|
|
/>
|
|
<Legend iconType="circle" wrapperStyle={{ fontSize: '12px', fontWeight: 700, paddingTop: '10px' }} />
|
|
<Bar dataKey="delivered" name="Delivered" stackId="a" fill="var(--color-accent)" radius={[0, 0, 4, 4]} />
|
|
<Bar dataKey="failed" name="Failed" stackId="a" fill="#ef4444" radius={[4, 4, 0, 0]} />
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
{/* Detailed Log Table */}
|
|
<div className="neu-raised rounded-2xl p-6 lg:p-8">
|
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-3 mb-6">
|
|
<h3 className="text-base font-black text-text-primary m-0">Recent Request Logs</h3>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-xs font-bold text-text-muted uppercase tracking-wider">Rows</span>
|
|
<select
|
|
value={pageSize}
|
|
onChange={(e) => {
|
|
setPage(1)
|
|
setPageSize(Number(e.target.value))
|
|
}}
|
|
className="neu-input !w-[92px] !py-1.5 !px-2 text-xs"
|
|
>
|
|
{[10, 20, 50].map((size) => (
|
|
<option key={size} value={size}>{size}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-3">
|
|
<div className="grid grid-cols-12 px-4 py-2 text-xs font-bold text-text-muted uppercase tracking-widest border-b border-white/5">
|
|
<div className="col-span-3">Request ID</div>
|
|
<div className="col-span-3">Phone Number</div>
|
|
<div className="col-span-3">Timestamp</div>
|
|
<div className="col-span-3 text-right">Status</div>
|
|
</div>
|
|
|
|
{logs.length === 0 ? (
|
|
<div className="text-center py-10 text-text-muted font-bold">No requests found yet.</div>
|
|
) : (
|
|
logs.map((log) => (
|
|
<div key={log.request_id} className="grid grid-cols-12 items-center px-4 py-3 neu-inset-sm rounded-xl bg-base text-sm font-semibold text-text-secondary">
|
|
<div className="col-span-3 font-mono text-xs">{log.request_id}</div>
|
|
<div className="col-span-3 text-text-primary">{log.phone}</div>
|
|
<div className="col-span-3 text-xs">{new Date(log.created_at).toLocaleString()}</div>
|
|
<div className="col-span-3 flex justify-end">
|
|
<span className={log.status === 'failed' ? 'badge-error' : 'badge-success'}>
|
|
{log.status === 'failed' ? <AlertCircle size={12} /> : null}
|
|
{log.status}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
|
|
<div className="mt-5 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
|
<div className="text-xs font-semibold text-text-secondary">
|
|
Showing page <span className="text-text-primary font-black">{pagination.page}</span> of <span className="text-text-primary font-black">{pagination.total_pages}</span> • Total logs: <span className="text-text-primary font-black">{pagination.total}</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => setPage((current) => Math.max(1, current - 1))}
|
|
disabled={!pagination.has_prev || loading}
|
|
className="neu-btn px-3 py-1.5 text-xs disabled:opacity-40"
|
|
>
|
|
Previous
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setPage((current) => current + 1)}
|
|
disabled={!pagination.has_next || loading}
|
|
className="neu-btn px-3 py-1.5 text-xs disabled:opacity-40"
|
|
>
|
|
Next
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
)
|
|
}
|