d35c806d5b
- Added comprehensive comments and JSDoc-style documentation to NotificationProvider, ThemeProvider, UserProvider, and WebsiteThemeProvider for better clarity and maintainability. - Improved type definitions in index.ts for better code understanding and usage. - Introduced Docker support with Dockerfiles for various services including AI server, signaling server, and browser-use service. - Created a docker-compose.yml file to orchestrate multiple services including PostgreSQL, AI, scraper, and frontend. - Added a startup guide (startup.txt) for setting up the CRM environment on Ubuntu with Docker. - Included a .dockerignore file to exclude unnecessary files from Docker builds.
275 lines
11 KiB
TypeScript
275 lines
11 KiB
TypeScript
// ── 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<string, string> = {
|
|
"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<string, { label: string; total: number; open: number; contacted: number; pending: number; closed: number; ignored: number }> = {}
|
|
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 })
|
|
}
|
|
}
|