Files
computer/src/lib/adminAnalytics.ts
T
zeekay 88e8d8327f feat(computer): migrate auth from Supabase to hanzo.id OAuth, data to Commerce API
- Replace Supabase auth with hanzo.id OAuth 2.0 PKCE flow (src/lib/auth.ts)
- Replace Supabase database queries with Commerce API client (src/lib/commerce.ts)
- Add OAuth callback page (src/pages/AuthCallback.tsx)
- Update all 19 files importing from supabase to use auth/commerce modules
- Remove @supabase/supabase-js dependency from package.json
- Delete src/lib/supabase.ts (422 lines)
- Update .env.example with IAM and Commerce API vars
- Build verified: zero Supabase imports remain
2026-03-04 16:30:20 -08:00

555 lines
15 KiB
TypeScript

import { supabase } from './auth';
import { startOfDay, endOfDay, subDays, format, eachDayOfInterval, eachWeekOfInterval, eachMonthOfInterval, startOfWeek, startOfMonth } from 'date-fns';
export interface RevenueDataPoint {
date: string;
revenue: number;
orders: number;
gpuType?: string;
}
export interface ConversionFunnel {
stage: string;
value: number;
percentage: number;
}
export interface CustomerGrowth {
date: string;
newCustomers: number;
totalCustomers: number;
}
export interface GPUTypeBreakdown {
gpuType: string;
revenue: number;
orders: number;
percentage: number;
}
export interface TopCustomer {
id: string;
name: string;
email: string;
revenue: number;
orders: number;
joinDate: string;
}
export interface QuotePerformance {
date: string;
sent: number;
accepted: number;
rejected: number;
pending: number;
acceptanceRate: number;
}
export interface MetricCard {
title: string;
value: number | string;
change: number;
changeType: 'increase' | 'decrease' | 'neutral';
prefix?: string;
suffix?: string;
}
// Get revenue data grouped by time period
export async function getRevenueData(
startDate: Date,
endDate: Date,
groupBy: 'day' | 'week' | 'month' = 'day'
): Promise<RevenueDataPoint[]> {
const { data: orders, error } = await supabase
.from('quotes')
.select('created_at, total, gpu_type, status')
.eq('status', 'accepted')
.gte('created_at', startDate.toISOString())
.lte('created_at', endDate.toISOString())
.order('created_at');
if (error) {
console.error('Error fetching revenue data:', error);
return [];
}
// Group data by period
const groupedData = new Map<string, RevenueDataPoint>();
let intervals: Date[] = [];
if (groupBy === 'day') {
intervals = eachDayOfInterval({ start: startDate, end: endDate });
} else if (groupBy === 'week') {
intervals = eachWeekOfInterval({ start: startDate, end: endDate });
} else {
intervals = eachMonthOfInterval({ start: startDate, end: endDate });
}
// Initialize all intervals with zero values
intervals.forEach(date => {
const key = format(date, groupBy === 'day' ? 'yyyy-MM-dd' : groupBy === 'week' ? 'yyyy-ww' : 'yyyy-MM');
groupedData.set(key, { date: key, revenue: 0, orders: 0 });
});
// Aggregate orders into periods
orders?.forEach(order => {
const orderDate = new Date(order.created_at);
let key: string;
if (groupBy === 'day') {
key = format(orderDate, 'yyyy-MM-dd');
} else if (groupBy === 'week') {
key = format(startOfWeek(orderDate), 'yyyy-ww');
} else {
key = format(startOfMonth(orderDate), 'yyyy-MM');
}
const existing = groupedData.get(key);
if (existing) {
existing.revenue += order.total;
existing.orders += 1;
}
});
return Array.from(groupedData.values());
}
// Get conversion funnel data
export async function getConversionFunnel(
startDate: Date,
endDate: Date
): Promise<ConversionFunnel[]> {
const { data: rfqs } = await supabase
.from('rfqs')
.select('id')
.gte('created_at', startDate.toISOString())
.lte('created_at', endDate.toISOString());
const { data: quotes } = await supabase
.from('quotes')
.select('id, status')
.gte('created_at', startDate.toISOString())
.lte('created_at', endDate.toISOString());
const { data: orders } = await supabase
.from('quotes')
.select('id')
.eq('status', 'accepted')
.gte('created_at', startDate.toISOString())
.lte('created_at', endDate.toISOString());
const rfqCount = rfqs?.length || 0;
const quoteCount = quotes?.length || 0;
const orderCount = orders?.length || 0;
return [
{
stage: 'RFQs Submitted',
value: rfqCount,
percentage: 100
},
{
stage: 'Quotes Sent',
value: quoteCount,
percentage: rfqCount > 0 ? (quoteCount / rfqCount) * 100 : 0
},
{
stage: 'Orders Placed',
value: orderCount,
percentage: rfqCount > 0 ? (orderCount / rfqCount) * 100 : 0
}
];
}
// Get customer growth over time
export async function getCustomerGrowth(
startDate: Date,
endDate: Date
): Promise<CustomerGrowth[]> {
const { data: customers, error } = await supabase
.from('users')
.select('id, created_at')
.gte('created_at', startDate.toISOString())
.lte('created_at', endDate.toISOString())
.order('created_at');
if (error) {
console.error('Error fetching customer data:', error);
return [];
}
const dailyGrowth = new Map<string, CustomerGrowth>();
const days = eachDayOfInterval({ start: startDate, end: endDate });
let totalCustomers = 0;
// Get count of customers before start date
const { count: previousCount } = await supabase
.from('users')
.select('id', { count: 'exact', head: true })
.lt('created_at', startDate.toISOString());
totalCustomers = previousCount || 0;
// Initialize days
days.forEach(day => {
const key = format(day, 'yyyy-MM-dd');
dailyGrowth.set(key, { date: key, newCustomers: 0, totalCustomers });
});
// Count new customers per day
customers?.forEach(customer => {
const date = format(new Date(customer.created_at), 'yyyy-MM-dd');
const growth = dailyGrowth.get(date);
if (growth) {
growth.newCustomers += 1;
totalCustomers += 1;
growth.totalCustomers = totalCustomers;
}
});
return Array.from(dailyGrowth.values());
}
// Get GPU type breakdown
export async function getGPUTypeBreakdown(
startDate: Date,
endDate: Date
): Promise<GPUTypeBreakdown[]> {
const { data: orders } = await supabase
.from('quotes')
.select('gpu_type, total')
.eq('status', 'accepted')
.gte('created_at', startDate.toISOString())
.lte('created_at', endDate.toISOString());
if (!orders || orders.length === 0) return [];
const breakdown = new Map<string, GPUTypeBreakdown>();
let totalRevenue = 0;
orders.forEach(order => {
const type = order.gpu_type || 'Unknown';
const existing = breakdown.get(type) || {
gpuType: type,
revenue: 0,
orders: 0,
percentage: 0
};
existing.revenue += order.total;
existing.orders += 1;
totalRevenue += order.total;
breakdown.set(type, existing);
});
// Calculate percentages
breakdown.forEach(item => {
item.percentage = totalRevenue > 0 ? (item.revenue / totalRevenue) * 100 : 0;
});
return Array.from(breakdown.values()).sort((a, b) => b.revenue - a.revenue);
}
// Get top customers
export async function getTopCustomers(limit: number = 10): Promise<TopCustomer[]> {
const { data: customers } = await supabase
.from('quotes')
.select(`
user_id,
users!inner(id, email, created_at),
total
`)
.eq('status', 'accepted');
if (!customers) return [];
const customerMap = new Map<string, TopCustomer>();
customers.forEach((order: any) => {
const userId = order.user_id;
const existing = customerMap.get(userId);
if (existing) {
existing.revenue += order.total;
existing.orders += 1;
} else {
customerMap.set(userId, {
id: userId,
name: order.users.email.split('@')[0],
email: order.users.email,
revenue: order.total,
orders: 1,
joinDate: order.users.created_at
});
}
});
return Array.from(customerMap.values())
.sort((a, b) => b.revenue - a.revenue)
.slice(0, limit);
}
// Get quote performance metrics
export async function getQuotePerformance(
startDate: Date,
endDate: Date
): Promise<QuotePerformance[]> {
const { data: quotes } = await supabase
.from('quotes')
.select('created_at, status')
.gte('created_at', startDate.toISOString())
.lte('created_at', endDate.toISOString())
.order('created_at');
if (!quotes) return [];
const performanceMap = new Map<string, QuotePerformance>();
const days = eachDayOfInterval({ start: startDate, end: endDate });
// Initialize days
days.forEach(day => {
const key = format(day, 'yyyy-MM-dd');
performanceMap.set(key, {
date: key,
sent: 0,
accepted: 0,
rejected: 0,
pending: 0,
acceptanceRate: 0
});
});
// Aggregate quotes by day and status
quotes.forEach(quote => {
const date = format(new Date(quote.created_at), 'yyyy-MM-dd');
const perf = performanceMap.get(date);
if (perf) {
perf.sent += 1;
switch(quote.status) {
case 'accepted':
perf.accepted += 1;
break;
case 'rejected':
perf.rejected += 1;
break;
case 'pending':
case 'sent':
perf.pending += 1;
break;
}
}
});
// Calculate acceptance rates
performanceMap.forEach(perf => {
if (perf.sent > 0) {
perf.acceptanceRate = (perf.accepted / perf.sent) * 100;
}
});
return Array.from(performanceMap.values());
}
// Get average deal value
export async function getAverageDealValue(
startDate: Date,
endDate: Date
): Promise<{ overall: number; byGPUType: Map<string, number> }> {
const { data: orders } = await supabase
.from('quotes')
.select('total, gpu_type')
.eq('status', 'accepted')
.gte('created_at', startDate.toISOString())
.lte('created_at', endDate.toISOString());
if (!orders || orders.length === 0) {
return { overall: 0, byGPUType: new Map() };
}
const totalRevenue = orders.reduce((sum, order) => sum + order.total, 0);
const overall = totalRevenue / orders.length;
const byGPUType = new Map<string, number>();
const gpuTypeTotals = new Map<string, { total: number; count: number }>();
orders.forEach(order => {
const type = order.gpu_type || 'Unknown';
const existing = gpuTypeTotals.get(type) || { total: 0, count: 0 };
existing.total += order.total;
existing.count += 1;
gpuTypeTotals.set(type, existing);
});
gpuTypeTotals.forEach((value, key) => {
byGPUType.set(key, value.total / value.count);
});
return { overall, byGPUType };
}
// Get key metrics with changes
export async function getKeyMetrics(period: '7d' | '30d' | '90d' | '1y' = '30d'): Promise<MetricCard[]> {
const endDate = new Date();
let startDate: Date;
let previousStartDate: Date;
let previousEndDate: Date;
switch(period) {
case '7d':
startDate = subDays(endDate, 7);
previousEndDate = subDays(endDate, 7);
previousStartDate = subDays(endDate, 14);
break;
case '30d':
startDate = subDays(endDate, 30);
previousEndDate = subDays(endDate, 30);
previousStartDate = subDays(endDate, 60);
break;
case '90d':
startDate = subDays(endDate, 90);
previousEndDate = subDays(endDate, 90);
previousStartDate = subDays(endDate, 180);
break;
case '1y':
startDate = subDays(endDate, 365);
previousEndDate = subDays(endDate, 365);
previousStartDate = subDays(endDate, 730);
break;
}
// Current period metrics
const { data: currentOrders } = await supabase
.from('quotes')
.select('total')
.eq('status', 'accepted')
.gte('created_at', startDate.toISOString())
.lte('created_at', endDate.toISOString());
const { data: currentQuotes } = await supabase
.from('quotes')
.select('id')
.gte('created_at', startDate.toISOString())
.lte('created_at', endDate.toISOString());
const { data: currentRFQs } = await supabase
.from('rfqs')
.select('id')
.gte('created_at', startDate.toISOString())
.lte('created_at', endDate.toISOString());
// Previous period metrics
const { data: previousOrders } = await supabase
.from('quotes')
.select('total')
.eq('status', 'accepted')
.gte('created_at', previousStartDate.toISOString())
.lte('created_at', previousEndDate.toISOString());
const currentRevenue = currentOrders?.reduce((sum, order) => sum + order.total, 0) || 0;
const previousRevenue = previousOrders?.reduce((sum, order) => sum + order.total, 0) || 0;
const revenueChange = previousRevenue > 0 ? ((currentRevenue - previousRevenue) / previousRevenue) * 100 : 0;
const currentOrderCount = currentOrders?.length || 0;
const previousOrderCount = previousOrders?.length || 0;
const orderChange = previousOrderCount > 0 ? ((currentOrderCount - previousOrderCount) / previousOrderCount) * 100 : 0;
const currentQuoteCount = currentQuotes?.length || 0;
const currentRFQCount = currentRFQs?.length || 0;
const conversionRate = currentRFQCount > 0 ? (currentOrderCount / currentRFQCount) * 100 : 0;
const avgDealValue = currentOrderCount > 0 ? currentRevenue / currentOrderCount : 0;
return [
{
title: 'Total Revenue',
value: currentRevenue,
change: revenueChange,
changeType: revenueChange >= 0 ? 'increase' : 'decrease',
prefix: '$'
},
{
title: 'Total Orders',
value: currentOrderCount,
change: orderChange,
changeType: orderChange >= 0 ? 'increase' : 'decrease'
},
{
title: 'Conversion Rate',
value: conversionRate.toFixed(1),
change: 0, // Would need previous conversion rate
changeType: 'neutral',
suffix: '%'
},
{
title: 'Avg Deal Value',
value: avgDealValue,
change: 0, // Would need previous avg
changeType: 'neutral',
prefix: '$'
}
];
}
// Get response time metrics
export async function getResponseTimeMetrics(
startDate: Date,
endDate: Date
): Promise<{ avgResponseTime: number; distribution: Map<string, number> }> {
const { data: rfqs } = await supabase
.from('rfqs')
.select('id, created_at')
.gte('created_at', startDate.toISOString())
.lte('created_at', endDate.toISOString());
const { data: quotes } = await supabase
.from('quotes')
.select('rfq_id, created_at')
.gte('created_at', startDate.toISOString())
.lte('created_at', endDate.toISOString());
if (!rfqs || !quotes) {
return { avgResponseTime: 0, distribution: new Map() };
}
const responseTimes: number[] = [];
const distribution = new Map<string, number>([
['< 1 hour', 0],
['1-6 hours', 0],
['6-24 hours', 0],
['1-3 days', 0],
['> 3 days', 0]
]);
rfqs.forEach(rfq => {
const quote = quotes.find(q => q.rfq_id === rfq.id);
if (quote) {
const rfqTime = new Date(rfq.created_at).getTime();
const quoteTime = new Date(quote.created_at).getTime();
const responseHours = (quoteTime - rfqTime) / (1000 * 60 * 60);
responseTimes.push(responseHours);
if (responseHours < 1) {
distribution.set('< 1 hour', (distribution.get('< 1 hour') || 0) + 1);
} else if (responseHours < 6) {
distribution.set('1-6 hours', (distribution.get('1-6 hours') || 0) + 1);
} else if (responseHours < 24) {
distribution.set('6-24 hours', (distribution.get('6-24 hours') || 0) + 1);
} else if (responseHours < 72) {
distribution.set('1-3 days', (distribution.get('1-3 days') || 0) + 1);
} else {
distribution.set('> 3 days', (distribution.get('> 3 days') || 0) + 1);
}
}
});
const avgResponseTime = responseTimes.length > 0
? responseTimes.reduce((sum, time) => sum + time, 0) / responseTimes.length
: 0;
return { avgResponseTime, distribution };
}