/ dashboard-v2 / frontend / src / pages / Overview.jsx
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">
265266              </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  }