209 lines
7.5 KiB
TypeScript
209 lines
7.5 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server"
|
|
import { getSessionUser } from "@/lib/auth"
|
|
import { query } from "@/lib/db"
|
|
import { avatarSvgUrl } from "@/lib/avatar"
|
|
|
|
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 }
|
|
}
|
|
|
|
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<string, string> = {
|
|
"7days": "Last 7 days",
|
|
"30days": "Last 30 days",
|
|
"6months": "Last 6 months",
|
|
"12months": "Last 12 months",
|
|
}
|
|
|
|
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"
|
|
}
|
|
}
|
|
|
|
async function fetchLeadsInRange(start: Date, end: Date) {
|
|
const result = await query(
|
|
`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
|
|
ORDER BY l.created_at DESC`,
|
|
[start.toISOString(), end.toISOString()]
|
|
)
|
|
return result.rows.map((r: any) => ({
|
|
...r,
|
|
status: stageToStatus(r.stage_name),
|
|
}))
|
|
}
|
|
|
|
function countStatuses(leads: any[]) {
|
|
const counts = { open: 0, contacted: 0, pending: 0, closed: 0, ignored: 0 }
|
|
leads.forEach((l: any) => {
|
|
const s = l.status as keyof typeof counts
|
|
if (s in counts) counts[s]++
|
|
})
|
|
return counts
|
|
}
|
|
|
|
function buildMonthlyBreakdown(leads: any[], period: string) {
|
|
const { start, end } = getPeriodDateRange(period)
|
|
const result: { label: string; total: number; open: number; contacted: number; pending: number; closed: number; ignored: number }[] = []
|
|
const current = new Date(start)
|
|
const isMonthly = period === "6months" || period === "12months"
|
|
|
|
while (current <= end) {
|
|
const label = isMonthly
|
|
? current.toLocaleDateString("en-US", { month: "short", year: "2-digit" })
|
|
: current.toLocaleDateString("en-US", { month: "short", day: "numeric" })
|
|
|
|
const ps = new Date(current)
|
|
const pe = isMonthly
|
|
? new Date(current.getFullYear(), current.getMonth() + 1, 0, 23, 59, 59)
|
|
: (() => { const d = new Date(current); d.setHours(23, 59, 59, 999); return d })()
|
|
|
|
const inPeriod = leads.filter((l: any) => {
|
|
const d = new Date(l.created_at)
|
|
return d >= ps && d <= pe
|
|
})
|
|
|
|
const counts = countStatuses(inPeriod)
|
|
result.push({ label, total: inPeriod.length, ...counts })
|
|
|
|
if (isMonthly) current.setMonth(current.getMonth() + 1)
|
|
else current.setDate(current.getDate() + 1)
|
|
}
|
|
return result
|
|
}
|
|
|
|
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 }
|
|
}
|
|
|
|
export async function GET(request: NextRequest) {
|
|
try {
|
|
const user = await getSessionUser()
|
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
|
|
|
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) {
|
|
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)
|
|
}
|
|
|
|
const [currentLeads, prevLeads] = await Promise.all([
|
|
fetchLeadsInRange(start, end),
|
|
fetchLeadsInRange(prevRange.start, prevRange.end),
|
|
])
|
|
|
|
const currentCounts = countStatuses(currentLeads)
|
|
const prevCounts = countStatuses(prevLeads)
|
|
|
|
const totalLeads = currentLeads.length
|
|
const closedLeads = currentCounts.closed
|
|
const conversionRate = totalLeads > 0 ? Math.round((closedLeads / totalLeads) * 100) : 0
|
|
|
|
const mappedLeads = currentLeads.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: r.status,
|
|
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,
|
|
}))
|
|
|
|
const monthlyBreakdown = buildMonthlyBreakdown(currentLeads, period)
|
|
|
|
const trends = {
|
|
totalLeads: computeTrend(currentCounts.open + currentCounts.contacted + currentCounts.pending + currentCounts.closed + currentCounts.ignored,
|
|
prevCounts.open + prevCounts.contacted + prevCounts.pending + prevCounts.closed + prevCounts.ignored),
|
|
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,
|
|
prevLeads.length > 0 ? Math.round((prevCounts.closed / prevLeads.length) * 100) : 0),
|
|
}
|
|
|
|
const stats = {
|
|
totalLeads,
|
|
openLeads: currentCounts.open,
|
|
contactedLeads: currentCounts.contacted,
|
|
pendingLeads: currentCounts.pending,
|
|
closedLeads,
|
|
ignoredLeads: currentCounts.ignored,
|
|
conversionRate,
|
|
monthlyBreakdown,
|
|
leadsPerMonth: monthlyBreakdown.map((m: any) => ({ label: m.label, leads: m.total, closed: m.closed })),
|
|
trends,
|
|
recentLeads: mappedLeads.slice(0, 10),
|
|
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 })
|
|
}
|
|
}
|