// ── Dashboard ──────────────────────────────────────────────────────────────── // GET /api/dashboard?period=6months&year=2025 // — Returns aggregated stats, trends, monthly breakdown, and recent leads. // // Auth: authenticated; non-admin users scoped to their own leads // // Periods: 7days, 30days, 6months (default), 12months, or a specific year import { NextRequest, NextResponse } from "next/server" import { getSessionUser } from "@/lib/auth" import { query } from "@/lib/db" import { avatarSvgUrl } from "@/lib/avatar" // ── Helpers ────────────────────────────────────────────────────────────────── /** * Returns the { start, end } Date range for a named period. */ function getPeriodDateRange(period: string): { start: Date; end: Date } { const end = new Date() let start: Date switch (period) { case "7days": start = new Date(end); start.setDate(start.getDate() - 7); break case "30days": start = new Date(end); start.setDate(start.getDate() - 30); break case "12months": start = new Date(end); start.setFullYear(start.getFullYear() - 12); break case "6months": default: start = new Date(end); start.setMonth(start.getMonth() - 6); break } return { start, end } } /** * Returns the range for the *previous* period of the same length. */ function getPreviousPeriodRange(period: string, currentStart: Date): { start: Date; end: Date } { const end = new Date(currentStart) const diff = end.getTime() - currentStart.getTime() const start = new Date(end.getTime() - diff) return { start, end } } const periodLabels: Record = { "7days": "Last 7 days", "30days": "Last 30 days", "6months": "Last 6 months", "12months": "Last 12 months", } /** * Maps DB stage name to client-facing status string. */ function stageToStatus(name: string): string { switch (name) { case "New": return "open" case "Contacted": return "contacted" case "Qualified": case "Interested": case "Demo Scheduled": case "Negotiation": return "pending" case "Closed Won": return "closed" case "Closed Lost": return "ignored" default: return "open" } } /** * Computes a trend object from current vs previous count. */ function computeTrend(current: number, previous: number): { pct: number; up: boolean } { if (previous === 0) return { pct: current > 0 ? 100 : 0, up: current > 0 } const pct = Math.round(((current - previous) / previous) * 100) return { pct: Math.abs(pct), up: pct >= 0 } } // Reusable SQL snippet that maps stage names to status strings const stageStatusSql = ` CASE WHEN ls.name = 'New' THEN 'open' WHEN ls.name = 'Contacted' THEN 'contacted' WHEN ls.name IN ('Qualified', 'Interested', 'Demo Scheduled', 'Negotiation') THEN 'pending' WHEN ls.name = 'Closed Won' THEN 'closed' WHEN ls.name = 'Closed Lost' THEN 'ignored' ELSE 'open' END` // ── GET ────────────────────────────────────────────────────────────────────── export async function GET(request: NextRequest) { try { const user = await getSessionUser() if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) const isAdmin = user.role === "admin" || user.role === "super_admin" const { searchParams } = new URL(request.url) const period = searchParams.get("period") || "6months" const yearParam = searchParams.get("year") let start: Date, end: Date, prevRange: { start: Date; end: Date } if (yearParam) { // Year mode: compare full year to previous full year const y = parseInt(yearParam) start = new Date(y, 0, 1) end = new Date(y, 11, 31, 23, 59, 59) prevRange = { start: new Date(y - 1, 0, 1), end: new Date(y - 1, 11, 31, 23, 59, 59) } } else { const r = getPeriodDateRange(period) start = r.start; end = r.end prevRange = getPreviousPeriodRange(period, start) } // Non-admin users only see their own leads const ownerFilter = isAdmin ? "" : "AND l.assigned_to = $3" // ── Status counts for current period (SQL aggregation) ────────── const countSql = ` SELECT ${stageStatusSql} AS status, COUNT(*) AS count FROM leads l JOIN lead_stages ls ON ls.id = l.stage_id WHERE l.deleted_at IS NULL AND l.created_at >= $1 AND l.created_at <= $2 ${ownerFilter} GROUP BY ${stageStatusSql}` const currentCountRows = await query( countSql, isAdmin ? [start.toISOString(), end.toISOString()] : [start.toISOString(), end.toISOString(), user.id], ) const prevCountRows = await query( countSql, isAdmin ? [prevRange.start.toISOString(), prevRange.end.toISOString()] : [prevRange.start.toISOString(), prevRange.end.toISOString(), user.id], ) function rowsToCounts(rows: any[]) { const counts = { open: 0, contacted: 0, pending: 0, closed: 0, ignored: 0 } for (const r of rows) { const s = r.status as keyof typeof counts if (s in counts) counts[s] = parseInt(r.count, 10) } return counts } const currentCounts = rowsToCounts(currentCountRows.rows) const prevCounts = rowsToCounts(prevCountRows.rows) const totalLeads = currentCountRows.rows.reduce((sum: number, r: any) => sum + parseInt(r.count, 10), 0) const prevTotal = prevCountRows.rows.reduce((sum: number, r: any) => sum + parseInt(r.count, 10), 0) const closedLeads = currentCounts.closed const conversionRate = totalLeads > 0 ? Math.round((closedLeads / totalLeads) * 100) : 0 // ── Monthly/weekly breakdown via date_trunc ───────────────────── const isMonthly = period === "6months" || period === "12months" const truncUnit = isMonthly ? "month" : "day" const breakdownSql = ` SELECT DATE_TRUNC($1, l.created_at) AS period_start, ${stageStatusSql} AS status, COUNT(*) AS count FROM leads l JOIN lead_stages ls ON ls.id = l.stage_id WHERE l.deleted_at IS NULL AND l.created_at >= $2 AND l.created_at <= $3 ${isAdmin ? "" : "AND l.assigned_to = $4"} GROUP BY DATE_TRUNC($1, l.created_at), ${stageStatusSql} ORDER BY period_start ASC` const breakdownParams = isAdmin ? [truncUnit, start.toISOString(), end.toISOString()] : [truncUnit, start.toISOString(), end.toISOString(), user.id] const breakdownResult = await query(breakdownSql, breakdownParams) // Aggregate the grouped rows into a label-keyed map const breakdownMap: Record = {} for (const r of breakdownResult.rows) { const d = new Date(r.period_start) const label = isMonthly ? d.toLocaleDateString("en-US", { month: "short", year: "2-digit" }) : d.toLocaleDateString("en-US", { month: "short", day: "numeric" }) if (!breakdownMap[label]) { breakdownMap[label] = { label, total: 0, open: 0, contacted: 0, pending: 0, closed: 0, ignored: 0 } } const count = parseInt(r.count, 10) breakdownMap[label].total += count const statusKey = r.status as 'open' | 'contacted' | 'pending' | 'closed' | 'ignored' breakdownMap[label][statusKey] = count } const monthlyBreakdown = Object.values(breakdownMap) // ── Recent 10 leads ───────────────────────────────────────────── const recentSql = ` SELECT l.id, l.created_at, l.company_name, l.contact_name, l.email, l.phone, l.notes, l.assigned_to, l.score, ls.name AS stage_name, u.id AS user_id, u.first_name, u.last_name, u.email AS user_email, u.avatar_url FROM leads l JOIN lead_stages ls ON ls.id = l.stage_id LEFT JOIN users u ON u.id = l.assigned_to WHERE l.deleted_at IS NULL AND l.created_at >= $1 AND l.created_at <= $2 ${ownerFilter} ORDER BY l.created_at DESC LIMIT 10` const recentResult = await query( recentSql, isAdmin ? [start.toISOString(), end.toISOString()] : [start.toISOString(), end.toISOString(), user.id], ) const recentLeads = recentResult.rows.map((r: any) => ({ id: r.id, companyName: r.company_name || "", contactName: r.contact_name, email: r.email || "", phone: r.phone || "", source: "", description: r.notes || "", status: stageToStatus(r.stage_name), assignedUserId: r.assigned_to, assignedUser: r.assigned_to ? { id: r.user_id, name: `${r.first_name} ${r.last_name}`, email: r.user_email, avatar: avatarSvgUrl(`${r.first_name} ${r.last_name}`), } : null, createdAt: r.created_at, updatedAt: r.updated_at, })) // ── Trends (current vs previous period) ───────────────────────── const trends = { totalLeads: computeTrend(totalLeads, prevTotal), openLeads: computeTrend(currentCounts.open, prevCounts.open), contactedLeads: computeTrend(currentCounts.contacted, prevCounts.contacted), pendingLeads: computeTrend(currentCounts.pending, prevCounts.pending), closedLeads: computeTrend(currentCounts.closed, prevCounts.closed), conversionRate: computeTrend(conversionRate, prevTotal > 0 ? Math.round((prevCounts.closed / prevTotal) * 100) : 0), } const stats = { totalLeads, openLeads: currentCounts.open, contactedLeads: currentCounts.contacted, pendingLeads: currentCounts.pending, closedLeads, ignoredLeads: currentCounts.ignored, conversionRate, monthlyBreakdown, leadsPerMonth: monthlyBreakdown.map((m) => ({ label: m.label, leads: m.total, closed: m.closed })), trends, recentLeads, statusDistribution: [ { name: "Open", value: currentCounts.open, color: "#3b82f6" }, { name: "Contacted", value: currentCounts.contacted, color: "#f59e0b" }, { name: "Pending", value: currentCounts.pending, color: "#8b5cf6" }, { name: "Closed", value: currentCounts.closed, color: "#10b981" }, { name: "Ignored", value: currentCounts.ignored, color: "#6B7280" }, ], periodLabel: periodLabels[period] ?? "Selected period", } return NextResponse.json(stats) } catch (error) { console.error("Dashboard API error:", error) return NextResponse.json({ error: "Failed to load dashboard stats" }, { status: 500 }) } }