From 8c7307d893d33ceb105ab2879206cc8f4af4d310 Mon Sep 17 00:00:00 2001 From: Eron Ng Date: Thu, 22 Jan 2026 23:13:21 +0800 Subject: [PATCH 01/12] feat: create call statistics backend logic --- .../calls/call-statistics.controller.ts | 123 ++++++ .../controllers/calls/calls.controller.ts | 5 + 8cbc_backend/middleware/authorization.ts | 2 + .../model/calls/call-statistics.model.ts | 354 ++++++++++++++++++ 4 files changed, 484 insertions(+) create mode 100644 8cbc_backend/controllers/calls/call-statistics.controller.ts create mode 100644 8cbc_backend/model/calls/call-statistics.model.ts diff --git a/8cbc_backend/controllers/calls/call-statistics.controller.ts b/8cbc_backend/controllers/calls/call-statistics.controller.ts new file mode 100644 index 00000000..0b256140 --- /dev/null +++ b/8cbc_backend/controllers/calls/call-statistics.controller.ts @@ -0,0 +1,123 @@ +import express from "express"; +import { z } from "zod"; +import { + getCallVolumeAnalytics, + getCallResolutionMetrics, + getCallStatisticsSummary, +} from "../../model/calls/call-statistics.model.ts"; +import { ResponseBuilder } from "../../responses/response.builder.ts"; +import { requireAdmin } from "../../middleware/authorization.ts"; + +const CallStatisticsController = () => { + const router = express.Router(); + + // Validation schemas + const dateRangeSchema = z.object({ + startDate: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/, "Invalid start date format (YYYY-MM-DD)") + .optional(), + endDate: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/, "Invalid end date format (YYYY-MM-DD)") + .optional(), + }); + + const volumeAnalyticsQuerySchema = dateRangeSchema.extend({ + period: z.enum(["day", "week", "month"]).optional().default("month"), + category: z.enum([ + "General Inquiry", + "Technical Support", + "Billing", + "Fraud Report", + "Loan Inquiry", + "Other", + ]).optional(), + }); + + const resolutionMetricsQuerySchema = dateRangeSchema.extend({ + category: z.enum([ + "General Inquiry", + "Technical Support", + "Billing", + "Fraud Report", + "Loan Inquiry", + "Other", + ]).optional(), + }); + + /** + * GET /analytics/volume + * Get call volume analytics with trends and breakdowns + * Admin only + */ + router.get( + "/analytics/volume", + requireAdmin, + async (req, res, next) => { + try { + const query = volumeAnalyticsQuerySchema.parse(req.query); + const analytics = await getCallVolumeAnalytics(query); + + return ResponseBuilder.success( + res, + analytics, + "Call volume analytics retrieved successfully" + ); + } catch (error) { + next(error); + } + } + ); + + /** + * GET /analytics/resolution + * Get call resolution metrics with completion/no-show/cancellation rates + * Admin only + */ + router.get( + "/analytics/resolution", + requireAdmin, + async (req, res, next) => { + try { + const query = resolutionMetricsQuerySchema.parse(req.query); + const metrics = await getCallResolutionMetrics(query); + + return ResponseBuilder.success( + res, + metrics, + "Call resolution metrics retrieved successfully" + ); + } catch (error) { + next(error); + } + } + ); + + /** + * GET /analytics/summary + * Get comprehensive call statistics summary (volume + resolution) + */ + router.get( + "/analytics/summary", + requireAdmin, + async (req, res, next) => { + try { + const query = dateRangeSchema.parse(req.query); + const summary = await getCallStatisticsSummary(query); + + return ResponseBuilder.success( + res, + summary, + "Call statistics summary retrieved successfully" + ); + } catch (error) { + next(error); + } + } + ); + + return router; +}; + +export default CallStatisticsController; diff --git a/8cbc_backend/controllers/calls/calls.controller.ts b/8cbc_backend/controllers/calls/calls.controller.ts index 22631b02..2410d494 100644 --- a/8cbc_backend/controllers/calls/calls.controller.ts +++ b/8cbc_backend/controllers/calls/calls.controller.ts @@ -16,6 +16,7 @@ import { getBookingsByUser, updateBookingStatus, } from "../../model/calls/calls.model.ts"; +import CallStatisticsController from "./call-statistics.controller.ts"; import { ResponseBuilder } from "../../responses/response.builder.ts"; import { validate } from "../../middleware/validation.ts"; import { BaseError } from "../../errors/base.error.ts"; @@ -740,6 +741,10 @@ const CallsController = () => { } }, ); + + // Integrate call statistics routes + router.use("/", CallStatisticsController()); + return router; }; diff --git a/8cbc_backend/middleware/authorization.ts b/8cbc_backend/middleware/authorization.ts index 02fe4ab0..3fc5912d 100644 --- a/8cbc_backend/middleware/authorization.ts +++ b/8cbc_backend/middleware/authorization.ts @@ -22,6 +22,8 @@ export const requireRole = (...allowedRoles: UserRole[]) => { export const requireAdminOrSupport = requireRole("administrator", "customer_support"); +export const requireAdmin = requireRole("administrator"); + export const isAdminOrSupport = (userType: string): boolean => { return ["administrator", "customer_support"].includes(userType); }; diff --git a/8cbc_backend/model/calls/call-statistics.model.ts b/8cbc_backend/model/calls/call-statistics.model.ts new file mode 100644 index 00000000..fa019751 --- /dev/null +++ b/8cbc_backend/model/calls/call-statistics.model.ts @@ -0,0 +1,354 @@ +import { db } from "../../database/index.ts"; +import { callBookingsTable } from "../../database/schema.ts"; +import { and, eq, gte, lte, count, sql } from "drizzle-orm"; +import { startOfDay, endOfDay, startOfMonth, endOfMonth, startOfWeek, endOfWeek, subMonths, subWeeks, subDays } from "date-fns"; + +// Types for analytics +export interface CallVolumeAnalytics { + totalCalls: number; + callsByCategory: Record; + peakHours: Array<{ hour: string; count: number }>; + peakDays: Array<{ day: string; count: number }>; + trendData: Array<{ date: string; count: number }>; + currentPeriodTotal: number; + previousPeriodTotal: number; + trendDirection: "up" | "down" | "stable"; + percentageChange: number; +} + +export interface CallResolutionMetrics { + completionRate: number; + noShowRate: number; + cancellationRate: number; + statusDistribution: Record; + averageBookingToCompletionTime: string; + trendData: Array<{ date: string; completionRate: number; noShowRate: number; cancellationRate: number }>; + totalCalls: number; + completedCalls: number; + noShowCalls: number; + cancelledCalls: number; +} + +// Helper function to get date range for period comparison +const getDateRanges = (period: "day" | "week" | "month", date?: string) => { + const currentDate = date ? new Date(date) : new Date(); + let currentStart, currentEnd, previousStart, previousEnd; + + switch (period) { + case "day": + currentStart = startOfDay(currentDate); + currentEnd = endOfDay(currentDate); + previousStart = startOfDay(subDays(currentDate, 1)); + previousEnd = endOfDay(subDays(currentDate, 1)); + break; + case "week": + currentStart = startOfWeek(currentDate); + currentEnd = endOfWeek(currentDate); + previousStart = startOfWeek(subWeeks(currentDate, 1)); + previousEnd = endOfWeek(subWeeks(currentDate, 1)); + break; + case "month": + currentStart = startOfMonth(currentDate); + currentEnd = endOfMonth(currentDate); + previousStart = startOfMonth(subMonths(currentDate, 1)); + previousEnd = endOfMonth(subMonths(currentDate, 1)); + break; + } + + return { currentStart, currentEnd, previousStart, previousEnd }; +}; + +// Get call volume analytics for a date range with optional period comparison +export const getCallVolumeAnalytics = async (options: { + startDate?: string; + endDate?: string; + period?: "day" | "week" | "month"; + category?: string; +}): Promise => { + const startDate = options.startDate ? new Date(options.startDate) : subMonths(new Date(), 1); + const endDate = options.endDate ? new Date(options.endDate) : new Date(); + + const whereConditions: (ReturnType | ReturnType | ReturnType)[] = [ + gte(callBookingsTable.createdAt, startDate), + lte(callBookingsTable.createdAt, endDate), + ]; + + if (options.category) { + whereConditions.push(eq(callBookingsTable.category, options.category as never)); + } + + // Get total calls in current period + const totalCallsResult = await db + .select({ count: count() }) + .from(callBookingsTable) + .where(and(...whereConditions)); + + const totalCalls = totalCallsResult[0]?.count || 0; + + // Get calls by category + const categoryBreakdown = await db + .select({ + category: callBookingsTable.category, + count: count(), + }) + .from(callBookingsTable) + .where(and(...whereConditions)) + .groupBy(callBookingsTable.category); + + const callsByCategory: Record = {}; + categoryBreakdown.forEach(({ category, count: cnt }) => { + callsByCategory[category] = cnt; + }); + + // Get peak hours + const peakHoursData = await db + .select({ + hour: sql`EXTRACT(HOUR FROM ${callBookingsTable.createdAt})::text`, + count: count(), + }) + .from(callBookingsTable) + .where(and(...whereConditions)) + .groupBy(sql`EXTRACT(HOUR FROM ${callBookingsTable.createdAt})`) + .orderBy(sql`count DESC`) + .limit(5); + + const peakHours = peakHoursData.map(({ hour, count: cnt }) => ({ + hour: `${hour.padStart(2, "0")}:00`, + count: cnt, + })); + + // Get peak days + const peakDaysData = await db + .select({ + dayOfWeek: sql`TO_CHAR(${callBookingsTable.createdAt}, 'Day')`, + count: count(), + }) + .from(callBookingsTable) + .where(and(...whereConditions)) + .groupBy(sql`TO_CHAR(${callBookingsTable.createdAt}, 'Day')`) + .orderBy(sql`count DESC`) + .limit(7); + + const peakDays = peakDaysData.map(({ dayOfWeek, count: cnt }) => ({ + day: dayOfWeek.trim(), + count: cnt, + })); + + // Get trend data + const trendDataResults = await db + .select({ + date: sql`DATE(${callBookingsTable.createdAt})`, + count: count(), + }) + .from(callBookingsTable) + .where(and(...whereConditions)) + .groupBy(sql`DATE(${callBookingsTable.createdAt})`) + .orderBy(sql`DATE(${callBookingsTable.createdAt})`); + + const trendData = trendDataResults.map(({ date, count: cnt }) => ({ + date: date, + count: cnt, + })); + + // Get previous period data for comparison + const { previousStart, previousEnd } = getDateRanges( + options.period || "month", + options.endDate + ); + + const previousWhereConditions: (ReturnType | ReturnType | ReturnType)[] = [ + gte(callBookingsTable.createdAt, previousStart), + lte(callBookingsTable.createdAt, previousEnd), + ]; + + if (options.category) { + previousWhereConditions.push(eq(callBookingsTable.category, options.category as never)); + } + + const previousPeriodResult = await db + .select({ count: count() }) + .from(callBookingsTable) + .where(and(...previousWhereConditions)); + + const previousPeriodTotal = previousPeriodResult[0]?.count || 0; + const percentageChange = previousPeriodTotal > 0 + ? ((totalCalls - previousPeriodTotal) / previousPeriodTotal) * 100 + : (totalCalls > 0 ? 100 : 0); + + const trendDirection = percentageChange > 0.5 ? "up" : percentageChange < -0.5 ? "down" : "stable"; + + return { + totalCalls, + callsByCategory, + peakHours, + peakDays, + trendData, + currentPeriodTotal: totalCalls, + previousPeriodTotal, + trendDirection, + percentageChange: Math.round(percentageChange * 100) / 100, + }; +}; + +// Get call resolution metrics +export const getCallResolutionMetrics = async (options: { + startDate?: string; + endDate?: string; + category?: string; +}): Promise => { + const startDate = options.startDate ? new Date(options.startDate) : subMonths(new Date(), 1); + const endDate = options.endDate ? new Date(options.endDate) : new Date(); + + const whereConditions: (ReturnType | ReturnType | ReturnType)[] = [ + gte(callBookingsTable.createdAt, startDate), + lte(callBookingsTable.createdAt, endDate), + ]; + + if (options.category) { + whereConditions.push(eq(callBookingsTable.category, options.category as never)); + } + + // Get total calls + const totalCallsResult = await db + .select({ count: count() }) + .from(callBookingsTable) + .where(and(...whereConditions)); + + const totalCalls = totalCallsResult[0]?.count || 0; + + // Get status distribution + const statusDistributionData = await db + .select({ + status: callBookingsTable.status, + count: count(), + }) + .from(callBookingsTable) + .where(and(...whereConditions)) + .groupBy(callBookingsTable.status); + + const statusDistribution: Record = {}; + let completedCalls = 0; + let noShowCalls = 0; + let cancelledCalls = 0; + + statusDistributionData.forEach(({ status, count: cnt }) => { + statusDistribution[status] = cnt; + if (status === "Completed") completedCalls = cnt; + if (status === "NoShow") noShowCalls = cnt; + if (status === "Cancelled") cancelledCalls = cnt; + }); + + // Calculate rates + const completionRate = totalCalls > 0 ? (completedCalls / totalCalls) * 100 : 0; + const noShowRate = totalCalls > 0 ? (noShowCalls / totalCalls) * 100 : 0; + const cancellationRate = totalCalls > 0 ? (cancelledCalls / totalCalls) * 100 : 0; + + // Get trend data + const trendDataResults = await db + .select({ + date: sql`DATE(${callBookingsTable.createdAt})`, + status: callBookingsTable.status, + count: count(), + }) + .from(callBookingsTable) + .where(and(...whereConditions)) + .groupBy(sql`DATE(${callBookingsTable.createdAt})`, callBookingsTable.status) + .orderBy(sql`DATE(${callBookingsTable.createdAt})`); + + // Aggregate trend data by date and calculate rates + const trendMap: Record = {}; + + trendDataResults.forEach(({ date, status, count: cnt }) => { + if (!trendMap[date]) { + trendMap[date] = { completed: 0, noShow: 0, cancelled: 0, total: 0 }; + } + + if (status === "Completed") trendMap[date].completed = cnt; + if (status === "NoShow") trendMap[date].noShow = cnt; + if (status === "Cancelled") trendMap[date].cancelled = cnt; + + trendMap[date].total += cnt; + }); + + const trendData = Object.entries(trendMap).map(([date, stats]) => ({ + date, + completionRate: stats.total > 0 ? (stats.completed / stats.total) * 100 : 0, + noShowRate: stats.total > 0 ? (stats.noShow / stats.total) * 100 : 0, + cancellationRate: stats.total > 0 ? (stats.cancelled / stats.total) * 100 : 0, + })); + + // Calculate average booking to completion time for completed calls + // Uses updatedAt as the completion timestamp + let averageBookingToCompletionTime = "PT0S"; + + if (completedCalls > 0) { + const completedCallsData = await db + .select({ + createdAt: callBookingsTable.createdAt, + updatedAt: callBookingsTable.updatedAt, + }) + .from(callBookingsTable) + .where( + and( + eq(callBookingsTable.status, "Completed"), + ...whereConditions + ) + ); + + if (completedCallsData.length > 0) { + // Calculate duration for each completed call in seconds + const durations = completedCallsData.map(call => { + const created = new Date(call.createdAt).getTime(); + const updated = new Date(call.updatedAt).getTime(); + return (updated - created) / 1000; + }); + + // Calculate average + const avgSeconds = durations.reduce((a, b) => a + b, 0) / durations.length; + + // Convert to ISO 8601 duration format + const hours = Math.floor(avgSeconds / 3600); + const minutes = Math.floor((avgSeconds % 3600) / 60); + const seconds = Math.floor(avgSeconds % 60); + + let durationStr = "PT"; + if (hours > 0) durationStr += `${hours}H`; + if (minutes > 0) durationStr += `${minutes}M`; + if (seconds > 0 || durationStr === "PT") durationStr += `${seconds}S`; + + averageBookingToCompletionTime = durationStr; + } + } + + return { + completionRate: Math.round(completionRate * 100) / 100, + noShowRate: Math.round(noShowRate * 100) / 100, + cancellationRate: Math.round(cancellationRate * 100) / 100, + statusDistribution, + averageBookingToCompletionTime, + trendData, + totalCalls, + completedCalls, + noShowCalls, + cancelledCalls, + }; +}; + +// Get summary statistics for dashboard +export const getCallStatisticsSummary = async (options?: { + startDate?: string; + endDate?: string; +}): Promise<{ + volumeAnalytics: CallVolumeAnalytics; + resolutionMetrics: CallResolutionMetrics; +}> => { + const [volumeAnalytics, resolutionMetrics] = await Promise.all([ + getCallVolumeAnalytics(options || {}), + getCallResolutionMetrics(options || {}), + ]); + + return { + volumeAnalytics, + resolutionMetrics, + }; +}; From f0ee0dd5fa80df0751b82c06db3ef94c160c3d49 Mon Sep 17 00:00:00 2001 From: Eron Ng Date: Fri, 23 Jan 2026 22:11:14 +0800 Subject: [PATCH 02/12] feat: add call tab to frontend admin dashboard --- 8cbc_frontend/app/(admin)/admin/page.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/8cbc_frontend/app/(admin)/admin/page.tsx b/8cbc_frontend/app/(admin)/admin/page.tsx index 4208f2c2..6e1bd15a 100644 --- a/8cbc_frontend/app/(admin)/admin/page.tsx +++ b/8cbc_frontend/app/(admin)/admin/page.tsx @@ -5,7 +5,7 @@ import {BranchStatus} from "@/components/branches/branch-status"; import {SupportCards} from "@/components/dashboard/admin/SupportCards"; import {SupportBookings} from "@/components/dashboard/admin/SupportBookings"; import {AssignedFraudReports} from "@/components/dashboard/admin/AssignedFraudReports"; -import { HandCoins, FileWarning, ChevronRight } from "lucide-react"; +import { HandCoins, FileWarning, ChevronRight, Phone } from "lucide-react"; import Link from "next/link"; import {useCallback, useEffect, useState} from "react"; import apiClient from "@/lib/api/ApiClient"; @@ -87,6 +87,12 @@ const AdminDashboard = () => { description: "Manage and assign fraud reports to support representatives", href: "/admin/fraud", }, + { + icon: Phone, + title: "View call statistics", + description: "Track and analyse call metrics and performance data", + href: "/admin/calls", + }, ]; return ( From 64e95ab73300841424214fffc653bed0a4d43435 Mon Sep 17 00:00:00 2001 From: Eron Ng Date: Fri, 23 Jan 2026 23:32:30 +0800 Subject: [PATCH 03/12] feat: create frontend UI for viewing call statistics, currently powered through sample data --- .../app/(admin)/admin/calls/page.tsx | 538 ++++++++++++++++++ 8cbc_frontend/lib/api/call-statistics.ts | 85 +++ 2 files changed, 623 insertions(+) create mode 100644 8cbc_frontend/app/(admin)/admin/calls/page.tsx create mode 100644 8cbc_frontend/lib/api/call-statistics.ts diff --git a/8cbc_frontend/app/(admin)/admin/calls/page.tsx b/8cbc_frontend/app/(admin)/admin/calls/page.tsx new file mode 100644 index 00000000..18d5da21 --- /dev/null +++ b/8cbc_frontend/app/(admin)/admin/calls/page.tsx @@ -0,0 +1,538 @@ +"use client"; + +import type React from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import Link from "next/link"; +import { + Bar, + BarChart, + CartesianGrid, + Line, + LineChart, + Pie, + PieChart, + XAxis, + YAxis, + Cell, +} from "recharts"; +import { + ChartContainer, + ChartTooltip, + ChartTooltipContent, + type ChartConfig, +} from "@/components/ui/chart"; +import { useAuth } from "@/contexts/AuthContext"; +import { isAdmin } from "@/types/auth.types"; +import { ArrowDownRight, ArrowLeft, ArrowUpRight, ChevronDown, Clock3, Phone } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { monthlyMockData } from "./data/mock-call-stats"; + + +const CATEGORY_COLORS: Record = { + "General Inquiry": "#0ea5e9", + "Technical Support": "#7c3aed", + Billing: "#f59e0b", + "Fraud Report": "#ef4444", + "Loan Inquiry": "#10b981", + Other: "#64748b", +}; + +const STATUS_COLORS: Record = { + Completed: "#0ea5e9", + Pending: "#f97316", + NoShow: "#ef4444", + Cancelled: "#94a3b8", +}; + +const numberFormatter = new Intl.NumberFormat("en-US"); + +const formatDateLabel = (date: string) => { + const d = new Date(date); + return d.toLocaleDateString("en-US", { month: "short", day: "numeric" }); +}; + +const formatDuration = (iso: string) => { + const match = iso.match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/); + if (!match) return iso; + const [, h, m, s] = match; + const parts = [] as string[]; + if (h) parts.push(`${h}h`); + if (m) parts.push(`${m}m`); + if (s) parts.push(`${s}s`); + return parts.join(" ") || "0m"; +}; + +const MonthFilterDropdown = ({ + value, + onChange, + options, + id, +}: { + value: string; + onChange: (value: string) => void; + options: string[]; + id: string; +}) => { + const [open, setOpen] = useState(false); + const ref = useRef(null); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (ref.current && !ref.current.contains(event.target as Node)) { + setOpen(false); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, []); + + return ( +
+ + {open ? ( +
+ {options.map((option) => ( + + ))} +
+ ) : null} +
+ ); +}; + +const StatCard = ({ + title, + value, + hint, + icon: Icon, + accent, +}: { + title: string; + value: string; + hint?: React.ReactNode; + icon?: React.ComponentType>; + accent?: "primary" | "muted"; +}) => { + return ( +
+ {Icon ? ( +
+ +
+ ) : null} +
+ {title} + {value} + {hint ? {hint} : null} +
+
+ ); +}; + +const TrendChip = ({ direction, change }: { direction: "up" | "down" | "stable"; change: number }) => { + const isUp = direction === "up"; + const Icon = isUp ? ArrowUpRight : ArrowDownRight; + + if (direction === "stable") { + return ( + + Stable vs previous period + + ); + } + + return ( + + + {Math.abs(change).toFixed(1)}% {isUp ? "higher" : "lower"} vs previous period + + ); +}; + +const ProgressBar = ({ value, color }: { value: number; color: string }) => { + return ( +
+
+
+ ); +}; + +const StatusLegend = ({ data }: { data: { name: string; value: number; fill: string }[] }) => ( +
+ {data.map((item) => ( +
+ + {item.name} + {numberFormatter.format(item.value)} +
+ ))} +
+); + +export default function AdminCallStatisticsPage() { + const { user } = useAuth(); + const monthOptions = useMemo(() => Object.keys(monthlyMockData), []); + const [selectedMonth, setSelectedMonth] = useState(monthOptions[0]); + const summary = monthlyMockData[selectedMonth]; + + const [categorySortOrder, setCategorySortOrder] = useState<"asc" | "desc">("desc"); + const [peakSortOrder, setPeakSortOrder] = useState<"asc" | "desc">("desc"); + const [activeCategory, setActiveCategory] = useState<{ name: string; value: number } | null>(null); + + const categoryChartData = useMemo(() => { + const data = Object.entries(summary.volumeAnalytics.callsByCategory).map(([name, value]) => ({ + name, + value, + fill: CATEGORY_COLORS[name] || "#0ea5e9", + })); + return data.sort((a, b) => (categorySortOrder === "asc" ? a.value - b.value : b.value - a.value)); + }, [summary.volumeAnalytics.callsByCategory, categorySortOrder]); + + const statusChartData = useMemo( + () => + Object.entries(summary.resolutionMetrics.statusDistribution).map(([name, value]) => ({ + name, + value, + fill: STATUS_COLORS[name] || "#0ea5e9", + })), + [summary.resolutionMetrics.statusDistribution], + ); + + const trendChartData = useMemo( + () => + summary.volumeAnalytics.trendData.map((item) => ({ + ...item, + label: formatDateLabel(item.date), + })), + [summary.volumeAnalytics.trendData], + ); + + const completionTrendData = useMemo( + () => + summary.resolutionMetrics.trendData.map((item) => ({ + ...item, + label: formatDateLabel(item.date), + })), + [summary.resolutionMetrics.trendData], + ); + + const completionTicks = useMemo(() => { + const labels = completionTrendData.map((item) => item.label); + return labels.filter((_, idx) => idx % 2 === 0 || idx === labels.length - 1); + }, [completionTrendData]); + + const categoryChartConfig = useMemo(() => { + return categoryChartData.reduce((acc, item) => { + acc[item.name] = { label: item.name, color: item.fill }; + return acc; + }, {} as ChartConfig); + }, [categoryChartData]); + + const statusChartConfig = useMemo(() => { + return statusChartData.reduce((acc, item) => { + acc[item.name] = { label: item.name, color: item.fill }; + return acc; + }, {} as ChartConfig); + }, [statusChartData]); + + const trendChartConfig: ChartConfig = { + calls: { + label: "Calls", + color: "#0ea5e9", + }, + }; + + const completionChartConfig: ChartConfig = { + completionRate: { label: "Completion", color: "#0ea5e9" }, + noShowRate: { label: "No-shows", color: "#ef4444" }, + cancellationRate: { label: "Cancelled", color: "#94a3b8" }, + }; + + const totalCalls = summary.volumeAnalytics.totalCalls; + const avgTime = formatDuration(summary.resolutionMetrics.averageBookingToCompletionTime); + + if (!user || !isAdmin(user.userType)) { + return ( +
+
+

Admins only

+

You need administrator access to view call statistics.

+
+
+ ); + } + + return ( +
+
+
+ + + +
+

Call statistics

+

Review and manage call analytics

+
+
+
+ +
+ + as unknown as string} + /> + + +
+ +
+
+
+
+

Total call volume

+

+ {numberFormatter.format(totalCalls)} + +

+
+
+ + +
+
+ + + + + + + } /> + + +
+ +
+
+
+

Status distribution

+

By latest status

+
+
+
+ + + + {statusChartData.map((entry) => ( + + ))} + + } /> + + + +
+
+
+ +
+
+
+
+

Calls by category

+

Distribution

+
+ +
+ + setActiveCategory(null)} + > + + + + + {categoryChartData.map((entry) => { + const muted = activeCategory && activeCategory.name !== entry.name; + return ( + setActiveCategory({ name: entry.name, value: entry.value })} + onMouseLeave={() => setActiveCategory(null)} + /> + ); + })} + + [value as number, " Calls"]} + labelFormatter={(label) => label as string} + /> + } + /> + + +
+ +
+
+
+

Peak scheduling windows

+

Hours & days

+
+ +
+
+
+

Peak hours

+
+ {([...summary.volumeAnalytics.peakHours] + .sort((a, b) => (peakSortOrder === "asc" ? a.count - b.count : b.count - a.count)) + ).map((item) => ( +
+ {item.hour} +
+ {item.count} +
+ ))} +
+
+
+

Peak days

+
+ {([...summary.volumeAnalytics.peakDays] + .sort((a, b) => (peakSortOrder === "asc" ? a.count - b.count : b.count - a.count)) + ).map((item) => ( +
+ {item.day} +
+ {item.count} +
+ ))} +
+
+
+
+
+ +
+
+
+

Outcome trends

+

Completion vs no-show vs cancellation

+
+
+ + +
+
+ + + + + `${v}%`} /> + + + + } /> + + +
+
+ ); +} diff --git a/8cbc_frontend/lib/api/call-statistics.ts b/8cbc_frontend/lib/api/call-statistics.ts new file mode 100644 index 00000000..51793e65 --- /dev/null +++ b/8cbc_frontend/lib/api/call-statistics.ts @@ -0,0 +1,85 @@ +import apiClient from "./ApiClient"; + +// Types for call statistics +export interface CallVolumeAnalytics { + totalCalls: number; + callsByCategory: Record; + peakHours: Array<{ hour: string; count: number }>; + peakDays: Array<{ day: string; count: number }>; + trendData: Array<{ date: string; count: number }>; + currentPeriodTotal: number; + previousPeriodTotal: number; + trendDirection: "up" | "down" | "stable"; + percentageChange: number; +} + +export interface CallResolutionMetrics { + completionRate: number; + noShowRate: number; + cancellationRate: number; + statusDistribution: Record; + averageBookingToCompletionTime: string; + trendData: Array<{ + date: string; + completionRate: number; + noShowRate: number; + cancellationRate: number; + }>; + totalCalls: number; + completedCalls: number; + noShowCalls: number; + cancelledCalls: number; +} + +export interface CallStatisticsSummary { + volumeAnalytics: CallVolumeAnalytics; + resolutionMetrics: CallResolutionMetrics; +} + +export interface CallStatisticsOptions { + startDate?: string; + endDate?: string; + period?: "day" | "week" | "month"; + category?: string; +} + +export const callStatisticsApi = { + /** + * Get call volume analytics with trends and breakdowns + */ + getVolumeAnalytics: async ( + options?: CallStatisticsOptions + ): Promise => { + const response = await apiClient.get<{ data: CallVolumeAnalytics }>( + "/api/calls/analytics/volume", + { params: options } + ); + return response.data.data; + }, + + /** + * Get call resolution metrics with completion/no-show/cancellation rates + */ + getResolutionMetrics: async ( + options?: CallStatisticsOptions + ): Promise => { + const response = await apiClient.get<{ data: CallResolutionMetrics }>( + "/api/calls/analytics/resolution", + { params: options } + ); + return response.data.data; + }, + + /** + * Get comprehensive call statistics summary (volume + resolution) + */ + getStatisticsSummary: async ( + options?: CallStatisticsOptions + ): Promise => { + const response = await apiClient.get<{ data: CallStatisticsSummary }>( + "/api/calls/analytics/summary", + { params: options } + ); + return response.data.data; + }, +}; From 7f9481508dc0ed8561330e52599cfb3d470a4f8b Mon Sep 17 00:00:00 2001 From: Eron Ng Date: Sun, 25 Jan 2026 23:49:10 +0800 Subject: [PATCH 04/12] feat: frontend now displays statistics from the backend --- .../model/calls/call-statistics.model.ts | 55 ++++-- .../app/(admin)/admin/calls/page.tsx | 173 ++++++++++++++---- 2 files changed, 176 insertions(+), 52 deletions(-) diff --git a/8cbc_backend/model/calls/call-statistics.model.ts b/8cbc_backend/model/calls/call-statistics.model.ts index fa019751..c5b4d4a5 100644 --- a/8cbc_backend/model/calls/call-statistics.model.ts +++ b/8cbc_backend/model/calls/call-statistics.model.ts @@ -282,26 +282,55 @@ export const getCallResolutionMetrics = async (options: { let averageBookingToCompletionTime = "PT0S"; if (completedCalls > 0) { + // Only include bookings marked Completed whose creation falls inside the window + const completionWindowConditions: (ReturnType | ReturnType | ReturnType)[] = [ + eq(callBookingsTable.status, "Completed"), + gte(callBookingsTable.createdAt, startDate), + lte(callBookingsTable.createdAt, endDate), + ]; + + if (options.category) { + completionWindowConditions.push(eq(callBookingsTable.category, options.category as never)); + } const completedCallsData = await db .select({ createdAt: callBookingsTable.createdAt, - updatedAt: callBookingsTable.updatedAt, + completionAt: sql`( + SELECT MIN(h.created_at) + FROM call_booking_history AS h + WHERE h.booking_id = ${callBookingsTable.id} + AND h.change_type = 'status_change' + AND h.new_value = 'Completed' + )`, }) .from(callBookingsTable) - .where( - and( - eq(callBookingsTable.status, "Completed"), - ...whereConditions - ) - ); + .where(and(...completionWindowConditions)); if (completedCallsData.length > 0) { - // Calculate duration for each completed call in seconds - const durations = completedCallsData.map(call => { - const created = new Date(call.createdAt).getTime(); - const updated = new Date(call.updatedAt).getTime(); - return (updated - created) / 1000; - }); + const durations = completedCallsData + .map(call => { + if (!call.completionAt) return NaN; + const created = new Date(call.createdAt).getTime(); + const completed = new Date(call.completionAt).getTime(); + return completed - created; + }) + .filter((ms) => Number.isFinite(ms) && ms >= 0) + .map((ms) => ms / 1000); + + if (durations.length === 0) { + return { + completionRate: Math.round(completionRate * 100) / 100, + noShowRate: Math.round(noShowRate * 100) / 100, + cancellationRate: Math.round(cancellationRate * 100) / 100, + statusDistribution, + averageBookingToCompletionTime, + trendData, + totalCalls, + completedCalls, + noShowCalls, + cancelledCalls, + }; + } // Calculate average const avgSeconds = durations.reduce((a, b) => a + b, 0) / durations.length; diff --git a/8cbc_frontend/app/(admin)/admin/calls/page.tsx b/8cbc_frontend/app/(admin)/admin/calls/page.tsx index 18d5da21..6223a443 100644 --- a/8cbc_frontend/app/(admin)/admin/calls/page.tsx +++ b/8cbc_frontend/app/(admin)/admin/calls/page.tsx @@ -25,7 +25,7 @@ import { useAuth } from "@/contexts/AuthContext"; import { isAdmin } from "@/types/auth.types"; import { ArrowDownRight, ArrowLeft, ArrowUpRight, ChevronDown, Clock3, Phone } from "lucide-react"; import { cn } from "@/lib/utils"; -import { monthlyMockData } from "./data/mock-call-stats"; +import { callStatisticsApi, type CallStatisticsSummary } from "@/lib/api/call-statistics"; const CATEGORY_COLORS: Record = { @@ -51,6 +51,11 @@ const formatDateLabel = (date: string) => { return d.toLocaleDateString("en-US", { month: "short", day: "numeric" }); }; +const formatMonthLabel = (date: string) => { + const d = new Date(date); + return d.toLocaleDateString("en-US", { month: "short", year: "numeric" }); +}; + const formatDuration = (iso: string) => { const match = iso.match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/); if (!match) return iso; @@ -213,50 +218,106 @@ const StatusLegend = ({ data }: { data: { name: string; value: number; fill: str export default function AdminCallStatisticsPage() { const { user } = useAuth(); - const monthOptions = useMemo(() => Object.keys(monthlyMockData), []); - const [selectedMonth, setSelectedMonth] = useState(monthOptions[0]); - const summary = monthlyMockData[selectedMonth]; - + const [summary, setSummary] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [selectedMonth, setSelectedMonth] = useState("All"); const [categorySortOrder, setCategorySortOrder] = useState<"asc" | "desc">("desc"); const [peakSortOrder, setPeakSortOrder] = useState<"asc" | "desc">("desc"); const [activeCategory, setActiveCategory] = useState<{ name: string; value: number } | null>(null); + useEffect(() => { + let cancelled = false; + + if (!user || !isAdmin(user.userType)) { + setSummary(null); + setLoading(false); + setError(null); + return () => { + cancelled = true; + }; + } + + const load = async () => { + setLoading(true); + try { + const data = await callStatisticsApi.getStatisticsSummary(); + if (cancelled) return; + setSummary(data); + setError(null); + } catch (err) { + if (cancelled) return; + setError("Failed to load call statistics. Please try again."); + setSummary(null); + } finally { + if (!cancelled) setLoading(false); + } + }; + + load(); + + return () => { + cancelled = true; + }; + }, [user]); + + const monthOptions = useMemo(() => { + if (!summary) return ["All"]; + const labels = Array.from( + new Set(summary.volumeAnalytics.trendData.map((item) => formatMonthLabel(item.date))) + ); + return labels.length ? labels : ["All"]; + }, [summary]); + + useEffect(() => { + if (monthOptions.length && !monthOptions.includes(selectedMonth)) { + setSelectedMonth(monthOptions[0]); + } + }, [monthOptions, selectedMonth]); + + const filterByMonth = (date: string) => selectedMonth === "All" || formatMonthLabel(date) === selectedMonth; + + const volumeAnalytics = summary?.volumeAnalytics; + const resolutionMetrics = summary?.resolutionMetrics; + const categoryChartData = useMemo(() => { - const data = Object.entries(summary.volumeAnalytics.callsByCategory).map(([name, value]) => ({ + if (!volumeAnalytics) return []; + const data = Object.entries(volumeAnalytics.callsByCategory).map(([name, value]) => ({ name, value, fill: CATEGORY_COLORS[name] || "#0ea5e9", })); return data.sort((a, b) => (categorySortOrder === "asc" ? a.value - b.value : b.value - a.value)); - }, [summary.volumeAnalytics.callsByCategory, categorySortOrder]); - - const statusChartData = useMemo( - () => - Object.entries(summary.resolutionMetrics.statusDistribution).map(([name, value]) => ({ - name, - value, - fill: STATUS_COLORS[name] || "#0ea5e9", - })), - [summary.resolutionMetrics.statusDistribution], - ); + }, [volumeAnalytics, categorySortOrder]); + + const statusChartData = useMemo(() => { + if (!resolutionMetrics) return []; + return Object.entries(resolutionMetrics.statusDistribution).map(([name, value]) => ({ + name, + value, + fill: STATUS_COLORS[name] || "#0ea5e9", + })); + }, [resolutionMetrics]); - const trendChartData = useMemo( - () => - summary.volumeAnalytics.trendData.map((item) => ({ + const trendChartData = useMemo(() => { + if (!volumeAnalytics) return []; + return volumeAnalytics.trendData + .filter((item) => filterByMonth(item.date)) + .map((item) => ({ ...item, label: formatDateLabel(item.date), - })), - [summary.volumeAnalytics.trendData], - ); - - const completionTrendData = useMemo( - () => - summary.resolutionMetrics.trendData.map((item) => ({ + })); + }, [volumeAnalytics, selectedMonth]); + + const completionTrendData = useMemo(() => { + if (!resolutionMetrics) return []; + return resolutionMetrics.trendData + .filter((item) => filterByMonth(item.date)) + .map((item) => ({ ...item, label: formatDateLabel(item.date), - })), - [summary.resolutionMetrics.trendData], - ); + })); + }, [resolutionMetrics, selectedMonth]); const completionTicks = useMemo(() => { const labels = completionTrendData.map((item) => item.label); @@ -289,9 +350,10 @@ export default function AdminCallStatisticsPage() { noShowRate: { label: "No-shows", color: "#ef4444" }, cancellationRate: { label: "Cancelled", color: "#94a3b8" }, }; - - const totalCalls = summary.volumeAnalytics.totalCalls; - const avgTime = formatDuration(summary.resolutionMetrics.averageBookingToCompletionTime); + const totalCalls = volumeAnalytics?.totalCalls ?? 0; + const avgTime = resolutionMetrics?.averageBookingToCompletionTime + ? formatDuration(resolutionMetrics.averageBookingToCompletionTime) + : "β€”"; if (!user || !isAdmin(user.userType)) { return ( @@ -304,6 +366,39 @@ export default function AdminCallStatisticsPage() { ); } + if (loading) { + return ( +
+
+

Loading call statistics…

+

Fetching the latest analytics.

+
+
+ ); + } + + if (error) { + return ( +
+
+

Unable to load statistics

+

{error}

+
+
+ ); + } + + if (!summary || !volumeAnalytics || !resolutionMetrics) { + return ( +
+
+

No statistics available

+

There are no call analytics to display yet.

+
+
+ ); + } + return (
@@ -326,10 +421,10 @@ export default function AdminCallStatisticsPage() { as unknown as string} + value={volumeAnalytics.trendDirection === "up" ? "Up" : volumeAnalytics.trendDirection === "down" ? "Down" : "Stable"} + hint={ as unknown as string} /> - +
@@ -340,7 +435,7 @@ export default function AdminCallStatisticsPage() {

Total call volume

{numberFormatter.format(totalCalls)} - +

@@ -467,7 +562,7 @@ export default function AdminCallStatisticsPage() {

Peak hours

- {([...summary.volumeAnalytics.peakHours] + {([...(volumeAnalytics?.peakHours ?? [])] .sort((a, b) => (peakSortOrder === "asc" ? a.count - b.count : b.count - a.count)) ).map((item) => (
@@ -481,7 +576,7 @@ export default function AdminCallStatisticsPage() {

Peak days

- {([...summary.volumeAnalytics.peakDays] + {([...(volumeAnalytics?.peakDays ?? [])] .sort((a, b) => (peakSortOrder === "asc" ? a.count - b.count : b.count - a.count)) ).map((item) => (
From 1548a1d5462728d5f115d60d3a44c9f651c625cb Mon Sep 17 00:00:00 2001 From: Eron Ng Date: Sun, 25 Jan 2026 23:51:49 +0800 Subject: [PATCH 05/12] feat: minor edit --- 8cbc_frontend/app/(admin)/admin/calls/page.tsx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/8cbc_frontend/app/(admin)/admin/calls/page.tsx b/8cbc_frontend/app/(admin)/admin/calls/page.tsx index 6223a443..39d6eb9c 100644 --- a/8cbc_frontend/app/(admin)/admin/calls/page.tsx +++ b/8cbc_frontend/app/(admin)/admin/calls/page.tsx @@ -245,7 +245,7 @@ export default function AdminCallStatisticsPage() { if (cancelled) return; setSummary(data); setError(null); - } catch (err) { + } catch { if (cancelled) return; setError("Failed to load call statistics. Please try again."); setSummary(null); @@ -275,8 +275,6 @@ export default function AdminCallStatisticsPage() { } }, [monthOptions, selectedMonth]); - const filterByMonth = (date: string) => selectedMonth === "All" || formatMonthLabel(date) === selectedMonth; - const volumeAnalytics = summary?.volumeAnalytics; const resolutionMetrics = summary?.resolutionMetrics; @@ -302,7 +300,7 @@ export default function AdminCallStatisticsPage() { const trendChartData = useMemo(() => { if (!volumeAnalytics) return []; return volumeAnalytics.trendData - .filter((item) => filterByMonth(item.date)) + .filter((item) => selectedMonth === "All" || formatMonthLabel(item.date) === selectedMonth) .map((item) => ({ ...item, label: formatDateLabel(item.date), @@ -312,7 +310,7 @@ export default function AdminCallStatisticsPage() { const completionTrendData = useMemo(() => { if (!resolutionMetrics) return []; return resolutionMetrics.trendData - .filter((item) => filterByMonth(item.date)) + .filter((item) => selectedMonth === "All" || formatMonthLabel(item.date) === selectedMonth) .map((item) => ({ ...item, label: formatDateLabel(item.date), From aa7a0addaaa0388f3238201d758a7d5b81841fe4 Mon Sep 17 00:00:00 2001 From: Eron Ng Date: Sun, 25 Jan 2026 23:52:33 +0800 Subject: [PATCH 06/12] feat: update admin page --- 8cbc_frontend/app/(admin)/admin/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/8cbc_frontend/app/(admin)/admin/page.tsx b/8cbc_frontend/app/(admin)/admin/page.tsx index 6e1bd15a..41363562 100644 --- a/8cbc_frontend/app/(admin)/admin/page.tsx +++ b/8cbc_frontend/app/(admin)/admin/page.tsx @@ -169,4 +169,4 @@ const AdminDashboard = () => { ); }; -export default AdminDashboard; +export default AdminDashboard; \ No newline at end of file From 09224ceb9b8b3405ce8a1a32ff4158cedbb86c1d Mon Sep 17 00:00:00 2001 From: Eron Ng Date: Mon, 26 Jan 2026 20:33:49 +0800 Subject: [PATCH 07/12] feat: update logic when displaying fraud reports --- 8cbc_backend/middleware/authorization.ts | 2 -- 8cbc_frontend/app/(admin)/admin/page.tsx | 10 +++++---- .../dashboard/admin/AssignedFraudReports.tsx | 22 ++++++++++--------- 3 files changed, 18 insertions(+), 16 deletions(-) diff --git a/8cbc_backend/middleware/authorization.ts b/8cbc_backend/middleware/authorization.ts index 5c742123..fff53f37 100644 --- a/8cbc_backend/middleware/authorization.ts +++ b/8cbc_backend/middleware/authorization.ts @@ -27,8 +27,6 @@ export const requireAdminOrSupport = requireRole( "customer_support", ); -export const requireAdmin = requireRole("administrator"); - export const isAdminOrSupport = (userType: string): boolean => { return ["administrator", "customer_support"].includes(userType); }; diff --git a/8cbc_frontend/app/(admin)/admin/page.tsx b/8cbc_frontend/app/(admin)/admin/page.tsx index 79638945..af55e28c 100644 --- a/8cbc_frontend/app/(admin)/admin/page.tsx +++ b/8cbc_frontend/app/(admin)/admin/page.tsx @@ -223,10 +223,12 @@ const AdminDashboard = () => { )} - + {!isAdminUser && ( + + )}
diff --git a/8cbc_frontend/components/dashboard/admin/AssignedFraudReports.tsx b/8cbc_frontend/components/dashboard/admin/AssignedFraudReports.tsx index b4890f76..31d29545 100644 --- a/8cbc_frontend/components/dashboard/admin/AssignedFraudReports.tsx +++ b/8cbc_frontend/components/dashboard/admin/AssignedFraudReports.tsx @@ -49,22 +49,24 @@ export const AssignedFraudReports = ({ - - All - UnderReview - Resolved - Dismissed - - +
+ + All + Under Review + Resolved + Dismissed + +
+ - + - + - +
From d06e484d76c2b088af9e95e14b89fbeaf38721b2 Mon Sep 17 00:00:00 2001 From: Eron Ng Date: Mon, 26 Jan 2026 22:28:33 +0800 Subject: [PATCH 08/12] feat: complete call statistics --- .../model/calls/call-statistics.model.ts | 13 +- .../{calls => calls-statistics}/page.tsx | 362 ++++++++++++++---- 8cbc_frontend/app/(admin)/admin/page.tsx | 7 + 3 files changed, 305 insertions(+), 77 deletions(-) rename 8cbc_frontend/app/(admin)/admin/{calls => calls-statistics}/page.tsx (63%) diff --git a/8cbc_backend/model/calls/call-statistics.model.ts b/8cbc_backend/model/calls/call-statistics.model.ts index c5b4d4a5..39cd0251 100644 --- a/8cbc_backend/model/calls/call-statistics.model.ts +++ b/8cbc_backend/model/calls/call-statistics.model.ts @@ -65,7 +65,7 @@ export const getCallVolumeAnalytics = async (options: { period?: "day" | "week" | "month"; category?: string; }): Promise => { - const startDate = options.startDate ? new Date(options.startDate) : subMonths(new Date(), 1); + const startDate = options.startDate ? new Date(options.startDate) : subMonths(new Date(), 3); const endDate = options.endDate ? new Date(options.endDate) : new Date(); const whereConditions: (ReturnType | ReturnType | ReturnType)[] = [ @@ -196,7 +196,7 @@ export const getCallResolutionMetrics = async (options: { endDate?: string; category?: string; }): Promise => { - const startDate = options.startDate ? new Date(options.startDate) : subMonths(new Date(), 1); + const startDate = options.startDate ? new Date(options.startDate) : subMonths(new Date(), 3); const endDate = options.endDate ? new Date(options.endDate) : new Date(); const whereConditions: (ReturnType | ReturnType | ReturnType)[] = [ @@ -278,7 +278,7 @@ export const getCallResolutionMetrics = async (options: { })); // Calculate average booking to completion time for completed calls - // Uses updatedAt as the completion timestamp + // Uses updatedAt as the completion timestamp (fallback if no history exists) let averageBookingToCompletionTime = "PT0S"; if (completedCalls > 0) { @@ -295,6 +295,7 @@ export const getCallResolutionMetrics = async (options: { const completedCallsData = await db .select({ createdAt: callBookingsTable.createdAt, + updatedAt: callBookingsTable.updatedAt, completionAt: sql`( SELECT MIN(h.created_at) FROM call_booking_history AS h @@ -309,9 +310,11 @@ export const getCallResolutionMetrics = async (options: { if (completedCallsData.length > 0) { const durations = completedCallsData .map(call => { - if (!call.completionAt) return NaN; + // Use history timestamp if available, otherwise use updatedAt + const completionTimestamp = call.completionAt || call.updatedAt; + if (!completionTimestamp) return NaN; const created = new Date(call.createdAt).getTime(); - const completed = new Date(call.completionAt).getTime(); + const completed = new Date(completionTimestamp).getTime(); return completed - created; }) .filter((ms) => Number.isFinite(ms) && ms >= 0) diff --git a/8cbc_frontend/app/(admin)/admin/calls/page.tsx b/8cbc_frontend/app/(admin)/admin/calls-statistics/page.tsx similarity index 63% rename from 8cbc_frontend/app/(admin)/admin/calls/page.tsx rename to 8cbc_frontend/app/(admin)/admin/calls-statistics/page.tsx index 39d6eb9c..61624dfc 100644 --- a/8cbc_frontend/app/(admin)/admin/calls/page.tsx +++ b/8cbc_frontend/app/(admin)/admin/calls-statistics/page.tsx @@ -14,6 +14,7 @@ import { XAxis, YAxis, Cell, + Legend, } from "recharts"; import { ChartContainer, @@ -38,10 +39,12 @@ const CATEGORY_COLORS: Record = { }; const STATUS_COLORS: Record = { - Completed: "#0ea5e9", + Completed: "#10b981", Pending: "#f97316", NoShow: "#ef4444", Cancelled: "#94a3b8", + Confirmed: "#0ea5e9", + Processing: "#8b5cf6", }; const numberFormatter = new Intl.NumberFormat("en-US"); @@ -60,11 +63,28 @@ const formatDuration = (iso: string) => { const match = iso.match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/); if (!match) return iso; const [, h, m, s] = match; - const parts = [] as string[]; - if (h) parts.push(`${h}h`); - if (m) parts.push(`${m}m`); - if (s) parts.push(`${s}s`); - return parts.join(" ") || "0m"; + + const hours = parseInt(h || "0", 10); + const minutes = parseInt(m || "0", 10); + const seconds = parseInt(s || "0", 10); + + const totalHours = hours + minutes / 60 + seconds / 3600; + + if (totalHours >= 24) { + const days = totalHours / 24; + return `${days.toFixed(1)}d`; + } + + if (totalHours >= 1) { + return `${totalHours.toFixed(1)}h`; + } + + const totalMinutes = minutes + seconds / 60; + if (totalMinutes >= 1) { + return `${totalMinutes.toFixed(0)}m`; + } + + return `${seconds}s`; }; const MonthFilterDropdown = ({ @@ -266,7 +286,7 @@ export default function AdminCallStatisticsPage() { const labels = Array.from( new Set(summary.volumeAnalytics.trendData.map((item) => formatMonthLabel(item.date))) ); - return labels.length ? labels : ["All"]; + return ["All", ...(labels.length ? labels : [])]; }, [summary]); useEffect(() => { @@ -278,45 +298,174 @@ export default function AdminCallStatisticsPage() { const volumeAnalytics = summary?.volumeAnalytics; const resolutionMetrics = summary?.resolutionMetrics; - const categoryChartData = useMemo(() => { + const trendChartData = useMemo(() => { if (!volumeAnalytics) return []; - const data = Object.entries(volumeAnalytics.callsByCategory).map(([name, value]) => ({ + return volumeAnalytics.trendData + .filter((item) => selectedMonth === "All" || formatMonthLabel(item.date) === selectedMonth) + .map((item) => ({ + ...item, + label: formatDateLabel(item.date), + })); + }, [volumeAnalytics, selectedMonth]); + + const filteredCategoryDistribution = useMemo(() => { + if (!volumeAnalytics) return {}; + if (selectedMonth === "All") return volumeAnalytics.callsByCategory; + + const filteredTrendData = volumeAnalytics.trendData.filter( + (item) => formatMonthLabel(item.date) === selectedMonth + ); + + if (filteredTrendData.length === 0) return volumeAnalytics.callsByCategory; + + // Calculate proportional category distribution for selected month + const totalCallsInMonth = filteredTrendData.reduce((sum, item) => sum + item.count, 0); + const totalCallsOverall = volumeAnalytics.trendData.reduce((sum, item) => sum + item.count, 0); + const monthRatio = totalCallsOverall > 0 ? totalCallsInMonth / totalCallsOverall : 0; + + const categoryDist: Record = {}; + Object.entries(volumeAnalytics.callsByCategory).forEach(([category, count]) => { + categoryDist[category] = Math.round(count * monthRatio); + }); + + return categoryDist; + }, [volumeAnalytics, selectedMonth]); + + const filteredVolumeAnalytics = useMemo(() => { + return volumeAnalytics; + }, [volumeAnalytics]); + + const categoryChartData = useMemo(() => { + if (!filteredCategoryDistribution) return []; + + const data = Object.entries(filteredCategoryDistribution).map(([name, value]) => ({ name, - value, + value: value as number, fill: CATEGORY_COLORS[name] || "#0ea5e9", })); return data.sort((a, b) => (categorySortOrder === "asc" ? a.value - b.value : b.value - a.value)); - }, [volumeAnalytics, categorySortOrder]); + }, [filteredCategoryDistribution, categorySortOrder]); + + const filteredStatusDistribution = useMemo(() => { + if (!resolutionMetrics) return {}; + if (selectedMonth === "All") return resolutionMetrics.statusDistribution; + + // Calculate status distribution for selected month + const filteredData = resolutionMetrics.trendData.filter( + (item) => formatMonthLabel(item.date) === selectedMonth + ); + + if (filteredData.length === 0) return resolutionMetrics.statusDistribution; + + const statusDist: Record = {}; + Object.keys(resolutionMetrics.statusDistribution).forEach(status => { + statusDist[status] = 0; + }); + + const monthData = resolutionMetrics.trendData.filter(d => formatMonthLabel(d.date) === selectedMonth); + monthData.forEach((_, idx) => { + if (idx === 0) { + Object.keys(statusDist).forEach(status => { + statusDist[status] = Math.round((resolutionMetrics.statusDistribution[status] || 0) / resolutionMetrics.trendData.length * monthData.length); + }); + } + }); + + return statusDist; + }, [resolutionMetrics, selectedMonth]); const statusChartData = useMemo(() => { - if (!resolutionMetrics) return []; - return Object.entries(resolutionMetrics.statusDistribution).map(([name, value]) => ({ + return Object.entries(filteredStatusDistribution).map(([name, value]) => ({ name, value, fill: STATUS_COLORS[name] || "#0ea5e9", })); - }, [resolutionMetrics]); - - const trendChartData = useMemo(() => { - if (!volumeAnalytics) return []; - return volumeAnalytics.trendData - .filter((item) => selectedMonth === "All" || formatMonthLabel(item.date) === selectedMonth) - .map((item) => ({ - ...item, - label: formatDateLabel(item.date), - })); - }, [volumeAnalytics, selectedMonth]); + }, [filteredStatusDistribution]); const completionTrendData = useMemo(() => { if (!resolutionMetrics) return []; return resolutionMetrics.trendData .filter((item) => selectedMonth === "All" || formatMonthLabel(item.date) === selectedMonth) - .map((item) => ({ - ...item, - label: formatDateLabel(item.date), - })); + .map((item) => { + // Calculate absolute counts from percentages + const totalCalls = item.completionRate + item.noShowRate + item.cancellationRate > 0 + ? 100 / ((item.completionRate + item.noShowRate + item.cancellationRate) / 100) + : 0; + + return { + ...item, + completedCalls: Math.round((item.completionRate / 100) * totalCalls), + noShowCalls: Math.round((item.noShowRate / 100) * totalCalls), + cancelledCalls: Math.round((item.cancellationRate / 100) * totalCalls), + label: formatDateLabel(item.date), + }; + }); }, [resolutionMetrics, selectedMonth]); + const trendChartTicks = useMemo(() => { + if (!trendChartData || trendChartData.length === 0) return []; + + if (selectedMonth === "All" && trendChartData.length > 40) { + return trendChartData + .map((item, idx) => ({ ...item, idx })) + .filter((_, idx) => idx % 7 === 0 || idx === trendChartData.length - 1) + .map(item => item.label); + } + + const interval = trendChartData.length > 20 ? 3 : 2; + return trendChartData + .map((item, idx) => ({ ...item, idx })) + .filter((_, idx) => idx % interval === 0 || idx === trendChartData.length - 1) + .map(item => item.label); + }, [trendChartData, selectedMonth]); + + const monthTrendData = useMemo(() => { + if (selectedMonth === "All" || !volumeAnalytics) { + return { + trendDirection: (volumeAnalytics?.trendDirection || "stable") as "up" | "down" | "stable", + percentageChange: volumeAnalytics?.percentageChange || 0, + }; + } + + // Get calls for selected month + const selectedMonthCalls = trendChartData.reduce((sum, item) => sum + item.count, 0); + + // Get calls for previous month by finding the previous month's label + const allMonths = volumeAnalytics.trendData.map((item) => formatMonthLabel(item.date)); + const uniqueMonths = Array.from(new Set(allMonths)); + const currentMonthIndex = uniqueMonths.indexOf(selectedMonth); + + let previousMonthCalls = 0; + if (currentMonthIndex > 0) { + const previousMonthLabel = uniqueMonths[currentMonthIndex - 1]; + previousMonthCalls = volumeAnalytics.trendData + .filter((item) => formatMonthLabel(item.date) === previousMonthLabel) + .reduce((sum, item) => sum + item.count, 0); + } + + // Calculate percentage change for this month vs previous month + const percentageChange = previousMonthCalls > 0 + ? ((selectedMonthCalls - previousMonthCalls) / previousMonthCalls) * 100 + : (selectedMonthCalls > 0 ? 100 : 0); + + const trendDirection: "up" | "down" | "stable" = percentageChange > 0.5 ? "up" : percentageChange < -0.5 ? "down" : "stable"; + + return { + trendDirection, + percentageChange: Math.round(percentageChange * 100) / 100, + }; + }, [selectedMonth, volumeAnalytics, trendChartData]); + + const maxPeakHourCount = useMemo(() => { + if (!filteredVolumeAnalytics?.peakHours || filteredVolumeAnalytics.peakHours.length === 0) return 1; + return Math.max(...filteredVolumeAnalytics.peakHours.map(h => h.count)); + }, [filteredVolumeAnalytics?.peakHours]); + + const maxPeakDayCount = useMemo(() => { + if (!filteredVolumeAnalytics?.peakDays || filteredVolumeAnalytics.peakDays.length === 0) return 1; + return Math.max(...filteredVolumeAnalytics.peakDays.map(d => d.count)); + }, [filteredVolumeAnalytics?.peakDays]); + const completionTicks = useMemo(() => { const labels = completionTrendData.map((item) => item.label); return labels.filter((_, idx) => idx % 2 === 0 || idx === labels.length - 1); @@ -348,10 +497,75 @@ export default function AdminCallStatisticsPage() { noShowRate: { label: "No-shows", color: "#ef4444" }, cancellationRate: { label: "Cancelled", color: "#94a3b8" }, }; - const totalCalls = volumeAnalytics?.totalCalls ?? 0; - const avgTime = resolutionMetrics?.averageBookingToCompletionTime - ? formatDuration(resolutionMetrics.averageBookingToCompletionTime) - : "β€”"; + + const totalCalls = useMemo(() => { + // Calculate total from status distribution to ensure consistency + const statusTotal = Object.values(filteredStatusDistribution).reduce((sum, count) => sum + count, 0); + if (statusTotal > 0) return statusTotal; + + // Fallback to trend data if no status data available + if (!trendChartData) return 0; + return trendChartData.reduce((sum, item) => sum + item.count, 0); + }, [trendChartData, filteredStatusDistribution]); + + const filteredResolutionMetrics = useMemo(() => { + if (!resolutionMetrics || selectedMonth === "All") return resolutionMetrics; + + // Calculate rates based on actual counts from status distribution + const statusCounts = filteredStatusDistribution; + const completedCount = statusCounts.Completed || 0; + const noShowCount = statusCounts.NoShow || 0; + const cancelledCount = statusCounts.Cancelled || 0; + const totalCount = Object.values(statusCounts).reduce((sum, count) => sum + count, 0); + + return { + ...resolutionMetrics, + completionRate: totalCount > 0 ? (completedCount / totalCount) * 100 : 0, + noShowRate: totalCount > 0 ? (noShowCount / totalCount) * 100 : 0, + cancellationRate: totalCount > 0 ? (cancelledCount / totalCount) * 100 : 0, + }; + }, [resolutionMetrics, selectedMonth, filteredStatusDistribution]); + + const avgTime = useMemo(() => { + if (selectedMonth === "All") { + return filteredResolutionMetrics?.averageBookingToCompletionTime + ? formatDuration(filteredResolutionMetrics.averageBookingToCompletionTime) + : "β€”"; + } + + if (!completionTrendData || completionTrendData.length === 0) { + return "β€”"; + } + + const totalCompletedCalls = completionTrendData.reduce((sum, item) => sum + item.completedCalls, 0); + if (totalCompletedCalls === 0) { + return "β€”"; + } + + const totalDays = completionTrendData.length; + const avgDaysPerCall = totalDays / totalCompletedCalls; + + const avgSeconds = avgDaysPerCall * 24 * 3600; + + const days = Math.floor(avgSeconds / (24 * 3600)); + const hours = Math.floor((avgSeconds % (24 * 3600)) / 3600); + const minutes = Math.floor((avgSeconds % 3600) / 60); + const seconds = Math.floor(avgSeconds % 60); + + let durationStr = "PT"; + if (days > 0) { + return formatDuration(`P${days}DT${hours}H${minutes}M${seconds}S`); + } else if (hours > 0) { + durationStr += `${hours}H`; + if (minutes > 0) durationStr += `${minutes}M`; + if (seconds > 0 || durationStr === "PT") durationStr += `${seconds}S`; + return formatDuration(durationStr); + } else if (minutes > 0) { + return formatDuration(`PT${minutes}M${seconds}S`); + } else { + return formatDuration(`PT${seconds}S`); + } + }, [selectedMonth, filteredResolutionMetrics, completionTrendData]); if (!user || !isAdmin(user.userType)) { return ( @@ -413,17 +627,28 @@ export default function AdminCallStatisticsPage() {

Review and manage call analytics

+
+ + +
- + as unknown as string} + value={monthTrendData.trendDirection === "up" ? "Up" : monthTrendData.trendDirection === "down" ? "Down" : "Stable"} + hint={ as unknown as string} /> - - + +
@@ -433,20 +658,9 @@ export default function AdminCallStatisticsPage() {

Total call volume

{numberFormatter.format(totalCalls)} - +

-
- - -
@@ -455,7 +669,10 @@ export default function AdminCallStatisticsPage() { dataKey="label" tickLine={false} axisLine={false} - interval={2} + ticks={trendChartTicks} + angle={selectedMonth === "All" ? -45 : 0} + textAnchor={selectedMonth === "All" ? "end" : "middle"} + height={selectedMonth === "All" ? 80 : 60} padding={{ left: 0, right: 24 }} /> @@ -551,7 +768,7 @@ export default function AdminCallStatisticsPage() {
@@ -560,12 +777,12 @@ export default function AdminCallStatisticsPage() {

Peak hours

- {([...(volumeAnalytics?.peakHours ?? [])] + {([...(filteredVolumeAnalytics?.peakHours ?? [])] .sort((a, b) => (peakSortOrder === "asc" ? a.count - b.count : b.count - a.count)) ).map((item) => (
{item.hour} -
+
{item.count}
))} @@ -574,12 +791,12 @@ export default function AdminCallStatisticsPage() {

Peak days

- {([...(volumeAnalytics?.peakDays ?? [])] + {([...(filteredVolumeAnalytics?.peakDays ?? [])] .sort((a, b) => (peakSortOrder === "asc" ? a.count - b.count : b.count - a.count)) ).map((item) => (
{item.day} -
+
{item.count}
))} @@ -595,20 +812,9 @@ export default function AdminCallStatisticsPage() {

Outcome trends

Completion vs no-show vs cancellation

-
- - -
- + - `${v}%`} /> - - - - } /> + + + + + + `${Math.round(Number(value))} calls`} + labelFormatter={(label) => label as string} + /> + } + />
diff --git a/8cbc_frontend/app/(admin)/admin/page.tsx b/8cbc_frontend/app/(admin)/admin/page.tsx index af55e28c..f7ecacdd 100644 --- a/8cbc_frontend/app/(admin)/admin/page.tsx +++ b/8cbc_frontend/app/(admin)/admin/page.tsx @@ -6,6 +6,7 @@ import { SupportBookings } from "@/components/dashboard/admin/SupportBookings"; import { AssignedFraudReports } from "@/components/dashboard/admin/AssignedFraudReports"; import { MetricCardData, MetricsGrid } from "@/components/dashboard/shared/MetricsGrid"; import { + BarChart3, CalendarDays, CheckCircle, ChevronRight, @@ -167,6 +168,12 @@ const AdminDashboard = () => { description: "Manage and assign fraud reports to support representatives", href: "/admin/fraud", }, + { + icon: BarChart3, + title: "View statistics", + description: "Analyze call performance and customer support metrics", + href: "/admin/calls-statistics", + }, ]; return ( From 41107419afab1fea1d26f11d2ff865906d1649de Mon Sep 17 00:00:00 2001 From: Eron Ng Date: Mon, 26 Jan 2026 23:10:15 +0800 Subject: [PATCH 09/12] feat: resolved copilot comments --- .../model/calls/call-statistics.model.ts | 28 +++-- .../(admin)/admin/calls-statistics/page.tsx | 117 +++++++----------- 8cbc_frontend/lib/api/call-statistics.ts | 7 +- 3 files changed, 67 insertions(+), 85 deletions(-) diff --git a/8cbc_backend/model/calls/call-statistics.model.ts b/8cbc_backend/model/calls/call-statistics.model.ts index 39cd0251..f4a7bd59 100644 --- a/8cbc_backend/model/calls/call-statistics.model.ts +++ b/8cbc_backend/model/calls/call-statistics.model.ts @@ -58,19 +58,31 @@ const getDateRanges = (period: "day" | "week" | "month", date?: string) => { return { currentStart, currentEnd, previousStart, previousEnd }; }; -// Get call volume analytics for a date range with optional period comparison export const getCallVolumeAnalytics = async (options: { startDate?: string; endDate?: string; period?: "day" | "week" | "month"; category?: string; }): Promise => { - const startDate = options.startDate ? new Date(options.startDate) : subMonths(new Date(), 3); - const endDate = options.endDate ? new Date(options.endDate) : new Date(); + let currentStart = options.startDate ? new Date(options.startDate) : undefined; + let currentEnd = options.endDate ? new Date(options.endDate) : undefined; + + if (!currentStart || !currentEnd) { + if (options.period && !options.startDate && !options.endDate) { + const { currentStart: periodStart, currentEnd: periodEnd } = getDateRanges(options.period, options.endDate); + currentStart = periodStart; + currentEnd = periodEnd; + } else { + currentStart = currentStart ?? subMonths(new Date(), 3); + currentEnd = currentEnd ?? new Date(); + } + } + + const windowDurationMs = Math.max(currentEnd.getTime() - currentStart.getTime(), 0); const whereConditions: (ReturnType | ReturnType | ReturnType)[] = [ - gte(callBookingsTable.createdAt, startDate), - lte(callBookingsTable.createdAt, endDate), + gte(callBookingsTable.createdAt, currentStart), + lte(callBookingsTable.createdAt, currentEnd), ]; if (options.category) { @@ -151,10 +163,8 @@ export const getCallVolumeAnalytics = async (options: { })); // Get previous period data for comparison - const { previousStart, previousEnd } = getDateRanges( - options.period || "month", - options.endDate - ); + const previousEnd = new Date(currentStart.getTime() - 1); + const previousStart = new Date(previousEnd.getTime() - windowDurationMs); const previousWhereConditions: (ReturnType | ReturnType | ReturnType)[] = [ gte(callBookingsTable.createdAt, previousStart), diff --git a/8cbc_frontend/app/(admin)/admin/calls-statistics/page.tsx b/8cbc_frontend/app/(admin)/admin/calls-statistics/page.tsx index 61624dfc..0fd54c29 100644 --- a/8cbc_frontend/app/(admin)/admin/calls-statistics/page.tsx +++ b/8cbc_frontend/app/(admin)/admin/calls-statistics/page.tsx @@ -51,39 +51,51 @@ const numberFormatter = new Intl.NumberFormat("en-US"); const formatDateLabel = (date: string) => { const d = new Date(date); - return d.toLocaleDateString("en-US", { month: "short", day: "numeric" }); + return d.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + timeZone: "Asia/Singapore", + }); }; const formatMonthLabel = (date: string) => { const d = new Date(date); - return d.toLocaleDateString("en-US", { month: "short", year: "numeric" }); + return d.toLocaleDateString("en-US", { + month: "short", + year: "numeric", + timeZone: "Asia/Singapore", + }); }; const formatDuration = (iso: string) => { - const match = iso.match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/); + const match = iso.match(/P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?/); if (!match) return iso; - const [, h, m, s] = match; - + + const [, d, h, m, s] = match; + + const days = parseInt(d || "0", 10); const hours = parseInt(h || "0", 10); const minutes = parseInt(m || "0", 10); const seconds = parseInt(s || "0", 10); - - const totalHours = hours + minutes / 60 + seconds / 3600; - if (totalHours >= 24) { - const days = totalHours / 24; - return `${days.toFixed(1)}d`; + const totalSeconds = + days * 86_400 + hours * 3_600 + minutes * 60 + seconds; + + if (totalSeconds >= 86_400) { + const totalDays = totalSeconds / 86_400; + return totalDays >= 10 ? `${Math.round(totalDays)}d` : `${totalDays.toFixed(1)}d`; } - if (totalHours >= 1) { - return `${totalHours.toFixed(1)}h`; + if (totalSeconds >= 3_600) { + const totalHours = totalSeconds / 3_600; + return totalHours >= 10 ? `${Math.round(totalHours)}h` : `${totalHours.toFixed(1)}h`; } - const totalMinutes = minutes + seconds / 60; - if (totalMinutes >= 1) { - return `${totalMinutes.toFixed(0)}m`; + if (totalSeconds >= 60) { + const totalMinutes = totalSeconds / 60; + return `${Math.round(totalMinutes)}m`; } - + return `${seconds}s`; }; @@ -192,14 +204,6 @@ const TrendChip = ({ direction, change }: { direction: "up" | "down" | "stable"; const isUp = direction === "up"; const Icon = isUp ? ArrowUpRight : ArrowDownRight; - if (direction === "stable") { - return ( - - Stable vs previous period - - ); - } - return ( { if (!volumeAnalytics) return {}; - if (selectedMonth === "All") return volumeAnalytics.callsByCategory; - - const filteredTrendData = volumeAnalytics.trendData.filter( - (item) => formatMonthLabel(item.date) === selectedMonth - ); - - if (filteredTrendData.length === 0) return volumeAnalytics.callsByCategory; - - // Calculate proportional category distribution for selected month - const totalCallsInMonth = filteredTrendData.reduce((sum, item) => sum + item.count, 0); - const totalCallsOverall = volumeAnalytics.trendData.reduce((sum, item) => sum + item.count, 0); - const monthRatio = totalCallsOverall > 0 ? totalCallsInMonth / totalCallsOverall : 0; - - const categoryDist: Record = {}; - Object.entries(volumeAnalytics.callsByCategory).forEach(([category, count]) => { - categoryDist[category] = Math.round(count * monthRatio); - }); - - return categoryDist; - }, [volumeAnalytics, selectedMonth]); + return volumeAnalytics.callsByCategory; + }, [volumeAnalytics]); const filteredVolumeAnalytics = useMemo(() => { return volumeAnalytics; @@ -349,29 +335,16 @@ export default function AdminCallStatisticsPage() { const filteredStatusDistribution = useMemo(() => { if (!resolutionMetrics) return {}; if (selectedMonth === "All") return resolutionMetrics.statusDistribution; - - // Calculate status distribution for selected month - const filteredData = resolutionMetrics.trendData.filter( + + const hasMonthData = resolutionMetrics.trendData?.some( (item) => formatMonthLabel(item.date) === selectedMonth ); - - if (filteredData.length === 0) return resolutionMetrics.statusDistribution; - - const statusDist: Record = {}; - Object.keys(resolutionMetrics.statusDistribution).forEach(status => { - statusDist[status] = 0; - }); - - const monthData = resolutionMetrics.trendData.filter(d => formatMonthLabel(d.date) === selectedMonth); - monthData.forEach((_, idx) => { - if (idx === 0) { - Object.keys(statusDist).forEach(status => { - statusDist[status] = Math.round((resolutionMetrics.statusDistribution[status] || 0) / resolutionMetrics.trendData.length * monthData.length); - }); - } - }); - - return statusDist; + + if (!hasMonthData) { + return resolutionMetrics.statusDistribution; + } + + return resolutionMetrics.statusDistribution; }, [resolutionMetrics, selectedMonth]); const statusChartData = useMemo(() => { @@ -387,7 +360,6 @@ export default function AdminCallStatisticsPage() { return resolutionMetrics.trendData .filter((item) => selectedMonth === "All" || formatMonthLabel(item.date) === selectedMonth) .map((item) => { - // Calculate absolute counts from percentages const totalCalls = item.completionRate + item.noShowRate + item.cancellationRate > 0 ? 100 / ((item.completionRate + item.noShowRate + item.cancellationRate) / 100) : 0; @@ -499,14 +471,9 @@ export default function AdminCallStatisticsPage() { }; const totalCalls = useMemo(() => { - // Calculate total from status distribution to ensure consistency - const statusTotal = Object.values(filteredStatusDistribution).reduce((sum, count) => sum + count, 0); - if (statusTotal > 0) return statusTotal; - - // Fallback to trend data if no status data available if (!trendChartData) return 0; return trendChartData.reduce((sum, item) => sum + item.count, 0); - }, [trendChartData, filteredStatusDistribution]); + }, [trendChartData]); const filteredResolutionMetrics = useMemo(() => { if (!resolutionMetrics || selectedMonth === "All") return resolutionMetrics; @@ -643,11 +610,11 @@ export default function AdminCallStatisticsPage() {
as unknown as string} + hint={} /> - +
diff --git a/8cbc_frontend/lib/api/call-statistics.ts b/8cbc_frontend/lib/api/call-statistics.ts index 51793e65..608725ec 100644 --- a/8cbc_frontend/lib/api/call-statistics.ts +++ b/8cbc_frontend/lib/api/call-statistics.ts @@ -43,6 +43,11 @@ export interface CallStatisticsOptions { category?: string; } +export interface CallStatisticsSummaryOptions { + startDate?: string; + endDate?: string; +} + export const callStatisticsApi = { /** * Get call volume analytics with trends and breakdowns @@ -74,7 +79,7 @@ export const callStatisticsApi = { * Get comprehensive call statistics summary (volume + resolution) */ getStatisticsSummary: async ( - options?: CallStatisticsOptions + options?: CallStatisticsSummaryOptions ): Promise => { const response = await apiClient.get<{ data: CallStatisticsSummary }>( "/api/calls/analytics/summary", From ecaf18cac6d381f62e4994f8e117dc1e3f23e298 Mon Sep 17 00:00:00 2001 From: Eron Ng Date: Thu, 29 Jan 2026 15:59:04 +0800 Subject: [PATCH 10/12] feat: implement PR review feedback and update dashboard display --- .../model/calls/call-statistics.model.ts | 302 ++++++++++---- .../(admin)/admin/calls-statistics/page.tsx | 392 ++++++++++++++---- 8cbc_frontend/app/(admin)/admin/page.tsx | 164 +++++++- 8cbc_frontend/lib/api/call-statistics.ts | 20 + 4 files changed, 710 insertions(+), 168 deletions(-) diff --git a/8cbc_backend/model/calls/call-statistics.model.ts b/8cbc_backend/model/calls/call-statistics.model.ts index f4a7bd59..05c3eb01 100644 --- a/8cbc_backend/model/calls/call-statistics.model.ts +++ b/8cbc_backend/model/calls/call-statistics.model.ts @@ -4,6 +4,20 @@ import { and, eq, gte, lte, count, sql } from "drizzle-orm"; import { startOfDay, endOfDay, startOfMonth, endOfMonth, startOfWeek, endOfWeek, subMonths, subWeeks, subDays } from "date-fns"; // Types for analytics +export interface MonthlyPeakData { + month: string; + peakHours: Array<{ hour: string; count: number }>; + peakDays: Array<{ day: string; count: number }>; +} + +export interface MonthlyCategoryData { + month: string; + categories: Record; +} +export interface MonthlyStatusData { + month: string; + statusDistribution: Record; +} export interface CallVolumeAnalytics { totalCalls: number; callsByCategory: Record; @@ -14,6 +28,8 @@ export interface CallVolumeAnalytics { previousPeriodTotal: number; trendDirection: "up" | "down" | "stable"; percentageChange: number; + monthlyPeaks?: MonthlyPeakData[]; + monthlyCategories?: MonthlyCategoryData[]; } export interface CallResolutionMetrics { @@ -22,11 +38,20 @@ export interface CallResolutionMetrics { cancellationRate: number; statusDistribution: Record; averageBookingToCompletionTime: string; - trendData: Array<{ date: string; completionRate: number; noShowRate: number; cancellationRate: number }>; + trendData: Array<{ + date: string; + completionRate: number; + noShowRate: number; + cancellationRate: number; + completedCalls: number; + noShowCalls: number; + cancelledCalls: number; + }>; totalCalls: number; completedCalls: number; noShowCalls: number; cancelledCalls: number; + monthlyStatuses?: MonthlyStatusData[]; } // Helper function to get date range for period comparison @@ -58,6 +83,169 @@ const getDateRanges = (period: "day" | "week" | "month", date?: string) => { return { currentStart, currentEnd, previousStart, previousEnd }; }; +const getMonthlyPeaks = async ( + startDate: Date, + endDate: Date, + category?: string +): Promise => { + const whereConditions: (ReturnType | ReturnType | ReturnType)[] = [ + gte(callBookingsTable.createdAt, startDate), + lte(callBookingsTable.createdAt, endDate), + ]; + + if (category) { + whereConditions.push(eq(callBookingsTable.category, category as never)); + } + + const monthlyData = await db + .select({ + month: sql`TO_CHAR(${callBookingsTable.createdAt}, 'YYYY-MM')`, + hour: sql`EXTRACT(HOUR FROM ${callBookingsTable.createdAt})::text`, + dayOfWeek: sql`TO_CHAR(${callBookingsTable.createdAt}, 'Day')`, + count: count(), + }) + .from(callBookingsTable) + .where(and(...whereConditions)) + .groupBy( + sql`TO_CHAR(${callBookingsTable.createdAt}, 'YYYY-MM')`, + sql`EXTRACT(HOUR FROM ${callBookingsTable.createdAt})`, + sql`TO_CHAR(${callBookingsTable.createdAt}, 'Day')` + ) + .orderBy(sql`TO_CHAR(${callBookingsTable.createdAt}, 'YYYY-MM')`); + + const monthMap: Record; days: Record }> = {}; + + monthlyData.forEach(({ month, hour, dayOfWeek, count: cnt }) => { + if (!monthMap[month]) { + monthMap[month] = { hours: {}, days: {} }; + } + + if (hour) { + monthMap[month].hours[hour] = (monthMap[month].hours[hour] || 0) + cnt; + } + + if (dayOfWeek) { + const trimmedDay = dayOfWeek.trim(); + monthMap[month].days[trimmedDay] = (monthMap[month].days[trimmedDay] || 0) + cnt; + } + }); + + const monthlyPeaks: MonthlyPeakData[] = Object.entries(monthMap).map(([month, data]) => { + const peakHours = Object.entries(data.hours) + .map(([hour, count]) => ({ + hour: `${hour.padStart(2, "0")}:00`, + count, + })) + .sort((a, b) => b.count - a.count) + .slice(0, 5); + + const peakDays = Object.entries(data.days) + .map(([day, count]) => ({ + day, + count, + })) + .sort((a, b) => b.count - a.count); + + return { + month, + peakHours, + peakDays, + }; + }); + + return monthlyPeaks; +}; + +const getMonthlyCategories = async ( + startDate: Date, + endDate: Date, + category?: string +): Promise => { + const whereConditions: (ReturnType | ReturnType | ReturnType)[] = [ + gte(callBookingsTable.createdAt, startDate), + lte(callBookingsTable.createdAt, endDate), + ]; + + if (category) { + whereConditions.push(eq(callBookingsTable.category, category as never)); + } + + const monthlyData = await db + .select({ + month: sql`TO_CHAR(${callBookingsTable.createdAt}, 'YYYY-MM')`, + category: callBookingsTable.category, + count: count(), + }) + .from(callBookingsTable) + .where(and(...whereConditions)) + .groupBy( + sql`TO_CHAR(${callBookingsTable.createdAt}, 'YYYY-MM')`, + callBookingsTable.category + ) + .orderBy(sql`TO_CHAR(${callBookingsTable.createdAt}, 'YYYY-MM')`); + + const monthMap: Record> = {}; + + monthlyData.forEach(({ month, category: cat, count: cnt }) => { + if (!monthMap[month]) { + monthMap[month] = {}; + } + monthMap[month][cat] = cnt; + }); + + const monthlyCategories: MonthlyCategoryData[] = Object.entries(monthMap).map(([month, categories]) => ({ + month, + categories, + })); + + return monthlyCategories; +}; + +const getMonthlyStatuses = async ( + startDate: Date, + endDate: Date, + category?: string +): Promise => { + const whereConditions: (ReturnType | ReturnType | ReturnType)[] = [ + gte(callBookingsTable.createdAt, startDate), + lte(callBookingsTable.createdAt, endDate), + ]; + + if (category) { + whereConditions.push(eq(callBookingsTable.category, category as never)); + } + + const monthlyData = await db + .select({ + month: sql`TO_CHAR(${callBookingsTable.createdAt}, 'YYYY-MM')`, + status: callBookingsTable.status, + count: count(), + }) + .from(callBookingsTable) + .where(and(...whereConditions)) + .groupBy( + sql`TO_CHAR(${callBookingsTable.createdAt}, 'YYYY-MM')`, + callBookingsTable.status + ) + .orderBy(sql`TO_CHAR(${callBookingsTable.createdAt}, 'YYYY-MM')`); + + const monthMap: Record> = {}; + + monthlyData.forEach(({ month, status, count: cnt }) => { + if (!monthMap[month]) { + monthMap[month] = {}; + } + monthMap[month][status] = cnt; + }); + + const monthlyStatuses: MonthlyStatusData[] = Object.entries(monthMap).map(([month, statusDistribution]) => ({ + month, + statusDistribution, + })); + + return monthlyStatuses; +}; + export const getCallVolumeAnalytics = async (options: { startDate?: string; endDate?: string; @@ -181,11 +369,15 @@ export const getCallVolumeAnalytics = async (options: { .where(and(...previousWhereConditions)); const previousPeriodTotal = previousPeriodResult[0]?.count || 0; - const percentageChange = previousPeriodTotal > 0 + const percentageChangeRaw = previousPeriodTotal > 0 ? ((totalCalls - previousPeriodTotal) / previousPeriodTotal) * 100 : (totalCalls > 0 ? 100 : 0); - const trendDirection = percentageChange > 0.5 ? "up" : percentageChange < -0.5 ? "down" : "stable"; + const trendDirection = percentageChangeRaw > 0.5 ? "up" : percentageChangeRaw < -0.5 ? "down" : "stable"; + + // Get monthly peaks if date range spans multiple months + const monthlyPeaks = await getMonthlyPeaks(currentStart, currentEnd, options.category); + const monthlyCategories = await getMonthlyCategories(currentStart, currentEnd, options.category); return { totalCalls, @@ -196,7 +388,9 @@ export const getCallVolumeAnalytics = async (options: { currentPeriodTotal: totalCalls, previousPeriodTotal, trendDirection, - percentageChange: Math.round(percentageChange * 100) / 100, + percentageChange: Math.round(percentageChangeRaw * 100) / 100, + monthlyPeaks, + monthlyCategories, }; }; @@ -253,46 +447,34 @@ export const getCallResolutionMetrics = async (options: { const noShowRate = totalCalls > 0 ? (noShowCalls / totalCalls) * 100 : 0; const cancellationRate = totalCalls > 0 ? (cancelledCalls / totalCalls) * 100 : 0; - // Get trend data const trendDataResults = await db .select({ date: sql`DATE(${callBookingsTable.createdAt})`, - status: callBookingsTable.status, - count: count(), + completionRate: sql`ROUND(COUNT(*) FILTER (WHERE ${callBookingsTable.status} = 'Completed') * 100.0 / COUNT(*), 2)`, + noShowRate: sql`ROUND(COUNT(*) FILTER (WHERE ${callBookingsTable.status} = 'NoShow') * 100.0 / COUNT(*), 2)`, + cancellationRate: sql`ROUND(COUNT(*) FILTER (WHERE ${callBookingsTable.status} = 'Cancelled') * 100.0 / COUNT(*), 2)`, + completedCalls: sql`COUNT(*) FILTER (WHERE ${callBookingsTable.status} = 'Completed')`, + noShowCalls: sql`COUNT(*) FILTER (WHERE ${callBookingsTable.status} = 'NoShow')`, + cancelledCalls: sql`COUNT(*) FILTER (WHERE ${callBookingsTable.status} = 'Cancelled')`, }) .from(callBookingsTable) .where(and(...whereConditions)) - .groupBy(sql`DATE(${callBookingsTable.createdAt})`, callBookingsTable.status) + .groupBy(sql`DATE(${callBookingsTable.createdAt})`) .orderBy(sql`DATE(${callBookingsTable.createdAt})`); - // Aggregate trend data by date and calculate rates - const trendMap: Record = {}; - - trendDataResults.forEach(({ date, status, count: cnt }) => { - if (!trendMap[date]) { - trendMap[date] = { completed: 0, noShow: 0, cancelled: 0, total: 0 }; - } - - if (status === "Completed") trendMap[date].completed = cnt; - if (status === "NoShow") trendMap[date].noShow = cnt; - if (status === "Cancelled") trendMap[date].cancelled = cnt; - - trendMap[date].total += cnt; - }); - - const trendData = Object.entries(trendMap).map(([date, stats]) => ({ + const trendData = trendDataResults.map(({ date, completionRate, noShowRate, cancellationRate, completedCalls, noShowCalls, cancelledCalls }) => ({ date, - completionRate: stats.total > 0 ? (stats.completed / stats.total) * 100 : 0, - noShowRate: stats.total > 0 ? (stats.noShow / stats.total) * 100 : 0, - cancellationRate: stats.total > 0 ? (stats.cancelled / stats.total) * 100 : 0, + completionRate: completionRate || 0, + noShowRate: noShowRate || 0, + cancellationRate: cancellationRate || 0, + completedCalls, + noShowCalls, + cancelledCalls, })); - // Calculate average booking to completion time for completed calls - // Uses updatedAt as the completion timestamp (fallback if no history exists) let averageBookingToCompletionTime = "PT0S"; if (completedCalls > 0) { - // Only include bookings marked Completed whose creation falls inside the window const completionWindowConditions: (ReturnType | ReturnType | ReturnType)[] = [ eq(callBookingsTable.status, "Completed"), gte(callBookingsTable.createdAt, startDate), @@ -302,58 +484,28 @@ export const getCallResolutionMetrics = async (options: { if (options.category) { completionWindowConditions.push(eq(callBookingsTable.category, options.category as never)); } - const completedCallsData = await db + + const avgDurationResult = await db .select({ - createdAt: callBookingsTable.createdAt, - updatedAt: callBookingsTable.updatedAt, - completionAt: sql`( - SELECT MIN(h.created_at) - FROM call_booking_history AS h - WHERE h.booking_id = ${callBookingsTable.id} - AND h.change_type = 'status_change' - AND h.new_value = 'Completed' - )`, + avgSeconds: sql`EXTRACT(EPOCH FROM (AVG(COALESCE( + (SELECT MIN(h.created_at) FROM call_booking_history h WHERE h.booking_id = ${callBookingsTable.id} AND h.change_type = 'status_change' AND h.new_value = 'Completed'), + ${callBookingsTable.updatedAt} + ) - ${callBookingsTable.createdAt})))`, }) .from(callBookingsTable) .where(and(...completionWindowConditions)); - if (completedCallsData.length > 0) { - const durations = completedCallsData - .map(call => { - // Use history timestamp if available, otherwise use updatedAt - const completionTimestamp = call.completionAt || call.updatedAt; - if (!completionTimestamp) return NaN; - const created = new Date(call.createdAt).getTime(); - const completed = new Date(completionTimestamp).getTime(); - return completed - created; - }) - .filter((ms) => Number.isFinite(ms) && ms >= 0) - .map((ms) => ms / 1000); - - if (durations.length === 0) { - return { - completionRate: Math.round(completionRate * 100) / 100, - noShowRate: Math.round(noShowRate * 100) / 100, - cancellationRate: Math.round(cancellationRate * 100) / 100, - statusDistribution, - averageBookingToCompletionTime, - trendData, - totalCalls, - completedCalls, - noShowCalls, - cancelledCalls, - }; - } - - // Calculate average - const avgSeconds = durations.reduce((a, b) => a + b, 0) / durations.length; - + const avgSeconds = avgDurationResult[0]?.avgSeconds; + if (avgDurationResult.length > 0 && avgSeconds !== null && avgSeconds !== undefined) { // Convert to ISO 8601 duration format - const hours = Math.floor(avgSeconds / 3600); + const days = Math.floor(avgSeconds / 86400); + const hours = Math.floor((avgSeconds % 86400) / 3600); const minutes = Math.floor((avgSeconds % 3600) / 60); const seconds = Math.floor(avgSeconds % 60); - let durationStr = "PT"; + let durationStr = "P"; + if (days > 0) durationStr += `${days}D`; + durationStr += "T"; if (hours > 0) durationStr += `${hours}H`; if (minutes > 0) durationStr += `${minutes}M`; if (seconds > 0 || durationStr === "PT") durationStr += `${seconds}S`; @@ -362,6 +514,9 @@ export const getCallResolutionMetrics = async (options: { } } + // Get monthly statuses + const monthlyStatuses = await getMonthlyStatuses(startDate, endDate, options.category); + return { completionRate: Math.round(completionRate * 100) / 100, noShowRate: Math.round(noShowRate * 100) / 100, @@ -373,6 +528,7 @@ export const getCallResolutionMetrics = async (options: { completedCalls, noShowCalls, cancelledCalls, + monthlyStatuses, }; }; diff --git a/8cbc_frontend/app/(admin)/admin/calls-statistics/page.tsx b/8cbc_frontend/app/(admin)/admin/calls-statistics/page.tsx index 0fd54c29..547a035c 100644 --- a/8cbc_frontend/app/(admin)/admin/calls-statistics/page.tsx +++ b/8cbc_frontend/app/(admin)/admin/calls-statistics/page.tsx @@ -3,6 +3,7 @@ import type React from "react"; import { useEffect, useMemo, useRef, useState } from "react"; import Link from "next/link"; +import { useRouter } from "next/navigation"; import { Bar, BarChart, @@ -242,6 +243,7 @@ const StatusLegend = ({ data }: { data: { name: string; value: number; fill: str export default function AdminCallStatisticsPage() { const { user } = useAuth(); + const router = useRouter(); const [summary, setSummary] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -251,39 +253,27 @@ export default function AdminCallStatisticsPage() { const [activeCategory, setActiveCategory] = useState<{ name: string; value: number } | null>(null); useEffect(() => { - let cancelled = false; - if (!user || !isAdmin(user.userType)) { - setSummary(null); - setLoading(false); - setError(null); - return () => { - cancelled = true; - }; + router.push("/dashboard"); + return; } const load = async () => { setLoading(true); try { const data = await callStatisticsApi.getStatisticsSummary(); - if (cancelled) return; setSummary(data); setError(null); } catch { - if (cancelled) return; setError("Failed to load call statistics. Please try again."); setSummary(null); } finally { - if (!cancelled) setLoading(false); + setLoading(false); } }; load(); - - return () => { - cancelled = true; - }; - }, [user]); + }, [user, router]); const monthOptions = useMemo(() => { if (!summary) return ["All"]; @@ -304,22 +294,88 @@ export default function AdminCallStatisticsPage() { const trendChartData = useMemo(() => { if (!volumeAnalytics) return []; - return volumeAnalytics.trendData + + const filteredData = volumeAnalytics.trendData .filter((item) => selectedMonth === "All" || formatMonthLabel(item.date) === selectedMonth) .map((item) => ({ ...item, label: formatDateLabel(item.date), })); + + // Calculate 7-day rolling average + return filteredData.map((item, index) => { + const windowStart = Math.max(0, index - 6); + const windowData = filteredData.slice(windowStart, index + 1); + + const countSum = windowData.reduce((sum, d) => sum + d.count, 0); + const countAvg = countSum / windowData.length; + + return { + ...item, + countAvg: Math.round(countAvg * 100) / 100, + }; + }); }, [volumeAnalytics, selectedMonth]); const filteredCategoryDistribution = useMemo(() => { if (!volumeAnalytics) return {}; - return volumeAnalytics.callsByCategory; - }, [volumeAnalytics]); + if (selectedMonth === "All") return volumeAnalytics.callsByCategory; + + const d = new Date(selectedMonth); + const monthKey = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`; + + const monthData = volumeAnalytics.monthlyCategories?.find((m) => m.month === monthKey); + if (!monthData) { + return volumeAnalytics.callsByCategory; + } + + return monthData.categories; + }, [volumeAnalytics, selectedMonth]); const filteredVolumeAnalytics = useMemo(() => { - return volumeAnalytics; - }, [volumeAnalytics]); + if (!volumeAnalytics || selectedMonth === "All") { + return volumeAnalytics; + } + + const d = new Date(selectedMonth); + const monthKey = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`; + + const monthData = volumeAnalytics.monthlyPeaks?.find((m) => m.month === monthKey); + if (!monthData) { + return volumeAnalytics; + } + + return { + ...volumeAnalytics, + peakHours: monthData.peakHours, + peakDays: monthData.peakDays, + }; + }, [volumeAnalytics, selectedMonth]); + + const aggregatedPeakData = useMemo(() => { + if (selectedMonth !== "All" || !volumeAnalytics?.monthlyPeaks) { + return null; + } + + const peakHourMap = new Map(); + const peakDayMap = new Map(); + + volumeAnalytics.monthlyPeaks.forEach((monthData) => { + monthData.peakHours.forEach((hour) => { + peakHourMap.set(hour.hour, (peakHourMap.get(hour.hour) || 0) + hour.count); + }); + monthData.peakDays.forEach((day) => { + peakDayMap.set(day.day, (peakDayMap.get(day.day) || 0) + day.count); + }); + }); + + const peakHours = Array.from(peakHourMap, ([hour, count]) => ({ hour, count })) + .sort((a, b) => (peakSortOrder === "asc" ? a.count - b.count : b.count - a.count)); + const peakDays = Array.from(peakDayMap, ([day, count]) => ({ day, count })) + .sort((a, b) => (peakSortOrder === "asc" ? a.count - b.count : b.count - a.count)); + + return { peakHours, peakDays }; + }, [volumeAnalytics?.monthlyPeaks, selectedMonth, peakSortOrder]); const categoryChartData = useMemo(() => { if (!filteredCategoryDistribution) return []; @@ -336,15 +392,16 @@ export default function AdminCallStatisticsPage() { if (!resolutionMetrics) return {}; if (selectedMonth === "All") return resolutionMetrics.statusDistribution; - const hasMonthData = resolutionMetrics.trendData?.some( - (item) => formatMonthLabel(item.date) === selectedMonth - ); + // Get monthly status data + const d = new Date(selectedMonth); + const monthKey = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`; - if (!hasMonthData) { + const monthData = resolutionMetrics.monthlyStatuses?.find((m) => m.month === monthKey); + if (!monthData) { return resolutionMetrics.statusDistribution; } - return resolutionMetrics.statusDistribution; + return monthData.statusDistribution; }, [resolutionMetrics, selectedMonth]); const statusChartData = useMemo(() => { @@ -357,21 +414,39 @@ export default function AdminCallStatisticsPage() { const completionTrendData = useMemo(() => { if (!resolutionMetrics) return []; - return resolutionMetrics.trendData - .filter((item) => selectedMonth === "All" || formatMonthLabel(item.date) === selectedMonth) - .map((item) => { - const totalCalls = item.completionRate + item.noShowRate + item.cancellationRate > 0 - ? 100 / ((item.completionRate + item.noShowRate + item.cancellationRate) / 100) - : 0; - - return { - ...item, - completedCalls: Math.round((item.completionRate / 100) * totalCalls), - noShowCalls: Math.round((item.noShowRate / 100) * totalCalls), - cancelledCalls: Math.round((item.cancellationRate / 100) * totalCalls), - label: formatDateLabel(item.date), - }; - }); + + let filteredData = resolutionMetrics.trendData.map((item) => ({ + ...item, + completedCalls: Number(item.completedCalls), + noShowCalls: Number(item.noShowCalls), + cancelledCalls: Number(item.cancelledCalls), + label: formatDateLabel(item.date), + })); + + if (selectedMonth !== "All") { + filteredData = filteredData.filter((item) => formatMonthLabel(item.date) === selectedMonth); + } + + return filteredData.map((item, index) => { + const windowStart = Math.max(0, index - 6); + const windowData = filteredData.slice(windowStart, index + 1); + + const completedSum = windowData.reduce((sum, d) => sum + d.completedCalls, 0); + const noShowSum = windowData.reduce((sum, d) => sum + d.noShowCalls, 0); + const cancelledSum = windowData.reduce((sum, d) => sum + d.cancelledCalls, 0); + + const windowLength = windowData.length; + const completedAvg = completedSum / windowLength; + const noShowAvg = noShowSum / windowLength; + const cancelledAvg = cancelledSum / windowLength; + + return { + ...item, + completedCallsAvg: Math.round(completedAvg * 100) / 100, + noShowCallsAvg: Math.round(noShowAvg * 100) / 100, + cancelledCallsAvg: Math.round(cancelledAvg * 100) / 100, + }; + }); }, [resolutionMetrics, selectedMonth]); const trendChartTicks = useMemo(() => { @@ -428,16 +503,6 @@ export default function AdminCallStatisticsPage() { }; }, [selectedMonth, volumeAnalytics, trendChartData]); - const maxPeakHourCount = useMemo(() => { - if (!filteredVolumeAnalytics?.peakHours || filteredVolumeAnalytics.peakHours.length === 0) return 1; - return Math.max(...filteredVolumeAnalytics.peakHours.map(h => h.count)); - }, [filteredVolumeAnalytics?.peakHours]); - - const maxPeakDayCount = useMemo(() => { - if (!filteredVolumeAnalytics?.peakDays || filteredVolumeAnalytics.peakDays.length === 0) return 1; - return Math.max(...filteredVolumeAnalytics.peakDays.map(d => d.count)); - }, [filteredVolumeAnalytics?.peakDays]); - const completionTicks = useMemo(() => { const labels = completionTrendData.map((item) => item.label); return labels.filter((_, idx) => idx % 2 === 0 || idx === labels.length - 1); @@ -534,17 +599,6 @@ export default function AdminCallStatisticsPage() { } }, [selectedMonth, filteredResolutionMetrics, completionTrendData]); - if (!user || !isAdmin(user.userType)) { - return ( -
-
-

Admins only

-

You need administrator access to view call statistics.

-
-
- ); - } - if (loading) { return (
@@ -643,8 +697,43 @@ export default function AdminCallStatisticsPage() { padding={{ left: 0, right: 24 }} /> - - } /> + + + { + if (active && payload && payload.length) { + return ( +
+

{label}

+ {payload.map((entry, index) => ( +

+ {entry.name}: {Math.round(Number(entry.value))} calls +

+ ))} +
+ ); + } + return null; + }} + />
@@ -661,7 +750,7 @@ export default function AdminCallStatisticsPage() { {statusChartData.map((entry) => ( - + ))} } /> @@ -744,29 +833,41 @@ export default function AdminCallStatisticsPage() {

Peak hours

- {([...(filteredVolumeAnalytics?.peakHours ?? [])] - .sort((a, b) => (peakSortOrder === "asc" ? a.count - b.count : b.count - a.count)) - ).map((item) => ( -
- {item.hour} -
- {item.count} -
- ))} + {(() => { + const peakHours = selectedMonth === "All" + ? aggregatedPeakData?.peakHours || [] + : [...(filteredVolumeAnalytics?.peakHours ?? [])].sort((a, b) => (peakSortOrder === "asc" ? a.count - b.count : b.count - a.count)); + + const maxCount = Math.max(...peakHours.map(h => h.count), 1); + + return peakHours.map((item) => ( +
+ {item.hour} +
+ {item.count} +
+ )); + })()}

Peak days

- {([...(filteredVolumeAnalytics?.peakDays ?? [])] - .sort((a, b) => (peakSortOrder === "asc" ? a.count - b.count : b.count - a.count)) - ).map((item) => ( -
- {item.day} -
- {item.count} -
- ))} + {(() => { + const peakDays = selectedMonth === "All" + ? aggregatedPeakData?.peakDays || [] + : [...(filteredVolumeAnalytics?.peakDays ?? [])].sort((a, b) => (peakSortOrder === "asc" ? a.count - b.count : b.count - a.count)); + + const maxCount = Math.max(...peakDays.map(d => d.count), 1); + + return peakDays.map((item) => ( +
+ {item.day} +
+ {item.count} +
+ )); + })()}
@@ -778,6 +879,7 @@ export default function AdminCallStatisticsPage() {

Outcome trends

Completion vs no-show vs cancellation

+

Solid lines show 7-day rolling average, lighter lines show daily fluctuations

@@ -792,21 +894,127 @@ export default function AdminCallStatisticsPage() { tickMargin={8} /> - - - + + + + + + `${Math.round(Number(value))} calls`} - labelFormatter={(label) => label as string} - /> - } + content={({ active, payload, label }) => { + if (active && payload && payload.length) { + const avgData = payload.filter(p => typeof p.name === 'string' && p.name.includes('7-day avg')); + const dailyData = payload.filter(p => typeof p.name === 'string' && p.name.includes('daily')); + + return ( +
+

{label}

+ +
+

7-day Rolling Average

+ {avgData.map((entry, index) => ( +
+
+
+ + {typeof entry.name === 'string' ? entry.name.replace(' (7-day avg)', '') : entry.name} + +
+ + {Math.round(Number(entry.value))} + +
+ ))} +
+ + {dailyData.length > 0 && ( +
+

Daily Values

+ {dailyData.map((entry, index) => ( +
+
+
+ + {typeof entry.name === 'string' ? entry.name.replace(' (daily)', '') : entry.name} + +
+ + {Math.round(Number(entry.value))} + +
+ ))} +
+ )} +
+ ); + } + return null; + }} /> diff --git a/8cbc_frontend/app/(admin)/admin/page.tsx b/8cbc_frontend/app/(admin)/admin/page.tsx index f7ecacdd..d8ff2d59 100644 --- a/8cbc_frontend/app/(admin)/admin/page.tsx +++ b/8cbc_frontend/app/(admin)/admin/page.tsx @@ -19,19 +19,66 @@ import { UserX } from "lucide-react"; import Link from "next/link"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import apiClient from "@/lib/api/ApiClient"; import type { FraudReport } from "@/lib/api/fraud"; import { fraudApi } from "@/lib/api/fraud"; import { AdminMetrics, getAdminMetrics } from "@/lib/api/admin"; +import { callStatisticsApi, type CallStatisticsSummary } from "@/lib/api/call-statistics"; import type { CallBookingWithDetails, SuccessResponse } from "@shared/types/api.types"; import { isAdmin } from "@/types/auth.types"; +import { + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from "@/components/ui/chart"; +import { + + PieChart, + Pie, + Cell, +} from "recharts"; + +const numberFormatter = new Intl.NumberFormat("en-US"); + +const ProgressBar = ({ value, color }: { value: number; color: string }) => { + return ( +
+
+
+ ); +}; + +const StatusLegend = ({ data }: { data: { name: string; value: number; fill: string }[] }) => ( +
+ {data.map((item) => ( +
+ + {item.name} + {numberFormatter.format(item.value)} +
+ ))} +
+); + +const STATUS_COLORS: Record = { + Completed: "#10b981", + Pending: "#f97316", + NoShow: "#ef4444", + Cancelled: "#94a3b8", + Confirmed: "#0ea5e9", + Processing: "#8b5cf6", +}; const AdminDashboard = () => { const { user } = useAuth(); const [bookings, setBookings] = useState([]); const [fraudReports, setFraudReports] = useState([]); const [adminMetrics, setAdminMetrics] = useState(null); + const [statistics, setStatistics] = useState(null); const [loading, setLoading] = useState(true); const fetchBookings = useCallback(async () => { @@ -68,14 +115,23 @@ const AdminDashboard = () => { } }, []); + const fetchStatistics = useCallback(async () => { + try { + const stats = await callStatisticsApi.getStatisticsSummary(); + setStatistics(stats); + } catch (error) { + console.error("Failed to fetch statistics:", error); + } + }, []); + useEffect(() => { const fetchData = async () => { setLoading(true); - await Promise.all([fetchBookings(), fetchReports(), fetchMetrics()]); + await Promise.all([fetchBookings(), fetchReports(), fetchMetrics(), fetchStatistics()]); setLoading(false); }; fetchData(); - }, [fetchBookings, fetchReports, fetchMetrics]); + }, [fetchBookings, fetchReports, fetchMetrics, fetchStatistics]); const completedCalls = bookings.filter( (b) => b.booking.status === "Completed", @@ -85,6 +141,36 @@ const AdminDashboard = () => { ).length; const noShows = bookings.filter((b) => b.booking.status === "NoShow").length; + // Get current month data for visualizations + const currentMonthKey = useMemo(() => { + const d = new Date(); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`; + }, []); + + const peakHours = useMemo(() => { + if (!statistics) return []; + const monthData = statistics.volumeAnalytics.monthlyPeaks?.find((m) => m.month === currentMonthKey); + return monthData?.peakHours || statistics.volumeAnalytics.peakHours || []; + }, [statistics, currentMonthKey]); + + const peakDays = useMemo(() => { + if (!statistics) return []; + const monthData = statistics.volumeAnalytics.monthlyPeaks?.find((m) => m.month === currentMonthKey); + return monthData?.peakDays || statistics.volumeAnalytics.peakDays || []; + }, [statistics, currentMonthKey]); + + const statusChartData = useMemo(() => { + if (!statistics) return []; + const monthData = statistics.resolutionMetrics.monthlyStatuses?.find((m) => m.month === currentMonthKey); + const statusDist = monthData?.statusDistribution || statistics.resolutionMetrics.statusDistribution; + + return Object.entries(statusDist).map(([name, value]) => ({ + name, + value: value as number, + fill: STATUS_COLORS[name] || "#0ea5e9", + })); + }, [statistics, currentMonthKey]); + const hours = new Date().getHours(); const greeting = hours < 12 @@ -194,6 +280,78 @@ const AdminDashboard = () => { metrics={isAdminUser ? adminMetricsData : supportMetrics} />
+ {isAdminUser && ( +
+
+
+
+

Peak scheduling windows

+

Hours & days

+
+
+
+
+

Peak hours

+
+ {(() => { + const maxCount = Math.max(...peakHours.map(h => h.count), 1); + return peakHours.map((item) => ( +
+ {item.hour} +
+ {item.count} +
+ )); + })()} +
+
+
+

Peak days

+
+ {(() => { + const maxCount = Math.max(...peakDays.map(d => d.count), 1); + return peakDays.map((item) => ( +
+ {item.day} +
+ {item.count} +
+ )); + })()} +
+
+
+
+ +
+
+

Status distribution

+

By latest status

+
+
+ {statusChartData.length > 0 ? ( + <> +
+ + + + {statusChartData.map((entry) => ( + + ))} + + } /> + + +
+
+ + ) : ( +

No data available

+ )} +
+
+
+ )}

Admin Actions

diff --git a/8cbc_frontend/lib/api/call-statistics.ts b/8cbc_frontend/lib/api/call-statistics.ts index 608725ec..f5ea453a 100644 --- a/8cbc_frontend/lib/api/call-statistics.ts +++ b/8cbc_frontend/lib/api/call-statistics.ts @@ -1,6 +1,20 @@ import apiClient from "./ApiClient"; // Types for call statistics +export interface MonthlyPeakData { + month: string; + peakHours: Array<{ hour: string; count: number }>; + peakDays: Array<{ day: string; count: number }>; +} + +export interface MonthlyCategoryData { + month: string; + categories: Record; +} +export interface MonthlyStatusData { + month: string; + statusDistribution: Record; +} export interface CallVolumeAnalytics { totalCalls: number; callsByCategory: Record; @@ -11,6 +25,8 @@ export interface CallVolumeAnalytics { previousPeriodTotal: number; trendDirection: "up" | "down" | "stable"; percentageChange: number; + monthlyPeaks?: MonthlyPeakData[]; + monthlyCategories?: MonthlyCategoryData[]; } export interface CallResolutionMetrics { @@ -24,11 +40,15 @@ export interface CallResolutionMetrics { completionRate: number; noShowRate: number; cancellationRate: number; + completedCalls: number; + noShowCalls: number; + cancelledCalls: number; }>; totalCalls: number; completedCalls: number; noShowCalls: number; cancelledCalls: number; + monthlyStatuses?: MonthlyStatusData[]; } export interface CallStatisticsSummary { From d43911e85f3d93a751f529d6b50d1135731c895c Mon Sep 17 00:00:00 2001 From: Xuan Han Tan <187362236+XuanHanTan-School@users.noreply.github.com> Date: Thu, 29 Jan 2026 23:02:46 +0800 Subject: [PATCH 11/12] fix: remove dots for line graphs --- .../(admin)/admin/calls-statistics/page.tsx | 54 +++++++++---------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/8cbc_frontend/app/(admin)/admin/calls-statistics/page.tsx b/8cbc_frontend/app/(admin)/admin/calls-statistics/page.tsx index 547a035c..e95bb32d 100644 --- a/8cbc_frontend/app/(admin)/admin/calls-statistics/page.tsx +++ b/8cbc_frontend/app/(admin)/admin/calls-statistics/page.tsx @@ -709,12 +709,12 @@ export default function AdminCallStatisticsPage() { legendType="none" connectNulls /> - - - - Date: Fri, 30 Jan 2026 17:23:16 +0800 Subject: [PATCH 12/12] feat: create seed files for database, includes 2 users for each user type, multiple fraud reports, calls between nov 2025 and feb 2026 for statistics --- .../drizzle/seeds/seed_call_bookings.ts | 223 ++++++++++++++++++ .../drizzle/seeds/seed_call_scheduling.ts | 64 +---- .../drizzle/seeds/seed_fraud_reports.ts | 118 +++++++++ 8cbc_backend/drizzle/seeds/seed_users.ts | 83 +++++-- 8cbc_backend/scripts/setup_db.ts | 10 +- .../(admin)/admin/calls-statistics/page.tsx | 87 +++++-- 8cbc_frontend/app/(admin)/admin/page.tsx | 12 +- 7 files changed, 501 insertions(+), 96 deletions(-) create mode 100644 8cbc_backend/drizzle/seeds/seed_call_bookings.ts create mode 100644 8cbc_backend/drizzle/seeds/seed_fraud_reports.ts diff --git a/8cbc_backend/drizzle/seeds/seed_call_bookings.ts b/8cbc_backend/drizzle/seeds/seed_call_bookings.ts new file mode 100644 index 00000000..2ff8dda7 --- /dev/null +++ b/8cbc_backend/drizzle/seeds/seed_call_bookings.ts @@ -0,0 +1,223 @@ +import { db } from "../../database/index.ts"; +import { + callSlotsTable, + callBookingsTable, + callCategoryEnum, + callStatusEnum, + usersTable, +} from "../../database/schema.ts"; +import { and, eq, inArray } from "drizzle-orm"; + +const CATEGORIES = [ + "General Inquiry", + "Technical Support", + "Billing", + "Fraud Report", + "Loan Inquiry", + "Other", +] as const; + +const STATUSES = [ + "Pending", + "Confirmed", + "Processing", + "Completed", + "NoShow", + "Cancelled", +] as const; + +const REASONS = [ + "Need help with account setup", + "Unable to access online banking", + "Question about recent transaction", + "Billing dispute", + "Card not working", + "Want to apply for loan", + "Fraud detection on account", + "Password reset assistance", + "Account balance inquiry", + "Transfer issues", + "Statement request", + "Need to update personal information", + "Interest rate inquiry", + "ATM withdrawal problem", + "Mobile app not working", +]; + +const randomElement = (arr: readonly T[]): T => { + const index = Math.floor(Math.random() * arr.length); + return arr[index]!; +}; + +const randomInt = (min: number, max: number): number => + Math.floor(Math.random() * (max - min + 1)) + min; + +const randomPhone = (): string => + `+65${randomInt(60000000, 99999999)}`; + +async function seedCallBookingsForMonth( + year: number, + month: number, + daysInMonth: number, + agentIds: number[], + targetCallsPerMonth: number +) { + const bookingsToInsert: typeof callBookingsTable.$inferInsert[] = []; + const slotsMap = new Map(); + let callsCreated = 0; + + for (let dayNum = 1; dayNum <= daysInMonth && callsCreated < targetCallsPerMonth; dayNum++) { + const dateStr = `${year}-${String(month).padStart(2, "0")}-${String( + dayNum + ).padStart(2, "0")}`; + + const remainingDays = daysInMonth - dayNum + 1; + const remainingCalls = targetCallsPerMonth - callsCreated; + const avgCallsPerDay = remainingCalls / remainingDays; + + const minCalls = remainingCalls > 0 ? Math.max(1, Math.floor(avgCallsPerDay * 0.6)) : 0; + const maxCalls = Math.ceil(avgCallsPerDay * 1.4); + const callsForToday = Math.min( + randomInt(minCalls, maxCalls), + remainingCalls + ); + + for (let callNum = 0; callNum < callsForToday; callNum++) { + const hour = randomInt(9, 16); + const minute = randomInt(0, 11) * 5; + const timeStr = `${String(hour).padStart(2, "0")}:${String(minute).padStart( + 2, + "0" + )}`; + + const slotKey = `${dateStr}|${timeStr}`; + + // Get or create slot ID + let slotId = slotsMap.get(slotKey); + if (!slotId) { + const slot = await db + .select() + .from(callSlotsTable) + .where( + and( + eq(callSlotsTable.date, dateStr), + eq(callSlotsTable.startTime, timeStr) + ) + ) + .limit(1); + + if (slot.length === 0) { + const result = await db + .insert(callSlotsTable) + .values({ + date: dateStr, + startTime: timeStr, + isActive: true, + }) + .returning(); + slotId = result[0]!.id; + } else { + slotId = slot[0]!.id; + } + slotsMap.set(slotKey, slotId); + } + + // Generate booking data + const category = randomElement(CATEGORIES); + const status = randomElement(STATUSES); + const reason = randomElement(REASONS); + const agentId = + Math.random() > 0.3 && agentIds.length > 0 ? randomElement(agentIds) : null; + + const date = new Date(`${dateStr}T${timeStr}`); + const updatedAt = new Date(date); + + if (status === "Completed") { + updatedAt.setDate( + updatedAt.getDate() + randomInt(1, 4) + ); + updatedAt.setHours(updatedAt.getHours() + randomInt(0, 11)); + } else if (status === "NoShow") { + updatedAt.setHours(updatedAt.getHours() + randomInt(0, 7)); + } else if (status === "Cancelled") { + updatedAt.setHours(updatedAt.getHours() + randomInt(0, 47)); + } else if (status === "Processing") { + updatedAt.setHours(updatedAt.getHours() + randomInt(2, 20)); + } else if (status === "Confirmed") { + updatedAt.setMinutes(updatedAt.getMinutes() + randomInt(10, 120)); + } else if (status === "Pending") { + updatedAt.setMinutes(updatedAt.getMinutes() + randomInt(1, 30)); + } + + bookingsToInsert.push({ + userId: 1, + slotId: slotId, + phoneNumber: randomPhone(), + category: category as typeof callCategoryEnum.enumValues[number], + reason: reason, + status: status as typeof callStatusEnum.enumValues[number], + assignedAgentId: agentId, + createdAt: date, + updatedAt: updatedAt, + }); + + callsCreated++; + } + } + + // Batch insert all bookings for the month + if (bookingsToInsert.length > 0) { + await db.insert(callBookingsTable).values(bookingsToInsert); + } +} + +export async function seedCallBookings() { + try { + console.log("🌱 Seeding call bookings..."); + + // Check if bookings already exist + const existingBookings = await db + .select() + .from(callBookingsTable) + .limit(1); + + if (existingBookings.length > 0) { + console.log("Call bookings already exist, skipping..."); + return; + } + + // Get all staff users (not customers) + const staffUsers = await db + .select({ id: usersTable.id }) + .from(usersTable) + .where(inArray(usersTable.userType, ["customer_support", "administrator"])); + + const agentIds = staffUsers.map((u) => u.id); + + if (agentIds.length === 0) { + console.log("No staff users found, skipping..."); + return; + } + + // Seed November 2025 - High volume (120 calls) + await seedCallBookingsForMonth(2025, 11, 30, agentIds, 120); + console.log("Seeded November 2025 - 120 calls"); + + // Seed December 2025 - Decrease (90 calls) + await seedCallBookingsForMonth(2025, 12, 31, agentIds, 90); + console.log("Seeded December 2025 - 90 calls"); + + // Seed January 2026 - Increase (110 calls) + await seedCallBookingsForMonth(2026, 1, 31, agentIds, 110); + console.log("Seeded January 2026 - 110 calls"); + + // Seed February 2026 - Decrease (80 calls) + await seedCallBookingsForMonth(2026, 2, 28, agentIds, 80); + console.log("Seeded February 2026 - 80 calls"); + + console.log("βœ… Call bookings seeding complete."); + } catch (error) { + console.error("❌ Seeding call bookings failed:", error); + throw error; + } +} diff --git a/8cbc_backend/drizzle/seeds/seed_call_scheduling.ts b/8cbc_backend/drizzle/seeds/seed_call_scheduling.ts index 677d9a57..5e295554 100644 --- a/8cbc_backend/drizzle/seeds/seed_call_scheduling.ts +++ b/8cbc_backend/drizzle/seeds/seed_call_scheduling.ts @@ -1,7 +1,6 @@ import { db } from "../../database/index.ts"; -import { usersTable, callSlotsTable, callBookingsTable } from "../../database/schema.ts"; -import { eq } from "drizzle-orm"; +import { callSlotsTable } from "../../database/schema.ts"; function formatDate(date: Date): string { return date.toISOString().split('T')[0]!; @@ -16,18 +15,6 @@ function addDays(date: Date, days: number): Date { export async function seedCallScheduling() { console.log("Seeding call scheduling..."); - const defaultUserEmail = "tanjingxianraymond@example.com"; - - // Find the user - const existingUsers = await db.select().from(usersTable).where(eq(usersTable.email, defaultUserEmail)); - - if (existingUsers.length === 0) { - console.error("User not found for call scheduling seeding. Ensure users are seeded first."); - return; - } - - const userId = existingUsers[0]!.id; - // Check if slots already exist const existingSlots = await db.select().from(callSlotsTable); @@ -68,55 +55,10 @@ export async function seedCallScheduling() { } await db.insert(callSlotsTable).values(slots); - console.log(`Created ${slots.length} call slots for the next 14 days.`); - } - - // Check if bookings already exist for this user - const existingBookings = await db.select().from(callBookingsTable).where(eq(callBookingsTable.userId, userId)); - - if (existingBookings.length > 0) { - console.log("User already has call bookings, skipping booking creation."); - } else { - // Get available slots to book - const availableSlots = await db.select().from(callSlotsTable).where(eq(callSlotsTable.isActive, true)); - - if (availableSlots.length >= 3) { - // Create a few sample bookings - const bookings = [ - { - userId, - slotId: availableSlots[0]!.id, - phoneNumber: "+6591234567", - category: "General Inquiry" as const, - reason: "I have questions about my account features and benefits.", - status: "Processing" as const, - }, - { - userId, - slotId: availableSlots[2]!.id, - phoneNumber: "+6591234567", - category: "Loan Inquiry" as const, - reason: "I would like to discuss loan options for a home purchase.", - status: "Pending" as const, - }, - { - userId, - slotId: availableSlots[4]!.id, - phoneNumber: "+6591234567", - category: "Technical Support" as const, - reason: "Having trouble with the mobile banking app.", - status: "Processing" as const, - }, - ]; - - await db.insert(callBookingsTable).values(bookings); - console.log(`Created ${bookings.length} sample call bookings for user.`); - } else { - console.log("Not enough available slots to create bookings."); - } + console.log(`βœ“ Created ${slots.length} call slots for the next 14 days.`); } - console.log("Call scheduling seeding complete."); + console.log("βœ… Call scheduling seeding complete."); } if (import.meta.main) { diff --git a/8cbc_backend/drizzle/seeds/seed_fraud_reports.ts b/8cbc_backend/drizzle/seeds/seed_fraud_reports.ts new file mode 100644 index 00000000..25a71e75 --- /dev/null +++ b/8cbc_backend/drizzle/seeds/seed_fraud_reports.ts @@ -0,0 +1,118 @@ +import { db } from "../../database/index.ts"; +import { + fraudReportsTable, + transactionsTable, + usersTable, +} from "../../database/schema.ts"; +import { eq } from "drizzle-orm"; + +const STATUSES = ["Open", "UnderReview", "Resolved", "Dismissed"] as const; +const REASONS = [ + "NotMine", + "WrongAmount", + "ChargedTwice", + "NotReceived", + "Other", +] as const; + +const DESCRIPTIONS = [ + "I did not authorize this transaction", + "The amount charged does not match my purchase", + "This transaction appears twice on my statement", + "I never received the service/product", + "Suspicious activity on my account", + "Card was used without permission", + "Fraudulent merchant transaction", + "Dispute with merchant over charges", +]; + +const randomElement = (arr: readonly T[]): T => + arr[Math.floor(Math.random() * arr.length)]!; + +const randomInt = (min: number, max: number): number => + Math.floor(Math.random() * (max - min + 1)) + min; + +export async function seedFraudReports() { + try { + console.log("🌱 Seeding fraud reports..."); + + // Check if fraud reports already exist + const existingReports = await db + .select() + .from(fraudReportsTable) + .limit(1); + + if (existingReports.length > 0) { + console.log("Fraud reports already exist, skipping..."); + return; + } + + // Get user ID (assuming user 1 exists from seedUsers) + const user = await db + .select() + .from(usersTable) + .where(eq(usersTable.id, 1)) + .limit(1); + + if (user.length === 0) { + console.log("No users found, skipping fraud report seeding"); + return; + } + + // Get some transactions to create fraud reports for + const transactions = await db + .select() + .from(transactionsTable) + .limit(15); + + if (transactions.length === 0) { + console.log("No transactions found, skipping fraud report seeding"); + return; + } + + let reportsCreated = 0; + + // Create fraud reports for some transactions + for (let i = 0; i < Math.min(10, transactions.length); i++) { + const transaction = transactions[i]!; + const status = randomElement(STATUSES); + const reason = randomElement(REASONS); + const description = randomElement(DESCRIPTIONS); + + const now = new Date(); + let resolvedAt: Date | null = null; + + // Set resolvedAt for resolved reports + if (status === "Resolved" || status === "Dismissed") { + resolvedAt = new Date( + now.getTime() + randomInt(1, 7) * 24 * 60 * 60 * 1000 + ); + } + + await db.insert(fraudReportsTable).values({ + reporterId: 1, + transactionId: transaction.transactionId, + accountId: transaction.fromAccountId || undefined, + reason: reason as typeof REASONS[number], + status: status as typeof STATUSES[number], + description: description, + handledBy: status !== "Open" ? randomInt(2, 4) : null, + resolutionNotes: + status === "Resolved" + ? "Fraud claim approved. Refund issued." + : status === "Dismissed" + ? "Claim investigated and found to be legitimate transaction." + : null, + resolvedAt: resolvedAt, + }); + + reportsCreated++; + } + + console.log(`βœ“ Created ${reportsCreated} fraud reports`); + console.log("βœ… Fraud reports seeding completed!"); + } catch (error) { + console.error("❌ Error seeding fraud reports:", error); + throw error; + } +} diff --git a/8cbc_backend/drizzle/seeds/seed_users.ts b/8cbc_backend/drizzle/seeds/seed_users.ts index 9f3d5f8a..f8c9a20f 100644 --- a/8cbc_backend/drizzle/seeds/seed_users.ts +++ b/8cbc_backend/drizzle/seeds/seed_users.ts @@ -6,27 +6,76 @@ import { eq } from "drizzle-orm"; export async function seedUsers() { console.log("Seeding users..."); - const defaultUser = { - name: "Tan Jing Xian Raymond", - email: "tanjingxianraymond@example.com", - // Hashed password for 'Password1234' - password: "$argon2id$v=19$m=65536,t=2,p=1$NgwLQ0youL9W4m/qRwfrFFIb8r+OIPu3A5E7fQzylRM$M8tmNYU4GJl06duNH1jx4ZzAEVzXkYmtpWCpmRvJDik", - phoneNumber: "+6591234567", - dateOfBirth: new Date("2000-01-01"), - userType: "customer" as const, - }; + const defaultUsers = [ + { + name: "Jessica Lim", + email: "jessica.lim@example.com", + // Hashed password for 'Password1234' + password: "$argon2id$v=19$m=65536,t=2,p=1$NgwLQ0youL9W4m/qRwfrFFIb8r+OIPu3A5E7fQzylRM$M8tmNYU4GJl06duNH1jx4ZzAEVzXkYmtpWCpmRvJDik", + phoneNumber: "+6599887766", + dateOfBirth: new Date("2001-09-10"), + userType: "customer" as const, + }, + { + name: "Tan Jing Xian Raymond", + email: "tanjingxianraymond@example.com", + // Hashed password for 'Password1234' + password: "$argon2id$v=19$m=65536,t=2,p=1$NgwLQ0youL9W4m/qRwfrFFIb8r+OIPu3A5E7fQzylRM$M8tmNYU4GJl06duNH1jx4ZzAEVzXkYmtpWCpmRvJDik", + phoneNumber: "+6591234567", + dateOfBirth: new Date("2000-01-01"), + userType: "customer" as const, + }, + { + name: "Sarah Chen", + email: "sarah.chen@8cbc.com", + // Hashed password for 'Password1234' + password: "$argon2id$v=19$m=65536,t=2,p=1$NgwLQ0youL9W4m/qRwfrFFIb8r+OIPu3A5E7fQzylRM$M8tmNYU4GJl06duNH1jx4ZzAEVzXkYmtpWCpmRvJDik", + phoneNumber: "+6598765432", + dateOfBirth: new Date("1995-03-15"), + userType: "customer_support" as const, + }, + { + name: "Michael Tan", + email: "michael.tan@8cbc.com", + // Hashed password for 'Password1234' + password: "$argon2id$v=19$m=65536,t=2,p=1$NgwLQ0youL9W4m/qRwfrFFIb8r+OIPu3A5E7fQzylRM$M8tmNYU4GJl06duNH1jx4ZzAEVzXkYmtpWCpmRvJDik", + phoneNumber: "+6591122334", + dateOfBirth: new Date("1992-07-22"), + userType: "customer_support" as const, + }, + { + name: "Priya Kumar", + email: "priya.kumar@8cbc.com", + // Hashed password for 'Password1234' + password: "$argon2id$v=19$m=65536,t=2,p=1$NgwLQ0youL9W4m/qRwfrFFIb8r+OIPu3A5E7fQzylRM$M8tmNYU4GJl06duNH1jx4ZzAEVzXkYmtpWCpmRvJDik", + phoneNumber: "+6594455667", + dateOfBirth: new Date("1998-11-08"), + userType: "administrator" as const, + }, + { + name: "Alex Wong", + email: "alex.wong@8cbc.com", + // Hashed password for 'Password1234' + password: "$argon2id$v=19$m=65536,t=2,p=1$NgwLQ0youL9W4m/qRwfrFFIb8r+OIPu3A5E7fQzylRM$M8tmNYU4GJl06duNH1jx4ZzAEVzXkYmtpWCpmRvJDik", + phoneNumber: "+6587654321", + dateOfBirth: new Date("1996-05-20"), + userType: "administrator" as const, + }, + ]; - // Check if user exists - const existing = await db.select().from(usersTable).where(eq(usersTable.email, defaultUser.email)); + // Add all users + for (const user of defaultUsers) { + const existing = await db.select().from(usersTable).where(eq(usersTable.email, user.email)); - if (existing.length > 0) { - console.log("User already exists, skipping creation."); - } else { - await db.insert(usersTable).values(defaultUser).returning(); - console.log("Created user:", defaultUser.email); + if (existing.length > 0) { + console.log("User already exists:", user.email); + } else { + await db.insert(usersTable).values(user).returning(); + console.log("Created user:", user.email); + } } - console.log("User seeding complete."); + console.log("βœ… User seeding complete."); } if (import.meta.main) { diff --git a/8cbc_backend/scripts/setup_db.ts b/8cbc_backend/scripts/setup_db.ts index 4da3a4f2..ab38a363 100644 --- a/8cbc_backend/scripts/setup_db.ts +++ b/8cbc_backend/scripts/setup_db.ts @@ -6,6 +6,8 @@ import util from "util"; import { seedUsers } from "../drizzle/seeds/seed_users.ts"; import { seedAccounts } from "../drizzle/seeds/seed_accounts.ts"; import { seedCallScheduling } from "../drizzle/seeds/seed_call_scheduling.ts"; +import { seedCallBookings } from "../drizzle/seeds/seed_call_bookings.ts"; +import { seedFraudReports } from "../drizzle/seeds/seed_fraud_reports.ts"; import { seedKnowledgebaseArticles, seedKnowledgebaseCategories, @@ -53,9 +55,15 @@ async function main() { // 5. Seed Transactions (Depends on Accounts and Categories) await seedTransactions(); - // 6. Seed Call Scheduling (Depends on Users) + // 6. Seed Fraud Reports (Depends on Transactions) + await seedFraudReports(); + + // 7. Seed Call Scheduling (Depends on Users) await seedCallScheduling(); + // 8. Seed Call Bookings (Depends on Call Slots from seedCallScheduling) + await seedCallBookings(); + await seedKnowledgebaseCategories(); await seedKnowledgebaseArticles(); diff --git a/8cbc_frontend/app/(admin)/admin/calls-statistics/page.tsx b/8cbc_frontend/app/(admin)/admin/calls-statistics/page.tsx index 547a035c..463e1878 100644 --- a/8cbc_frontend/app/(admin)/admin/calls-statistics/page.tsx +++ b/8cbc_frontend/app/(admin)/admin/calls-statistics/page.tsx @@ -143,7 +143,7 @@ const MonthFilterDropdown = ({ {open ? ( -
+
{options.map((option) => (
- - + + + { if (active && payload && payload.length) { + const avgData = payload.filter(p => typeof p.name === 'string' && p.name.includes('7-day avg')); + const dailyData = payload.filter(p => typeof p.name === 'string' && p.name.includes('daily')); + return ( -
-

{label}

- {payload.map((entry, index) => ( -

- {entry.name}: {Math.round(Number(entry.value))} calls -

- ))} +
+

{label}

+ +
+

7-day Rolling Average

+ {avgData.map((entry, index) => ( +
+
+
+ + {typeof entry.name === 'string' ? entry.name.replace(' (7-day avg)', '') : entry.name} + +
+ + {Math.round(Number(entry.value))} + +
+ ))} +
+ + {dailyData.length > 0 && ( +
+

Daily Values

+ {dailyData.map((entry, index) => ( +
+
+
+ + {typeof entry.name === 'string' ? entry.name.replace(' (daily)', '') : entry.name} + +
+ + {Math.round(Number(entry.value))} + +
+ ))} +
+ )}
); } @@ -935,7 +990,7 @@ export default function AdminCallStatisticsPage() { dataKey="completedCallsAvg" stroke="#0ea5e9" strokeWidth={2.5} - dot={{ r: 3 }} + dot={selectedMonth === "All" ? false : { r: 3 }} name="Completed (7-day avg)" /> { const fetchStatistics = useCallback(async () => { try { - const stats = await callStatisticsApi.getStatisticsSummary(); + // Calculate date range: 5 years back to 1 year forward to capture all available data + const today = new Date(); + const startDate = new Date(today); + startDate.setFullYear(startDate.getFullYear() - 5); + const endDate = new Date(today); + endDate.setFullYear(endDate.getFullYear() + 1); + + const stats = await callStatisticsApi.getStatisticsSummary({ + startDate: startDate.toISOString().split("T")[0], + endDate: endDate.toISOString().split("T")[0], + }); setStatistics(stats); } catch (error) { console.error("Failed to fetch statistics:", error);