import { useState, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useAuth } from '../../contexts/AuthContext';
import { AppLayout } from '../../components/layout/AppLayout';
import { LoadingSpinner } from '../../components/ui/LoadingSpinner';
import { estimatesApi, type EstimateStage, type FunnelAnalytics, type EstimateStats } from '../../api/estimates';
import {
BarChart3,
TrendingUp,
TrendingDown,
DollarSign,
FileText,
CheckCircle2,
AlertTriangle,
Clock,
Filter,
RefreshCw,
ArrowRight,
Percent,
Target,
} from 'lucide-react';
// ─── Helpers ──────────────────────────────────────────────────────────────
const FUNNEL_STAGES: { key: EstimateStage; label: string; color: string; bgColor: string }[] = [
{ key: 'draft', label: 'Draft', color: 'text-slate-700', bgColor: 'bg-slate-100' },
{ key: 'delivered', label: 'Delivered', color: 'text-blue-700', bgColor: 'bg-blue-100' },
{ key: 'accepted', label: 'Accepted', color: 'text-emerald-700', bgColor: 'bg-emerald-100' },
{ key: 'scheduled', label: 'Scheduled', color: 'text-amber-700', bgColor: 'bg-amber-100' },
{ key: 'closed', label: 'Closed', color: 'text-green-700', bgColor: 'bg-green-100' },
];
const TERMINAL_STAGES: { key: EstimateStage; label: string; color: string; bgColor: string }[] = [
{ key: 'rejected', label: 'Rejected', color: 'text-red-700', bgColor: 'bg-red-100' },
{ key: 'expired', label: 'Expired', color: 'text-gray-700', bgColor: 'bg-gray-100' },
];
function formatCurrency(value: number): string {
if (value >= 1_000_000) return `$${(value / 1_000_000).toFixed(1)}M`;
if (value >= 1_000) return `$${(value / 1_000).toFixed(1)}K`;
return `$${value.toFixed(0)}`;
}
function formatPct(value: number): string {
return `${value.toFixed(1)}%`;
}
// ─── Stat Card ────────────────────────────────────────────────────────────
function StatCard({
icon: Icon,
label,
value,
subtext,
trend,
}: {
icon: React.ElementType;
label: string;
value: string;
subtext?: string;
trend?: 'up' | 'down' | 'neutral';
}) {
const trendColor = trend === 'up' ? 'text-emerald-600' : trend === 'down' ? 'text-red-600' : 'text-surface-400';
return (
<div className="rounded-xl border border-surface-200 bg-white p-5">
<div className="flex items-center justify-between">
<p className="text-xs font-medium uppercase tracking-wide text-surface-500">{label}</p>
<Icon className={`h-4 w-4 ${trendColor}`} />
</div>
<p className="mt-2 text-2xl font-bold text-surface-900">{value}</p>
{subtext && <p className="mt-1 text-xs text-surface-500">{subtext}</p>}
</div>
);
}
// ─── Funnel Bar ───────────────────────────────────────────────────────────
function FunnelBar({
stage,
count,
value,
maxCount,
conversionRate,
}: {
stage: { key: EstimateStage; label: string; color: string; bgColor: string };
count: number;
value: number;
maxCount: number;
conversionRate?: number;
}) {
const widthPct = maxCount > 0 ? Math.max((count / maxCount) * 100, 8) : 8;
return (
<div className="group">
<div className="flex items-center justify-between mb-1.5">
<div className="flex items-center gap-2">
<span className={`inline-flex rounded-full px-2 py-0.5 text-xs font-semibold ${stage.bgColor} ${stage.color}`}>
{stage.label}
</span>
<span className="text-sm font-bold text-surface-900">{count}</span>
<span className="text-xs text-surface-500">{formatCurrency(value)}</span>
</div>
{conversionRate !== undefined && (
<div className="flex items-center gap-1">
<ArrowRight className="h-3 w-3 text-surface-300" />
<span className={`text-xs font-semibold ${conversionRate >= 50 ? 'text-emerald-600' : conversionRate >= 25 ? 'text-amber-600' : 'text-red-600'}`}>
{formatPct(conversionRate)}
</span>
</div>
)}
</div>
<div className="h-3 rounded-full bg-surface-100 overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-500 ${stage.bgColor.replace('100', '400')}`}
style={{ width: `${widthPct}%` }}
/>
</div>
</div>
);
}
// ─── Drop-off Panel ───────────────────────────────────────────────────────
function DropOffPanel({ data }: { data: FunnelAnalytics['drop_off'] }) {
if (data.total_drop_off_count === 0) return null;
return (
<div className="rounded-xl border border-red-200 bg-red-50 p-5">
<div className="flex items-center gap-2 mb-4">
<AlertTriangle className="h-5 w-5 text-red-600" />
<h3 className="text-sm font-semibold text-red-900">Drop-off Analysis</h3>
<span className="ml-auto text-lg font-bold text-red-700">{formatPct(data.drop_off_rate)}</span>
</div>
<div className="grid grid-cols-2 gap-4 mb-4">
<div className="rounded-lg bg-white p-3 border border-red-100">
<p className="text-xs text-red-600 font-medium">Rejected</p>
<p className="text-xl font-bold text-red-700">{data.rejected_count}</p>
</div>
<div className="rounded-lg bg-white p-3 border border-red-100">
<p className="text-xs text-gray-600 font-medium">Expired</p>
<p className="text-xl font-bold text-gray-700">{data.expired_count}</p>
</div>
</div>
{Object.keys(data.rejection_reasons).length > 0 && (
<div>
<p className="text-xs font-medium text-red-800 mb-2">Rejection Reasons</p>
<div className="space-y-1.5">
{Object.entries(data.rejection_reasons)
.sort(([, a], [, b]) => b - a)
.map(([reason, count]) => (
<div key={reason} className="flex items-center justify-between text-sm">
<span className="text-red-800">{reason}</span>
<span className="font-semibold text-red-900">{count}</span>
</div>
))}
</div>
</div>
)}
</div>
);
}
// ─── Source Breakdown ─────────────────────────────────────────────────────
function SourcePanel({ sources }: { sources: Record<string, { count: number; total_value: number; conversion_rate: number; avg_value: number }> }) {
const entries = Object.entries(sources).sort(([, a], [, b]) => b.total_value - a.total_value);
if (entries.length === 0) return null;
return (
<div className="rounded-xl border border-surface-200 bg-white p-5">
<div className="flex items-center gap-2 mb-4">
<Target className="h-5 w-5 text-surface-600" />
<h3 className="text-sm font-semibold text-surface-900">By Source</h3>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-surface-200">
<th className="text-left py-2 px-3 text-xs font-medium text-surface-500 uppercase">Source</th>
<th className="text-right py-2 px-3 text-xs font-medium text-surface-500 uppercase">Count</th>
<th className="text-right py-2 px-3 text-xs font-medium text-surface-500 uppercase">Value</th>
<th className="text-right py-2 px-3 text-xs font-medium text-surface-500 uppercase">Avg</th>
<th className="text-right py-2 px-3 text-xs font-medium text-surface-500 uppercase">Conv. Rate</th>
</tr>
</thead>
<tbody>
{entries.map(([source, data]) => (
<tr key={source} className="border-b border-surface-100 last:border-0">
<td className="py-2 px-3 font-medium text-surface-900 capitalize">{source}</td>
<td className="py-2 px-3 text-right text-surface-700">{data.count}</td>
<td className="py-2 px-3 text-right text-surface-700">{formatCurrency(data.total_value)}</td>
<td className="py-2 px-3 text-right text-surface-500">{formatCurrency(data.avg_value)}</td>
<td className={`py-2 px-3 text-right font-semibold ${data.conversion_rate >= 30 ? 'text-emerald-600' : data.conversion_rate >= 15 ? 'text-amber-600' : 'text-red-600'}`}>
{formatPct(data.conversion_rate)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
// ─── Page ─────────────────────────────────────────────────────────────────
export function EstimatesFunnelPage() {
const { user } = useAuth();
const companyId = user?.tenantId as string;
const [days, setDays] = useState(90);
const [sourceFilter, setSourceFilter] = useState('');
const daysLabel = days === 30 ? '30 days' : days === 90 ? '90 days' : days === 180 ? '180 days' : days === 365 ? '1 year' : `${days} days`;
// Funnel analytics
const { data: funnel, isLoading: funnelLoading, refetch: refetchFunnel } = useQuery({
queryKey: ['estimates-funnel', companyId, days, sourceFilter],
queryFn: () =>
estimatesApi.getFunnelAnalytics(companyId, {
days,
...(sourceFilter ? { source: sourceFilter as any } : {}),
}),
});
// Quick stats
const { data: stats } = useQuery({
queryKey: ['estimates-stats', companyId, 30],
queryFn: () => estimatesApi.getEstimateStats(companyId, 30),
});
// Source breakdown
const { data: bySource } = useQuery({
queryKey: ['estimates-by-source', companyId, days],
queryFn: () => estimatesApi.getEstimatesBySource(companyId, days),
});
const summary = useMemo(() => funnel?.pipeline_summary ?? null, [funnel]);
if (funnelLoading) {
return (
<AppLayout>
<div className="flex items-center justify-center h-96">
<LoadingSpinner />
</div>
</AppLayout>
);
}
const maxCount = funnel ? Math.max(...Object.values(funnel.stage_counts)) : 1;
return (
<AppLayout>
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-surface-900">Estimates Funnel</h1>
<p className="text-sm text-surface-500 mt-1">
Pipeline tracking and conversion analytics — {daysLabel}
</p>
</div>
<div className="flex items-center gap-2">
{/* Date range selector */}
<select
value={days}
onChange={(e) => setDays(Number(e.target.value))}
className="rounded-lg border border-surface-300 bg-white px-3 py-2 text-sm outline-none focus:border-brand-500"
>
<option value={30}>Last 30 days</option>
<option value={90}>Last 90 days</option>
<option value={180}>Last 180 days</option>
<option value={365}>Last year</option>
</select>
{/* Refresh */}
<button
onClick={() => refetchFunnel()}
className="inline-flex items-center gap-1.5 rounded-lg border border-surface-300 px-3 py-2 text-sm text-surface-600 hover:bg-surface-50 min-h-[44px]"
>
<RefreshCw className="h-4 w-4" />
</button>
</div>
</div>
{/* Summary Cards */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard
icon={FileText}
label="Total Estimates"
value={String(funnel?.total_estimates ?? 0)}
subtext={daysLabel}
/>
<StatCard
icon={DollarSign}
label="Pipeline Value"
value={formatCurrency(summary?.total_pipeline_value ?? 0)}
subtext={`${summary?.closed_count ?? 0} closed`}
/>
<StatCard
icon={Percent}
label="Win Rate"
value={formatPct(summary?.win_rate ?? 0)}
subtext={stats ? `${stats.closed_count} won / ${stats.total_count} total (30d)` : undefined}
/>
<StatCard
icon={Clock}
label="Avg Deal Size"
value={formatCurrency(summary?.avg_deal_size ?? 0)}
subtext={
summary?.avg_time_to_accept_days
? `${summary.avg_time_to_accept_days}d to accept`
: 'No acceptance data yet'
}
/>
</div>
{/* Funnel Visualization */}
<div className="rounded-xl border border-surface-200 bg-white p-6">
<div className="flex items-center gap-2 mb-6">
<BarChart3 className="h-5 w-5 text-surface-600" />
<h2 className="text-lg font-semibold text-surface-900">Conversion Funnel</h2>
</div>
<div className="space-y-4">
{FUNNEL_STAGES.map((stage, i) => {
const count = funnel?.stage_counts[stage.key] ?? 0;
const value = funnel?.stage_values[stage.key] ?? 0;
const convKey = `${stage.key}_to_${FUNNEL_STAGES[i + 1]?.key}`;
const convRate = funnel?.conversion_rates[convKey]?.rate;
return (
<FunnelBar
key={stage.key}
stage={stage}
count={count}
value={value}
maxCount={maxCount}
conversionRate={convRate}
/>
);
})}
</div>
{/* Terminal stages */}
{TERMINAL_STAGES.length > 0 && (
<>
<div className="border-t border-surface-200 my-4" />
<p className="text-xs font-medium uppercase tracking-wide text-surface-500 mb-3">Terminal Stages</p>
<div className="space-y-4">
{TERMINAL_STAGES.map((stage) => {
const count = funnel?.stage_counts[stage.key] ?? 0;
const value = funnel?.stage_values[stage.key] ?? 0;
return (
<FunnelBar
key={stage.key}
stage={stage}
count={count}
value={value}
maxCount={maxCount}
/>
);
})}
</div>
</>
)}
</div>
{/* Bottom row: Drop-off + Source */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{funnel && <DropOffPanel data={funnel.drop_off} />}
{bySource && <SourcePanel sources={bySource.sources} />}
</div>
{/* 30-day quick stats */}
{stats && (
<div className="rounded-xl border border-surface-200 bg-white p-6">
<div className="flex items-center gap-2 mb-4">
<TrendingUp className="h-5 w-5 text-surface-600" />
<h2 className="text-lg font-semibold text-surface-900">30-Day Snapshot</h2>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-4">
{[
{ label: 'New Estimates', value: String(stats.total_count) },
{ label: 'Active', value: String(stats.active_count) },
{ label: 'Closed', value: String(stats.closed_count) },
{ label: 'Total Value', value: formatCurrency(stats.total_value) },
{ label: 'Pipeline', value: formatCurrency(stats.pipeline_value) },
{ label: 'Closed Value', value: formatCurrency(stats.closed_value) },
].map((item) => (
<div key={item.label} className="rounded-lg bg-surface-50 p-3">
<p className="text-xs text-surface-500">{item.label}</p>
<p className="mt-1 text-lg font-bold text-surface-900">{item.value}</p>
</div>
))}
</div>
</div>
)}
</div>
</AppLayout>
);
}