Overview.jsx
1 import { useQuery } from '@tanstack/react-query'; 2 import { fetchOverview } from '../api'; 3 import MetricCard from '../components/MetricCard'; 4 import DataTable from '../components/DataTable'; 5 import { BarChart, LineChart, StackedBarChart, FunnelChart, PieChart } from '../components/charts'; 6 7 // ── Cost Forecast Panel ─────────────────────────────────────────────────────── 8 // Business plan constants (updated via cache every 4 days) 9 const BP = { 10 avg_price_aud: 297, // average deal value (AUD) 11 cogs_per_sale: 2.0, // variable cost per customer (AUD) 12 fixed_monthly: 306, // fixed monthly opex (AUD, Year 1) 13 outreach_per_month: 500, // target outreach volume 14 response_rate: 0.02, // 2% response rate 15 conversion_rate: 0.2, // 20% of responses convert to sales 16 // personal break-even 17 personal_monthly_needed: 9207, // AUD 18 }; 19 20 function CostForecast({ forecast }) { 21 if (!forecast) return null; 22 const f = forecast; 23 24 // API cost actuals (from llm_usage, last 30d rolling average) 25 const daily_api_cost = f.daily_api_cost_avg ?? 0; 26 const monthly_api_cost = daily_api_cost * 30; 27 28 // Profitability calcs 29 const monthly_sales = f.monthly_sales ?? 0; 30 const monthly_revenue = monthly_sales * (f.avg_deal_value ?? BP.avg_price_aud); 31 const monthly_cogs = monthly_sales * BP.cogs_per_sale; 32 const monthly_opex = BP.fixed_monthly + monthly_api_cost; 33 const monthly_profit = monthly_revenue - monthly_cogs - monthly_opex; 34 const margin_pct = monthly_revenue > 0 ? (monthly_profit / monthly_revenue) * 100 : 0; 35 36 // Pipeline cost forecast: sites currently in pipeline × avg cost per stage 37 const pipeline_cost = Object.entries(f.pipeline_cost_forecast ?? {}).reduce( 38 (sum, [, v]) => sum + (v.count ?? 0) * (v.avg_cost ?? 0), 39 0 40 ); 41 42 // Outreach funnel projection 43 const projected_responses = Math.round(BP.outreach_per_month * BP.response_rate); 44 const projected_sales = Math.round(projected_responses * BP.conversion_rate); 45 const projected_revenue = projected_sales * BP.avg_price_aud; 46 47 // Break-even 48 const be_sales_needed = Math.ceil(monthly_opex / (BP.avg_price_aud - BP.cogs_per_sale)); 49 const be_status = monthly_sales >= be_sales_needed ? 'above' : 'below'; 50 51 return ( 52 <div className="bg-slate-800 rounded-xl p-4 border border-slate-700 space-y-4"> 53 <div className="flex items-center justify-between"> 54 <h3 className="text-sm font-medium text-slate-400">Cost Forecast & Profitability</h3> 55 <span className="text-xs text-slate-600">updated every 4 days</span> 56 </div> 57 58 {/* Current month actuals */} 59 <div className="grid grid-cols-2 md:grid-cols-4 gap-3"> 60 <div className="bg-slate-900 rounded-lg p-3"> 61 <p className="text-xs text-slate-500">API Cost/Day</p> 62 <p className="text-lg font-bold text-amber-400">${daily_api_cost.toFixed(2)}</p> 63 <p className="text-xs text-slate-600">${monthly_api_cost.toFixed(0)}/mo est.</p> 64 </div> 65 <div className="bg-slate-900 rounded-lg p-3"> 66 <p className="text-xs text-slate-500">Pipeline Cost (pending)</p> 67 <p className="text-lg font-bold text-amber-400">${pipeline_cost.toFixed(2)}</p> 68 <p className="text-xs text-slate-600">to process current queue</p> 69 </div> 70 <div className="bg-slate-900 rounded-lg p-3"> 71 <p className="text-xs text-slate-500">Monthly Revenue</p> 72 <p className="text-lg font-bold text-emerald-400"> 73 ${monthly_revenue.toLocaleString('en-AU', { maximumFractionDigits: 0 })} 74 </p> 75 <p className="text-xs text-slate-600"> 76 {monthly_sales} sale{monthly_sales !== 1 ? 's' : ''} × $ 77 {(f.avg_deal_value ?? BP.avg_price_aud).toFixed(0)} 78 </p> 79 </div> 80 <div className="bg-slate-900 rounded-lg p-3"> 81 <p className="text-xs text-slate-500">Monthly Profit</p> 82 <p 83 className={`text-lg font-bold ${monthly_profit >= 0 ? 'text-emerald-400' : 'text-red-400'}`} 84 > 85 ${monthly_profit.toLocaleString('en-AU', { maximumFractionDigits: 0 })} 86 </p> 87 <p className="text-xs text-slate-600">{margin_pct.toFixed(1)}% margin</p> 88 </div> 89 </div> 90 91 {/* Break-even status */} 92 <div 93 className={`rounded-lg p-3 border ${ 94 be_status === 'above' 95 ? 'bg-emerald-900/20 border-emerald-700' 96 : 'bg-amber-900/20 border-amber-700' 97 }`} 98 > 99 <div className="flex items-center justify-between text-sm"> 100 <span className="text-slate-300"> 101 Break-even: <strong>{be_sales_needed} sales/mo</strong> needed (fixed costs $ 102 {monthly_opex.toFixed(0)}/mo ÷ ${(BP.avg_price_aud - BP.cogs_per_sale).toFixed(0)}{' '} 103 margin/sale) 104 </span> 105 <span 106 className={ 107 be_status === 'above' ? 'text-emerald-400 font-bold' : 'text-amber-400 font-bold' 108 } 109 > 110 {be_status === 'above' 111 ? '✓ Profitable' 112 : `Need ${be_sales_needed - monthly_sales} more sale${be_sales_needed - monthly_sales !== 1 ? 's' : ''}`} 113 </span> 114 </div> 115 </div> 116 117 {/* Projected vs actual */} 118 <div className="grid grid-cols-3 gap-3 text-center"> 119 <div> 120 <p className="text-xs text-slate-500">Projected Sales/mo</p> 121 <p className="text-base font-semibold text-slate-300">{projected_sales}</p> 122 <p className="text-xs text-slate-600"> 123 {BP.outreach_per_month} outreach × {(BP.response_rate * 100).toFixed(0)}% resp ×{' '} 124 {(BP.conversion_rate * 100).toFixed(0)}% conv 125 </p> 126 </div> 127 <div> 128 <p className="text-xs text-slate-500">Projected Revenue/mo</p> 129 <p className="text-base font-semibold text-sky-400"> 130 ${projected_revenue.toLocaleString('en-AU', { maximumFractionDigits: 0 })} 131 </p> 132 <p className="text-xs text-slate-600">at ${BP.avg_price_aud} avg deal</p> 133 </div> 134 <div> 135 <p className="text-xs text-slate-500">Personal Break-even</p> 136 <p className="text-base font-semibold text-slate-300"> 137 ${BP.personal_monthly_needed.toLocaleString()}/mo 138 </p> 139 <p className="text-xs text-slate-600"> 140 living + ops ($ 141 {Math.ceil(BP.personal_monthly_needed / (BP.avg_price_aud - BP.cogs_per_sale))} sales 142 needed) 143 </p> 144 </div> 145 </div> 146 147 {/* Per-stage cost breakdown */} 148 {Object.keys(f.pipeline_cost_forecast ?? {}).length > 0 && ( 149 <div> 150 <p className="text-xs text-slate-500 mb-2">Pipeline cost forecast by stage</p> 151 <div className="grid grid-cols-2 md:grid-cols-4 gap-2"> 152 {Object.entries(f.pipeline_cost_forecast ?? {}).map(([stage, v]) => ( 153 <div key={stage} className="bg-slate-900 rounded p-2 text-xs"> 154 <p className="text-slate-500 capitalize">{stage.replace('_', ' ')}</p> 155 <p className="text-slate-300"> 156 {v.count ?? 0} sites × ${(v.avg_cost ?? 0).toFixed(4)} 157 </p> 158 <p className="text-amber-400 font-medium"> 159 ${((v.count ?? 0) * (v.avg_cost ?? 0)).toFixed(3)} 160 </p> 161 </div> 162 ))} 163 </div> 164 </div> 165 )} 166 </div> 167 ); 168 } 169 170 // ── Columns ─────────────────────────────────────────────────────────────────── 171 const STUCK_COLS = [ 172 { accessorKey: 'domain', header: 'Domain' }, 173 { accessorKey: 'status', header: 'Stage' }, 174 { 175 accessorKey: 'hours_stuck', 176 header: 'Hours Stuck', 177 cell: i => `${(i.getValue() ?? 0).toFixed(0)}h`, 178 }, 179 { 180 accessorKey: 'error_message', 181 header: 'Error', 182 cell: i => ( 183 <span className="text-red-400 text-xs truncate max-w-xs block">{i.getValue() ?? ''}</span> 184 ), 185 }, 186 ]; 187 188 const ERROR_COLS = [ 189 { accessorKey: 'error', header: 'Error Message' }, 190 { accessorKey: 'count', header: 'Count' }, 191 { accessorKey: 'stage', header: 'Stage' }, 192 ]; 193 194 export default function Overview() { 195 const { data, isLoading, error } = useQuery({ queryKey: ['overview'], queryFn: fetchOverview }); 196 197 if (isLoading) return <div className="text-slate-400 p-8">Loading…</div>; 198 if (error) return <div className="text-red-400 p-8">Error: {error.message}</div>; 199 200 const d = data ?? {}; 201 const funnel = d.pipeline_funnel ?? []; 202 const hourly = d.hourly_status_48h ?? []; 203 const daily = d.daily_throughput_30d ?? []; 204 const errors = d.error_breakdown ?? []; 205 const stuck = d.stuck_sites ?? []; 206 const activity = d.activity_24h ?? []; 207 const kn = d.key_numbers ?? {}; 208 const forecast = d.cost_forecast ?? null; 209 210 return ( 211 <div className="space-y-6"> 212 {/* Key metrics */} 213 <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4"> 214 <MetricCard label="Total Sites" value={(kn.total_sites ?? 0).toLocaleString()} /> 215 <MetricCard label="Active Pipeline" value={(kn.active_pipeline ?? 0).toLocaleString()} /> 216 <MetricCard label="Outreach Sent" value={(kn.outreach_sent ?? 0).toLocaleString()} /> 217 <MetricCard 218 label="Response Rate" 219 value={`${(kn.response_rate ?? 0).toFixed(1)}%`} 220 variant={(kn.response_rate ?? 0) >= 2 ? 'success' : 'warning'} 221 /> 222 <MetricCard label="Total Sales" value={kn.sales ?? 0} variant="success" /> 223 <MetricCard 224 label="Revenue" 225 value={`$${(kn.revenue ?? 0).toLocaleString('en-AU', { maximumFractionDigits: 0 })}`} 226 variant="success" 227 /> 228 </div> 229 230 {/* Cost Forecast */} 231 <CostForecast forecast={forecast} /> 232 233 {/* Pipeline Funnel */} 234 {funnel.length > 0 && ( 235 <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> 236 <div className="bg-slate-800 rounded-xl p-4 border border-slate-700"> 237 <h3 className="text-sm font-medium text-slate-400 mb-3">Pipeline Funnel</h3> 238 <FunnelChart data={funnel} nameKey="status" valueKey="count" /> 239 </div> 240 <div className="bg-slate-800 rounded-xl p-4 border border-slate-700"> 241 <h3 className="text-sm font-medium text-slate-400 mb-3">Status Distribution</h3> 242 <PieChart data={funnel} nameKey="status" valueKey="count" /> 243 </div> 244 </div> 245 )} 246 247 {/* Pipeline Health */} 248 {(errors.length > 0 || stuck.length > 0) && ( 249 <details 250 className="bg-slate-800 rounded-xl border border-slate-700 group" 251 open={errors.length > 0} 252 > 253 <summary className="flex items-center justify-between p-4 cursor-pointer select-none"> 254 <h3 className="text-sm font-medium text-slate-400"> 255 Pipeline Health 256 {(errors.length > 0 || stuck.length > 0) && ( 257 <span className="ml-2 text-xs text-red-400"> 258 {errors.length > 0 ? `${errors.length} error types` : ''} 259 {errors.length > 0 && stuck.length > 0 ? ', ' : ''} 260 {stuck.length > 0 ? `${stuck.length} stuck` : ''} 261 </span> 262 )} 263 </h3> 264 <span className="text-slate-600 text-sm group-open:rotate-180 transition-transform"> 265 ▼ 266 </span> 267 </summary> 268 <div className="px-4 pb-4 space-y-4"> 269 {errors.length > 0 && ( 270 <> 271 <BarChart 272 data={errors.slice(0, 10)} 273 xKey="error" 274 bars={[{ key: 'count', name: 'Count', color: '#f87171' }]} 275 height={200} 276 /> 277 <DataTable columns={ERROR_COLS} data={errors} maxRows={10} /> 278 </> 279 )} 280 {stuck.length > 0 && <DataTable columns={STUCK_COLS} data={stuck} maxRows={20} />} 281 </div> 282 </details> 283 )} 284 285 {/* Hourly throughput 48h */} 286 {hourly.length > 0 && ( 287 <div className="bg-slate-800 rounded-xl p-4 border border-slate-700"> 288 <h3 className="text-sm font-medium text-slate-400 mb-3">Hourly Activity (Last 48h)</h3> 289 <StackedBarChart 290 data={hourly} 291 xKey="hour" 292 bars={[ 293 { key: 'scored', name: 'Scored', color: '#38bdf8' }, 294 { key: 'enriched', name: 'Enriched', color: '#34d399' }, 295 { key: 'outreach_sent', name: 'Outreach', color: '#fbbf24' }, 296 ]} 297 /> 298 </div> 299 )} 300 301 {/* Daily throughput 30d */} 302 {daily.length > 0 && ( 303 <div className="bg-slate-800 rounded-xl p-4 border border-slate-700"> 304 <h3 className="text-sm font-medium text-slate-400 mb-3"> 305 Daily Throughput (Last 30 Days) 306 </h3> 307 <LineChart 308 data={daily} 309 xKey="date" 310 lines={[ 311 { key: 'processed', name: 'Processed', color: '#38bdf8' }, 312 { key: 'outreach_sent', name: 'Outreach', color: '#fbbf24' }, 313 ]} 314 /> 315 </div> 316 )} 317 318 {/* Activity 24h */} 319 {activity.length > 0 && ( 320 <div className="bg-slate-800 rounded-xl p-4 border border-slate-700"> 321 <h3 className="text-sm font-medium text-slate-400 mb-3">Activity Last 24h</h3> 322 <BarChart 323 data={activity} 324 xKey="stage" 325 bars={[{ key: 'count', name: 'Sites', color: '#60a5fa' }]} 326 /> 327 </div> 328 )} 329 </div> 330 ); 331 }