2025-09-17 18:43:43 +09:00
|
|
|
/**
|
|
|
|
|
* Dashboard Utilities
|
|
|
|
|
* Helper functions for dashboard data processing and formatting
|
|
|
|
|
*/
|
|
|
|
|
|
2025-10-07 17:38:39 +09:00
|
|
|
import type { Activity, ActivityFilter, ActivityFilterConfig } from "@customer-portal/domain/dashboard";
|
2025-09-17 18:43:43 +09:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Activity filter configurations
|
|
|
|
|
*/
|
|
|
|
|
export const ACTIVITY_FILTERS: ActivityFilterConfig[] = [
|
|
|
|
|
{ key: "all", label: "All" },
|
|
|
|
|
{
|
|
|
|
|
key: "billing",
|
|
|
|
|
label: "Billing",
|
|
|
|
|
types: ["invoice_created", "invoice_paid"],
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
key: "orders",
|
|
|
|
|
label: "Orders",
|
|
|
|
|
types: ["service_activated"],
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
key: "support",
|
|
|
|
|
label: "Support",
|
|
|
|
|
types: ["case_created", "case_closed"],
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Filter activities by type
|
|
|
|
|
*/
|
|
|
|
|
export function filterActivities(activities: Activity[], filter: ActivityFilter): Activity[] {
|
|
|
|
|
if (filter === "all") {
|
|
|
|
|
return activities;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const filterConfig = ACTIVITY_FILTERS.find(f => f.key === filter);
|
|
|
|
|
if (!filterConfig?.types) {
|
|
|
|
|
return activities;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return activities.filter(activity => filterConfig.types!.includes(activity.type));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Check if an activity is clickable (navigable)
|
|
|
|
|
*/
|
|
|
|
|
export function isActivityClickable(activity: Activity): boolean {
|
|
|
|
|
const clickableTypes: Activity["type"][] = ["invoice_created", "invoice_paid"];
|
|
|
|
|
|
|
|
|
|
return clickableTypes.includes(activity.type) && !!activity.relatedId;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get navigation path for an activity
|
|
|
|
|
*/
|
|
|
|
|
export function getActivityNavigationPath(activity: Activity): string | null {
|
|
|
|
|
if (!isActivityClickable(activity) || !activity.relatedId) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
switch (activity.type) {
|
|
|
|
|
case "invoice_created":
|
|
|
|
|
case "invoice_paid":
|
|
|
|
|
return `/billing/invoices/${activity.relatedId}`;
|
|
|
|
|
case "service_activated":
|
|
|
|
|
return `/subscriptions/${activity.relatedId}`;
|
|
|
|
|
case "case_created":
|
|
|
|
|
case "case_closed":
|
|
|
|
|
return `/support/cases/${activity.relatedId}`;
|
|
|
|
|
default:
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Format activity date for display
|
|
|
|
|
*/
|
|
|
|
|
export function formatActivityDate(date: string): string {
|
|
|
|
|
try {
|
|
|
|
|
const activityDate = new Date(date);
|
|
|
|
|
const now = new Date();
|
|
|
|
|
const diffInHours = (now.getTime() - activityDate.getTime()) / (1000 * 60 * 60);
|
|
|
|
|
|
|
|
|
|
if (diffInHours < 1) {
|
|
|
|
|
const diffInMinutes = Math.floor(diffInHours * 60);
|
|
|
|
|
return diffInMinutes <= 1 ? "Just now" : `${diffInMinutes} minutes ago`;
|
|
|
|
|
} else if (diffInHours < 24) {
|
|
|
|
|
const hours = Math.floor(diffInHours);
|
|
|
|
|
return `${hours} hour${hours === 1 ? "" : "s"} ago`;
|
|
|
|
|
} else if (diffInHours < 48) {
|
|
|
|
|
return "Yesterday";
|
|
|
|
|
} else {
|
|
|
|
|
return activityDate.toLocaleDateString("en-US", {
|
|
|
|
|
month: "short",
|
|
|
|
|
day: "numeric",
|
|
|
|
|
year: activityDate.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
return "Unknown date";
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get activity icon gradient class
|
|
|
|
|
*/
|
|
|
|
|
export function getActivityIconGradient(activityType: Activity["type"]): string {
|
|
|
|
|
const gradientMap: Record<Activity["type"], string> = {
|
|
|
|
|
invoice_created: "from-blue-500 to-cyan-500",
|
|
|
|
|
invoice_paid: "from-green-500 to-emerald-500",
|
|
|
|
|
service_activated: "from-purple-500 to-pink-500",
|
|
|
|
|
case_created: "from-yellow-500 to-orange-500",
|
|
|
|
|
case_closed: "from-green-500 to-emerald-500",
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return gradientMap[activityType] || "from-gray-500 to-slate-500";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Truncate text to specified length
|
|
|
|
|
*/
|
|
|
|
|
export function truncateText(text: string, maxLength = 28): string {
|
|
|
|
|
if (text.length <= maxLength) {
|
|
|
|
|
return text;
|
|
|
|
|
}
|
|
|
|
|
return text.slice(0, Math.max(0, maxLength - 1)) + "…";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Generate dashboard task suggestions based on summary data
|
|
|
|
|
*/
|
|
|
|
|
export function generateDashboardTasks(summary: {
|
|
|
|
|
nextInvoice?: { id: number } | null;
|
|
|
|
|
stats?: { unpaidInvoices?: number; openCases?: number };
|
|
|
|
|
}): Array<{ label: string; href: string }> {
|
|
|
|
|
const tasks: Array<{ label: string; href: string }> = [];
|
|
|
|
|
|
|
|
|
|
if (summary.nextInvoice) {
|
|
|
|
|
tasks.push({
|
|
|
|
|
label: "Pay upcoming invoice",
|
|
|
|
|
href: "#attention",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (summary.stats?.unpaidInvoices && summary.stats.unpaidInvoices > 0) {
|
|
|
|
|
tasks.push({
|
|
|
|
|
label: "Review unpaid invoices",
|
|
|
|
|
href: "/billing/invoices",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (summary.stats?.openCases && summary.stats.openCases > 0) {
|
|
|
|
|
tasks.push({
|
|
|
|
|
label: "Check support cases",
|
|
|
|
|
href: "/support/cases",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return tasks;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Calculate dashboard loading progress
|
|
|
|
|
*/
|
|
|
|
|
export function calculateLoadingProgress(loadingStates: Record<string, boolean>): number {
|
|
|
|
|
const states = Object.values(loadingStates);
|
|
|
|
|
const completedCount = states.filter(loading => !loading).length;
|
|
|
|
|
return Math.round((completedCount / states.length) * 100);
|
|
|
|
|
}
|