Update dashboard components and styles for improved functionality and consistency

- Refactored DashboardView to integrate new TaskList, QuickStats, and ActivityFeed components for enhanced task management and user engagement.
- Updated global styles in tokens.css to standardize color usage and improve accessibility across the application.
- Adjusted color tokens for better visual coherence, including updates to brand and semantic colors.
- Enhanced loading states and skeleton screens for a smoother user experience during data fetching.
This commit is contained in:
barsa 2025-12-16 17:10:14 +09:00
parent 9d6c7dcde0
commit 1f7f77775b
11 changed files with 1486 additions and 383 deletions

View File

@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@ -9,149 +9,306 @@
:root {
--radius: 0.625rem;
/* Core neutrals (light) */
/* Background and cards are both white for clean look */
/* ============= CORE NEUTRALS ============= */
--background: oklch(1 0 0); /* pure white for main content */
--foreground: oklch(0.16 0 0);
--card: oklch(1 0 0); /* pure white for cards */
--card-foreground: var(--foreground);
--card-muted: oklch(0.98 0.003 250); /* subtle gray for nested/muted cards */
--card-muted: oklch(0.98 0.008 234.4); /* subtle brand-tinted gray */
--popover: oklch(1 0 0);
--popover-foreground: var(--foreground);
/* Primary brand - matches logo cyan blue */
--primary: oklch(0.68 0.14 220); /* bright cyan blue from logo */
/* ============= BRAND COLORS (from logo) ============= */
/* Primary Light Blue - Brand Accent */
--primary: oklch(0.6884 0.1342 234.4);
--primary-foreground: oklch(0.99 0 0);
/* Interaction shades */
--primary-hover: oklch(0.63 0.14 220);
--primary-active: oklch(0.58 0.14 220);
--primary-hover: oklch(0.62 0.1342 234.4);
--primary-active: oklch(0.56 0.1342 234.4);
/* Subtle surfaces & text */
--secondary: oklch(0.95 0.003 250); /* subtle cool gray */
/* Subtle surfaces & text - tinted with primary brand hue */
--secondary: oklch(0.95 0.015 234.4);
--secondary-foreground: oklch(0.29 0 0);
--muted: oklch(0.96 0.003 250); /* subtle cool gray for surfaces */
--muted: oklch(0.96 0.008 234.4);
--muted-foreground: oklch(0.5 0 0);
/* Light accent (tinted with brand) */
--accent: oklch(0.95 0.05 220); /* light cyan tint */
--accent: oklch(0.95 0.04 234.4);
--accent-foreground: var(--foreground);
/* Feedback (now with full semantic set) */
--destructive: oklch(0.62 0.21 27);
--destructive-foreground: oklch(0.99 0 0);
--destructive-soft: oklch(0.96 0.05 27);
/* ============= SEMANTIC STATUS COLORS ============= */
/* Three-tier system: text (dark readable), bg (almost white), border (subtle accent) */
/* Reserve brand cyan for primary interaction; status colors are secondary */
--success: oklch(0.67 0.14 150);
/* Success - #166534 text, #F0FDF4 bg, #BBF7D0 border */
--success: oklch(0.42 0.1 145); /* dark green for text/icons */
--success-foreground: oklch(0.99 0 0);
--success-soft: oklch(0.95 0.05 150);
--success-bg: oklch(0.98 0.02 145); /* very light green bg */
--success-border: oklch(0.93 0.08 150); /* subtle green border */
--warning: oklch(0.78 0.16 90);
--warning-foreground: oklch(0.16 0 0);
--warning-soft: oklch(0.96 0.07 90);
--info: oklch(0.68 0.14 220); /* matches primary */
/* Info - uses primary brand hue */
--info: oklch(0.48 0.1 234.4);
--info-foreground: oklch(0.99 0 0);
--info-soft: oklch(0.95 0.06 220);
--info-bg: oklch(0.97 0.02 234.4);
--info-border: oklch(0.91 0.05 234.4);
/* UI chrome */
--border: oklch(0.9 0 0);
--border-muted: oklch(0.92 0 0);
--input: oklch(0.88 0 0); /* slightly darker for inputs */
--ring: oklch(0.72 0.12 220);
/* Warning - #92400E text, #FFFBEB bg, #FDE68A border */
--warning: oklch(0.45 0.12 55); /* dark amber for text/icons */
--warning-foreground: oklch(0.99 0 0);
--warning-bg: oklch(0.99 0.02 90); /* very light amber bg */
--warning-border: oklch(0.92 0.12 90); /* subtle amber border */
/* Error - #9F1239 text, #FFF1F2 bg, #FECDD3 border (Overdue) */
--error: oklch(0.42 0.16 0); /* dark rose for text/icons */
--error-foreground: oklch(0.99 0 0);
--error-bg: oklch(0.98 0.01 0); /* very light rose bg */
--error-border: oklch(0.89 0.06 0); /* subtle rose border */
/* Destructive - #991B1B text, #FEF2F2 bg, #FECACA border (Terminated) */
--destructive: oklch(0.43 0.15 25); /* dark red for text/icons */
--destructive-foreground: oklch(0.99 0 0);
--destructive-bg: oklch(0.98 0.01 20); /* very light red bg */
--destructive-border: oklch(0.89 0.06 20); /* subtle red border */
/* Neutral - uses sidebar brand hue */
--neutral: oklch(0.36 0.03 272.34);
--neutral-foreground: oklch(0.99 0 0);
--neutral-bg: oklch(0.97 0.008 272.34);
--neutral-border: oklch(0.87 0.02 272.34);
/* ============= GRANULAR STATUS COLORS ============= */
/* Three-tier: text (dark readable), bg (almost white), border (subtle accent) */
/* Active - uses primary brand hue */
--status-active: oklch(0.48 0.1 234.4);
--status-active-bg: oklch(0.97 0.02 234.4);
--status-active-border: oklch(0.91 0.05 234.4);
/* Completed - uses sidebar brand hue */
--status-completed: oklch(0.2754 0.1199 272.34);
--status-completed-bg: oklch(0.96 0.02 272.34);
--status-completed-border: oklch(0.87 0.07 272.34);
/* Paid - #0F766E text, #ECFDF5 bg, #A7F3D0 border (teal, closer to brand) */
--status-paid: oklch(0.48 0.08 175);
--status-paid-bg: oklch(0.98 0.02 165);
--status-paid-border: oklch(0.92 0.08 160);
/* Success - #166534 text, #F0FDF4 bg, #BBF7D0 border */
--status-success: oklch(0.42 0.1 145);
--status-success-bg: oklch(0.98 0.02 145);
--status-success-border: oklch(0.93 0.08 150);
/* Pending - #92400E text, #FFFBEB bg, #FDE68A border */
--status-pending: oklch(0.45 0.12 55);
--status-pending-bg: oklch(0.99 0.02 90);
--status-pending-border: oklch(0.92 0.12 90);
/* Suspended - uses sidebar brand hue */
--status-suspended: oklch(0.36 0.03 272.34);
--status-suspended-bg: oklch(0.97 0.008 272.34);
--status-suspended-border: oklch(0.87 0.02 272.34);
/* Unpaid - #9A3412 text, #FFF7ED bg, #FED7AA border */
--status-unpaid: oklch(0.46 0.13 45);
--status-unpaid-bg: oklch(0.98 0.02 70);
--status-unpaid-border: oklch(0.9 0.08 65);
/* Terminated - #991B1B text, #FEF2F2 bg, #FECACA border */
--status-terminated: oklch(0.43 0.15 25);
--status-terminated-bg: oklch(0.98 0.01 20);
--status-terminated-border: oklch(0.89 0.06 20);
/* Overdue - #9F1239 text, #FFF1F2 bg, #FECDD3 border */
--status-overdue: oklch(0.42 0.16 0);
--status-overdue-bg: oklch(0.98 0.01 0);
--status-overdue-border: oklch(0.89 0.06 0);
/* ============= SOFT COLOR VARIANTS (for badges/pills) ============= */
/* These use the -bg colors for backgrounds with text colors for contrast */
--success-soft: var(--success-bg);
--info-soft: var(--info-bg);
--warning-soft: var(--warning-bg);
--destructive-soft: var(--destructive-bg);
--neutral-soft: var(--neutral-bg);
/* ============= UI CHROME ============= */
--border: oklch(0.9 0.005 234.4);
--border-muted: oklch(0.92 0.003 234.4);
--input: oklch(0.88 0.005 234.4);
--ring: oklch(0.72 0.12 234.4);
--ring-subtle: color-mix(in oklch, var(--ring) 45%, transparent);
/* Charts */
--chart-1: oklch(0.68 0.14 220); /* cyan from logo */
--chart-2: oklch(0.28 0.1 265); /* navy from logo */
--chart-3: oklch(0.64 0.19 147);
--chart-4: oklch(0.7 0.16 82);
--chart-5: oklch(0.68 0.16 28);
/* ============= CHARTS ============= */
/* Harmonious palette based on brand and status colors */
--chart-1: oklch(0.6884 0.1342 234.4); /* Primary brand blue */
--chart-2: oklch(0.65 0.12 145); /* Success - green */
--chart-3: oklch(0.75 0.14 85); /* Pending - amber */
--chart-4: oklch(0.55 0.16 38); /* Overdue - orange-red */
--chart-5: oklch(0.2754 0.1199 272.34); /* Sidebar brand navy */
/* Sidebar - Brand navy blue from logo */
--sidebar: oklch(0.28 0.1 265); /* deep navy blue from logo */
--sidebar-foreground: oklch(1 0 0); /* pure white text */
--sidebar-primary: oklch(1 0 0); /* white for emphasis */
--sidebar-primary-foreground: oklch(0.28 0.1 265);
--sidebar-accent: oklch(0.35 0.09 265); /* lighter navy for hover */
/* ============= SIDEBAR ============= */
/* Primary Dark Blue - Brand Anchor (Logo Color) */
--sidebar: oklch(0.2754 0.1199 272.34);
--sidebar-foreground: oklch(1 0 0);
--sidebar-primary: oklch(0.6884 0.1342 234.4); /* primary brand blue for accent */
--sidebar-primary-foreground: oklch(1 0 0);
--sidebar-accent: oklch(0.36 0.1199 272.34);
--sidebar-accent-foreground: oklch(1 0 0);
--sidebar-border: oklch(0.35 0.08 265); /* lighter navy border */
--sidebar-ring: oklch(0.72 0.12 220);
--sidebar-border: oklch(0.36 0.1 272.34);
--sidebar-ring: oklch(0.72 0.12 234.4);
}
.dark {
/* Core neutrals (dark) */
--background: oklch(0.15 0 0);
/* ============= CORE NEUTRALS (DARK) ============= */
--background: oklch(0.15 0.01 272.34);
--foreground: oklch(0.98 0 0);
--card: oklch(0.18 0 0);
--card: oklch(0.18 0.01 272.34);
--card-foreground: var(--foreground);
--card-muted: oklch(0.22 0 0); /* subtle lighter gray for nested cards */
--popover: oklch(0.18 0 0);
--card-muted: oklch(0.22 0.01 272.34);
--popover: oklch(0.18 0.01 272.34);
--popover-foreground: var(--foreground);
/* Primary brand - lighter cyan for dark mode */
--primary: oklch(0.75 0.12 220);
/* ============= BRAND COLORS (DARK MODE) ============= */
/* Lighter brand blue for dark mode visibility */
--primary: oklch(0.76 0.12 234.4);
--primary-foreground: oklch(0.15 0 0);
--primary-hover: oklch(0.7 0.12 220);
--primary-active: oklch(0.65 0.12 220);
--primary-hover: oklch(0.7 0.12 234.4);
--primary-active: oklch(0.65 0.12 234.4);
/* Subtle surfaces & text */
--secondary: oklch(0.22 0 0);
/* Subtle surfaces & text - tinted with sidebar hue */
--secondary: oklch(0.22 0.01 272.34);
--secondary-foreground: oklch(0.9 0 0);
--muted: oklch(0.25 0 0);
--muted: oklch(0.25 0.01 272.34);
--muted-foreground: oklch(0.74 0 0);
/* Accent (tinted with brand) */
--accent: oklch(0.24 0.04 220);
--accent: oklch(0.24 0.03 234.4);
--accent-foreground: oklch(0.92 0 0);
/* Feedback */
--destructive: oklch(0.7 0.21 27);
--destructive-foreground: oklch(0.15 0 0);
--destructive-soft: oklch(0.25 0.05 27);
/* ============= SEMANTIC STATUS COLORS (DARK) ============= */
/* Three-tier: text (lighter for dark bg), bg (dark subtle), border (mid accent) */
--success: oklch(0.76 0.14 150);
/* Success - lighter green for dark mode */
--success: oklch(0.72 0.1 145);
--success-foreground: oklch(0.15 0 0);
--success-soft: oklch(0.24 0.05 150);
--success-bg: oklch(0.24 0.04 145);
--success-border: oklch(0.38 0.08 150);
--warning: oklch(0.86 0.16 90);
--warning-foreground: oklch(0.15 0 0);
--warning-soft: oklch(0.26 0.07 90);
--info: oklch(0.75 0.12 220);
/* Info - uses primary brand hue */
--info: oklch(0.72 0.1 234.4);
--info-foreground: oklch(0.15 0 0);
--info-soft: oklch(0.24 0.05 220);
--info-bg: oklch(0.24 0.04 234.4);
--info-border: oklch(0.38 0.07 234.4);
/* UI chrome */
--border: oklch(0.32 0 0);
--border-muted: oklch(0.28 0 0);
--input: oklch(0.35 0 0); /* slightly lighter for inputs */
--ring: oklch(0.78 0.1 220);
/* Warning - lighter amber for dark mode */
--warning: oklch(0.78 0.12 55);
--warning-foreground: oklch(0.15 0 0);
--warning-bg: oklch(0.26 0.04 90);
--warning-border: oklch(0.42 0.1 90);
/* Error - lighter rose for dark mode */
--error: oklch(0.72 0.14 0);
--error-foreground: oklch(0.15 0 0);
--error-bg: oklch(0.24 0.03 0);
--error-border: oklch(0.38 0.08 0);
/* Destructive - lighter red for dark mode */
--destructive: oklch(0.7 0.14 25);
--destructive-foreground: oklch(0.15 0 0);
--destructive-bg: oklch(0.24 0.03 20);
--destructive-border: oklch(0.38 0.08 20);
/* Neutral - uses sidebar brand hue */
--neutral: oklch(0.7 0.03 272.34);
--neutral-foreground: oklch(0.15 0 0);
--neutral-bg: oklch(0.24 0.02 272.34);
--neutral-border: oklch(0.38 0.03 272.34);
/* ============= GRANULAR STATUS COLORS (DARK) ============= */
/* Active - uses primary brand hue */
--status-active: oklch(0.72 0.1 234.4);
--status-active-bg: oklch(0.24 0.04 234.4);
--status-active-border: oklch(0.38 0.07 234.4);
/* Completed - uses sidebar brand hue */
--status-completed: oklch(0.55 0.1 272.34);
--status-completed-bg: oklch(0.24 0.04 272.34);
--status-completed-border: oklch(0.38 0.07 272.34);
/* Paid */
--status-paid: oklch(0.7 0.08 175);
--status-paid-bg: oklch(0.24 0.04 165);
--status-paid-border: oklch(0.4 0.08 160);
/* Success */
--status-success: oklch(0.72 0.1 145);
--status-success-bg: oklch(0.24 0.04 145);
--status-success-border: oklch(0.38 0.08 150);
/* Pending */
--status-pending: oklch(0.78 0.12 55);
--status-pending-bg: oklch(0.26 0.04 90);
--status-pending-border: oklch(0.42 0.1 90);
/* Suspended - uses sidebar brand hue */
--status-suspended: oklch(0.7 0.03 272.34);
--status-suspended-bg: oklch(0.24 0.02 272.34);
--status-suspended-border: oklch(0.38 0.03 272.34);
/* Unpaid */
--status-unpaid: oklch(0.76 0.12 45);
--status-unpaid-bg: oklch(0.26 0.04 70);
--status-unpaid-border: oklch(0.42 0.08 65);
/* Terminated */
--status-terminated: oklch(0.7 0.14 25);
--status-terminated-bg: oklch(0.24 0.03 20);
--status-terminated-border: oklch(0.38 0.08 20);
/* Overdue */
--status-overdue: oklch(0.72 0.14 0);
--status-overdue-bg: oklch(0.24 0.03 0);
--status-overdue-border: oklch(0.38 0.08 0);
/* ============= SOFT COLOR VARIANTS (for badges/pills) ============= */
--success-soft: var(--success-bg);
--info-soft: var(--info-bg);
--warning-soft: var(--warning-bg);
--destructive-soft: var(--destructive-bg);
--neutral-soft: var(--neutral-bg);
/* ============= UI CHROME (DARK) ============= */
--border: oklch(0.32 0.02 272.34);
--border-muted: oklch(0.28 0.01 272.34);
--input: oklch(0.35 0.02 272.34);
--ring: oklch(0.78 0.11 234.4);
--ring-subtle: color-mix(in oklch, var(--ring) 40%, transparent);
/* Charts */
--chart-1: oklch(0.75 0.12 220); /* cyan from logo */
--chart-2: oklch(0.45 0.08 265); /* navy from logo */
--chart-3: oklch(0.7 0.18 147);
--chart-4: oklch(0.74 0.17 82);
--chart-5: oklch(0.72 0.17 28);
/* ============= CHARTS (DARK) ============= */
--chart-1: oklch(0.76 0.12 234.4); /* Primary brand blue */
--chart-2: oklch(0.72 0.12 145); /* Success - green */
--chart-3: oklch(0.82 0.14 85); /* Pending - amber */
--chart-4: oklch(0.65 0.15 38); /* Overdue - orange-red */
--chart-5: oklch(0.5 0.1 272.34); /* Sidebar brand navy */
/* Sidebar - Dark navy from logo */
--sidebar: oklch(0.2 0.08 265); /* darker navy */
--sidebar-foreground: oklch(1 0 0); /* pure white */
--sidebar-primary: oklch(1 0 0);
--sidebar-primary-foreground: oklch(0.2 0.08 265);
--sidebar-accent: oklch(0.28 0.07 265); /* lighter on hover */
/* ============= SIDEBAR (DARK) ============= */
--sidebar: oklch(0.22 0.1199 272.34);
--sidebar-foreground: oklch(1 0 0);
--sidebar-primary: oklch(0.76 0.12 234.4); /* lighter primary brand blue */
--sidebar-primary-foreground: oklch(0.15 0 0);
--sidebar-accent: oklch(0.32 0.1 272.34);
--sidebar-accent-foreground: oklch(1 0 0);
--sidebar-border: oklch(0.28 0.06 265);
--sidebar-border: oklch(0.3 0.08 272.34);
--sidebar-ring: var(--ring);
}
/* Tailwind v4 token map (add the new ones) */
/* Tailwind v4 token map */
@theme {
/* Core */
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
@ -160,6 +317,7 @@
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
/* Brand */
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-primary-hover: var(--primary-hover);
@ -174,34 +332,94 @@
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-destructive-soft: var(--destructive-soft);
/* Semantic status colors (three-tier: text, bg, border + soft for badges) */
--color-success: var(--success);
--color-success-foreground: var(--success-foreground);
--color-success-bg: var(--success-bg);
--color-success-border: var(--success-border);
--color-success-soft: var(--success-soft);
--color-warning: var(--warning);
--color-warning-foreground: var(--warning-foreground);
--color-warning-soft: var(--warning-soft);
--color-info: var(--info);
--color-info-foreground: var(--info-foreground);
--color-info-bg: var(--info-bg);
--color-info-border: var(--info-border);
--color-info-soft: var(--info-soft);
--color-warning: var(--warning);
--color-warning-foreground: var(--warning-foreground);
--color-warning-bg: var(--warning-bg);
--color-warning-border: var(--warning-border);
--color-warning-soft: var(--warning-soft);
--color-error: var(--error);
--color-error-foreground: var(--error-foreground);
--color-error-bg: var(--error-bg);
--color-error-border: var(--error-border);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-destructive-bg: var(--destructive-bg);
--color-destructive-border: var(--destructive-border);
--color-destructive-soft: var(--destructive-soft);
--color-neutral: var(--neutral);
--color-neutral-foreground: var(--neutral-foreground);
--color-neutral-bg: var(--neutral-bg);
--color-neutral-border: var(--neutral-border);
--color-neutral-soft: var(--neutral-soft);
/* Granular status colors (three-tier: text, bg, border) */
--color-status-active: var(--status-active);
--color-status-active-bg: var(--status-active-bg);
--color-status-active-border: var(--status-active-border);
--color-status-completed: var(--status-completed);
--color-status-completed-bg: var(--status-completed-bg);
--color-status-completed-border: var(--status-completed-border);
--color-status-paid: var(--status-paid);
--color-status-paid-bg: var(--status-paid-bg);
--color-status-paid-border: var(--status-paid-border);
--color-status-success: var(--status-success);
--color-status-success-bg: var(--status-success-bg);
--color-status-success-border: var(--status-success-border);
--color-status-pending: var(--status-pending);
--color-status-pending-bg: var(--status-pending-bg);
--color-status-pending-border: var(--status-pending-border);
--color-status-suspended: var(--status-suspended);
--color-status-suspended-bg: var(--status-suspended-bg);
--color-status-suspended-border: var(--status-suspended-border);
--color-status-unpaid: var(--status-unpaid);
--color-status-unpaid-bg: var(--status-unpaid-bg);
--color-status-unpaid-border: var(--status-unpaid-border);
--color-status-terminated: var(--status-terminated);
--color-status-terminated-bg: var(--status-terminated-bg);
--color-status-terminated-border: var(--status-terminated-border);
--color-status-overdue: var(--status-overdue);
--color-status-overdue-bg: var(--status-overdue-bg);
--color-status-overdue-border: var(--status-overdue-border);
/* UI chrome */
--color-border: var(--border);
--color-border-muted: var(--border-muted);
--color-input: var(--input);
--color-ring: var(--ring);
--color-ring-subtle: var(--ring-subtle);
/* Charts */
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
/* Sidebar */
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);

View File

@ -0,0 +1,195 @@
"use client";
import { useRouter } from "next/navigation";
import {
DocumentTextIcon,
CheckCircleIcon,
ServerIcon,
ChatBubbleLeftRightIcon,
ArrowTrendingUpIcon,
ChevronRightIcon,
} from "@heroicons/react/24/outline";
import type { Activity } from "@customer-portal/domain/dashboard";
import { formatActivityDate, getActivityNavigationPath } from "../utils/dashboard.utils";
import { cn } from "@/lib/utils";
interface ActivityFeedProps {
activities: Activity[];
maxItems?: number;
isLoading?: boolean;
className?: string;
}
const ICON_COMPONENTS: Record<
Activity["type"],
React.ComponentType<React.SVGProps<SVGSVGElement>>
> = {
invoice_created: DocumentTextIcon,
invoice_paid: CheckCircleIcon,
service_activated: ServerIcon,
case_created: ChatBubbleLeftRightIcon,
case_closed: CheckCircleIcon,
};
const ICON_STYLES: Record<Activity["type"], { bg: string; color: string }> = {
invoice_created: { bg: "bg-info/10", color: "text-info" },
invoice_paid: { bg: "bg-success/10", color: "text-success" },
service_activated: { bg: "bg-primary/10", color: "text-primary" },
case_created: { bg: "bg-warning/10", color: "text-warning" },
case_closed: { bg: "bg-success/10", color: "text-success" },
};
interface ActivityItemProps {
activity: Activity;
isLast?: boolean;
}
function ActivityItem({ activity, isLast = false }: ActivityItemProps) {
const router = useRouter();
const Icon = ICON_COMPONENTS[activity.type] ?? DocumentTextIcon;
const styles = ICON_STYLES[activity.type] ?? { bg: "bg-muted", color: "text-muted-foreground" };
const timeAgo = formatActivityDate(activity.date);
const navPath = getActivityNavigationPath(activity);
const isClickable = Boolean(navPath);
const content = (
<div className="flex items-start gap-3 py-3">
{/* Icon with timeline connector */}
<div className="relative flex-shrink-0">
<div
className={cn(
"h-8 w-8 rounded-lg flex items-center justify-center",
styles.bg,
isClickable && "group-hover:scale-105 transition-transform"
)}
>
<Icon className={cn("h-4 w-4", styles.color)} />
</div>
{/* Timeline connector */}
{!isLast && (
<div className="absolute top-10 left-1/2 -translate-x-1/2 w-px h-[calc(100%-0.5rem)] bg-border/60" />
)}
</div>
{/* Content */}
<div className="flex-1 min-w-0 pt-0.5">
<p
className={cn(
"text-sm font-medium text-foreground leading-snug",
isClickable && "group-hover:text-primary transition-colors"
)}
>
{activity.title}
</p>
<p className="text-xs text-muted-foreground mt-0.5">{timeAgo}</p>
</div>
{/* Arrow for clickable items */}
{isClickable && (
<ChevronRightIcon className="h-4 w-4 text-muted-foreground/40 group-hover:text-primary group-hover:translate-x-0.5 transition-all flex-shrink-0 mt-1" />
)}
</div>
);
if (isClickable) {
return (
<button
type="button"
onClick={() => router.push(navPath!)}
className="group w-full text-left rounded-lg hover:bg-muted/50 transition-colors px-2 -mx-2"
>
{content}
</button>
);
}
return <div className="px-2 -mx-2">{content}</div>;
}
function ActivityItemSkeleton({ isLast = false }: { isLast?: boolean }) {
return (
<div className="flex items-start gap-3 py-3 px-2 -mx-2 animate-pulse">
<div className="relative flex-shrink-0">
<div className="h-8 w-8 rounded-lg bg-muted" />
{!isLast && (
<div className="absolute top-10 left-1/2 -translate-x-1/2 w-px h-[calc(100%-0.5rem)] bg-muted" />
)}
</div>
<div className="flex-1 min-w-0 pt-0.5 space-y-1.5">
<div className="h-4 bg-muted rounded w-3/4" />
<div className="h-3 bg-muted rounded w-16" />
</div>
</div>
);
}
function EmptyActivity() {
return (
<div className="relative overflow-hidden rounded-xl bg-gradient-to-br from-muted/30 to-muted/10 border border-border/40 text-center py-10 px-6">
{/* Decorative elements */}
<div className="absolute inset-0 overflow-hidden">
<div className="absolute -top-12 -right-12 w-24 h-24 rounded-full bg-primary/5 blur-2xl" />
<div className="absolute -bottom-12 -left-12 w-24 h-24 rounded-full bg-info/5 blur-2xl" />
</div>
<div className="relative">
<div className="w-14 h-14 rounded-xl bg-muted/50 flex items-center justify-center mx-auto mb-4">
<ArrowTrendingUpIcon className="h-7 w-7 text-muted-foreground/50" />
</div>
<h4 className="text-sm font-semibold text-foreground">No recent activity</h4>
<p className="text-xs text-muted-foreground mt-1 max-w-[200px] mx-auto">
Your account activity will appear here as you use our services.
</p>
</div>
</div>
);
}
/**
* ActivityFeed - Beautiful activity timeline for dashboard
*/
export function ActivityFeed({
activities,
maxItems = 5,
isLoading = false,
className,
}: ActivityFeedProps) {
if (isLoading) {
return (
<div className={cn("space-y-4", className)}>
<div className="flex items-center justify-between">
<h3 className="text-base font-semibold text-foreground">Recent Activity</h3>
</div>
<div className="bg-surface border border-border/60 rounded-xl p-4">
{Array.from({ length: 3 }).map((_, i) => (
<ActivityItemSkeleton key={i} isLast={i === 2} />
))}
</div>
</div>
);
}
const visibleActivities = activities.slice(0, maxItems);
return (
<div className={cn("space-y-4", className)}>
<div className="flex items-center justify-between">
<h3 className="text-base font-semibold text-foreground">Recent Activity</h3>
</div>
{visibleActivities.length === 0 ? (
<EmptyActivity />
) : (
<div className="bg-surface border border-border/60 rounded-xl p-4">
{visibleActivities.map((activity, index) => (
<ActivityItem
key={activity.id}
activity={activity}
isLast={index === visibleActivities.length - 1}
/>
))}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,163 @@
"use client";
import Link from "next/link";
import {
ServerIcon,
ChatBubbleLeftRightIcon,
ClipboardDocumentListIcon,
ArrowRightIcon,
} from "@heroicons/react/24/outline";
import { cn } from "@/lib/utils";
interface QuickStatsProps {
activeSubscriptions: number;
openCases: number;
recentOrders?: number;
isLoading?: boolean;
className?: string;
}
type StatTone = "primary" | "info" | "warning" | "success";
interface StatItemProps {
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
label: string;
value: number;
href: string;
tone?: StatTone;
emptyText?: string;
}
const toneStyles: Record<StatTone, { iconBg: string; iconColor: string; hoverBorder: string }> = {
primary: {
iconBg: "bg-primary/10 group-hover:bg-primary/20",
iconColor: "text-primary",
hoverBorder: "hover:border-primary/30",
},
info: {
iconBg: "bg-info/10 group-hover:bg-info/20",
iconColor: "text-info",
hoverBorder: "hover:border-info/30",
},
warning: {
iconBg: "bg-warning/10 group-hover:bg-warning/20",
iconColor: "text-warning",
hoverBorder: "hover:border-warning/30",
},
success: {
iconBg: "bg-success/10 group-hover:bg-success/20",
iconColor: "text-success",
hoverBorder: "hover:border-success/30",
},
};
function StatItem({ icon: Icon, label, value, href, tone = "primary", emptyText }: StatItemProps) {
const styles = toneStyles[tone];
return (
<Link
href={href}
className={cn(
"group flex items-center gap-4 p-4 rounded-xl",
"bg-surface border border-border/60",
"transition-all duration-[var(--cp-duration-normal)]",
"hover:shadow-md",
styles.hoverBorder
)}
>
<div
className={cn(
"flex-shrink-0 h-11 w-11 rounded-xl flex items-center justify-center",
"transition-colors",
styles.iconBg
)}
>
<Icon className={cn("h-5 w-5", styles.iconColor)} />
</div>
<div className="min-w-0 flex-1">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">{label}</p>
{value > 0 ? (
<p className="text-2xl font-bold text-foreground tabular-nums mt-0.5">{value}</p>
) : (
<p className="text-sm text-muted-foreground mt-1">{emptyText || "None"}</p>
)}
</div>
<ArrowRightIcon className="h-4 w-4 text-muted-foreground/40 group-hover:text-foreground group-hover:translate-x-0.5 transition-all flex-shrink-0" />
</Link>
);
}
function StatItemSkeleton() {
return (
<div className="flex items-center gap-4 p-4 rounded-xl bg-surface border border-border/60 animate-pulse">
<div className="flex-shrink-0 h-11 w-11 rounded-xl bg-muted" />
<div className="min-w-0 flex-1 space-y-2">
<div className="h-3 bg-muted rounded w-20" />
<div className="h-7 bg-muted rounded w-10" />
</div>
<div className="h-4 w-4 bg-muted rounded flex-shrink-0" />
</div>
);
}
/**
* QuickStats - Beautiful stats display for dashboard
*/
export function QuickStats({
activeSubscriptions,
openCases,
recentOrders,
isLoading = false,
className,
}: QuickStatsProps) {
if (isLoading) {
return (
<div className={cn("space-y-4", className)}>
<div className="flex items-center justify-between">
<h3 className="text-base font-semibold text-foreground">Account Overview</h3>
</div>
<div className="space-y-3">
<StatItemSkeleton />
<StatItemSkeleton />
<StatItemSkeleton />
</div>
</div>
);
}
return (
<div className={cn("space-y-4", className)}>
<div className="flex items-center justify-between">
<h3 className="text-base font-semibold text-foreground">Account Overview</h3>
</div>
<div className="space-y-3">
<StatItem
icon={ServerIcon}
label="Active Services"
value={activeSubscriptions}
href="/subscriptions"
tone="primary"
emptyText="No active services"
/>
<StatItem
icon={ChatBubbleLeftRightIcon}
label="Open Support Cases"
value={openCases}
href="/support/cases"
tone={openCases > 0 ? "warning" : "info"}
emptyText="No open cases"
/>
{recentOrders !== undefined && (
<StatItem
icon={ClipboardDocumentListIcon}
label="Recent Orders"
value={recentOrders}
href="/orders"
tone="success"
emptyText="No recent orders"
/>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,175 @@
"use client";
import Link from "next/link";
import { ChevronRightIcon } from "@heroicons/react/24/outline";
import { cn } from "@/lib/utils";
import { Button } from "@/components/atoms/button";
export type TaskTone = "critical" | "warning" | "info" | "neutral";
export interface TaskCardProps {
/** Unique identifier for the task */
id: string;
/** Icon component to display */
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
/** Task title */
title: string;
/** Task description */
description: string;
/** Action button label */
actionLabel: string;
/** Link destination for the card click (navigates to detail page) */
detailHref?: string;
/** Click handler for the action button */
onAction?: () => void;
/** Visual tone based on priority */
tone?: TaskTone;
/** Loading state for the action button */
isLoading?: boolean;
/** Loading text for the action button */
loadingText?: string;
/** Additional className */
className?: string;
}
const toneStyles: Record<
TaskTone,
{
card: string;
border: string;
iconBg: string;
iconColor: string;
buttonVariant: "default" | "outline";
}
> = {
critical: {
card: "bg-error/5 hover:bg-error/10",
border: "border-l-error",
iconBg: "bg-error/15",
iconColor: "text-error",
buttonVariant: "default",
},
warning: {
card: "bg-warning/5 hover:bg-warning/10",
border: "border-l-warning",
iconBg: "bg-warning/15",
iconColor: "text-warning",
buttonVariant: "outline",
},
info: {
card: "bg-info/5 hover:bg-info/10",
border: "border-l-info",
iconBg: "bg-info/15",
iconColor: "text-info",
buttonVariant: "outline",
},
neutral: {
card: "bg-primary/5 hover:bg-primary/10",
border: "border-l-primary",
iconBg: "bg-primary/15",
iconColor: "text-primary",
buttonVariant: "outline",
},
};
export function TaskCard({
id,
icon: Icon,
title,
description,
actionLabel,
detailHref,
onAction,
tone = "neutral",
isLoading = false,
loadingText,
className,
}: TaskCardProps) {
const styles = toneStyles[tone];
const cardContent = (
<>
{/* Icon - Larger for prominence */}
<div
className={cn(
"flex-shrink-0 h-12 w-12 rounded-xl flex items-center justify-center",
styles.iconBg
)}
aria-hidden="true"
>
<Icon className={cn("h-6 w-6", styles.iconColor)} />
</div>
{/* Content - Larger text */}
<div className="flex-1 min-w-0">
<h3 className="text-base font-semibold text-foreground">{title}</h3>
<p className="text-sm text-muted-foreground mt-1 line-clamp-2">{description}</p>
</div>
</>
);
const actionButton = (
<Button
variant={styles.buttonVariant}
size="sm"
onClick={e => {
e.preventDefault();
e.stopPropagation();
onAction?.();
}}
isLoading={isLoading}
loadingText={loadingText}
rightIcon={!isLoading ? <ChevronRightIcon className="h-4 w-4" /> : undefined}
className="shrink-0"
>
{actionLabel}
</Button>
);
const cardClasses = cn(
"group flex items-center gap-5 p-5 rounded-2xl border border-border/60",
"border-l-4",
styles.border,
styles.card,
"transition-all duration-[var(--cp-duration-normal)]",
"shadow-[var(--cp-shadow-1)] hover:shadow-[var(--cp-shadow-3)]",
detailHref && "cursor-pointer",
className
);
if (detailHref) {
return (
<Link href={detailHref} data-task-id={id} className={cn(cardClasses, "block")}>
<div className="flex items-center gap-5 w-full">
{cardContent}
{/* Action button */}
<div className="shrink-0">{actionButton}</div>
</div>
</Link>
);
}
return (
<div data-task-id={id} className={cardClasses}>
{cardContent}
{/* Action button */}
<div className="shrink-0">{actionButton}</div>
</div>
);
}
/**
* Loading skeleton for TaskCard
*/
export function TaskCardSkeleton() {
return (
<div className="flex items-center gap-5 p-5 rounded-2xl bg-muted/30 border border-border/60 border-l-4 border-l-muted animate-pulse">
<div className="flex-shrink-0 h-12 w-12 rounded-xl bg-muted" />
<div className="flex-1 min-w-0 space-y-2">
<div className="h-5 bg-muted rounded w-1/3" />
<div className="h-4 bg-muted rounded w-2/3" />
</div>
<div className="h-9 w-24 bg-muted rounded-lg" />
</div>
);
}

View File

@ -0,0 +1,172 @@
"use client";
import Link from "next/link";
import {
CheckCircleIcon,
Squares2X2Icon,
DocumentTextIcon,
ChatBubbleLeftRightIcon,
ArrowRightIcon,
} from "@heroicons/react/24/outline";
import { TaskCard, TaskCardSkeleton } from "./TaskCard";
import type { DashboardTask } from "../hooks/useDashboardTasks";
import {
useCreateInvoiceSsoLink,
useCreatePaymentMethodsSsoLink,
} from "@/features/billing/hooks/useBilling";
import { useState } from "react";
import { log } from "@/lib/logger";
interface TaskListProps {
tasks: DashboardTask[];
isLoading?: boolean;
maxTasks?: number;
}
/**
* Beautiful empty state when all tasks are completed
*/
function AllCaughtUp() {
return (
<div className="relative overflow-hidden rounded-2xl bg-gradient-to-br from-success/10 via-primary/5 to-info/10 border border-success/20">
{/* Decorative background elements */}
<div className="absolute inset-0 overflow-hidden">
<div className="absolute -top-24 -right-24 w-48 h-48 rounded-full bg-success/10 blur-3xl" />
<div className="absolute -bottom-24 -left-24 w-48 h-48 rounded-full bg-primary/10 blur-3xl" />
</div>
<div className="relative text-center py-10 px-6">
{/* Animated checkmark */}
<div className="relative w-20 h-20 mx-auto mb-5">
<div className="absolute inset-0 rounded-full bg-success/20 animate-ping" />
<div className="relative w-20 h-20 rounded-full bg-success/15 flex items-center justify-center">
<CheckCircleIcon className="h-10 w-10 text-success" />
</div>
</div>
<h3 className="text-xl font-bold text-foreground">You&apos;re all caught up!</h3>
<p className="text-sm text-muted-foreground mt-2 max-w-md mx-auto">
No outstanding tasks right now. Take a moment to explore or manage your account.
</p>
{/* Quick action cards */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 mt-8 max-w-lg mx-auto">
<Link
href="/catalog"
className="group flex flex-col items-center gap-2 p-4 rounded-xl bg-surface/80 backdrop-blur-sm border border-border/60 hover:border-primary/40 hover:shadow-lg transition-all"
>
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center group-hover:bg-primary/20 transition-colors">
<Squares2X2Icon className="h-5 w-5 text-primary" />
</div>
<span className="text-sm font-medium text-foreground">Browse Catalog</span>
<ArrowRightIcon className="h-4 w-4 text-muted-foreground group-hover:text-primary group-hover:translate-x-0.5 transition-all" />
</Link>
<Link
href="/billing/invoices"
className="group flex flex-col items-center gap-2 p-4 rounded-xl bg-surface/80 backdrop-blur-sm border border-border/60 hover:border-info/40 hover:shadow-lg transition-all"
>
<div className="w-10 h-10 rounded-lg bg-info/10 flex items-center justify-center group-hover:bg-info/20 transition-colors">
<DocumentTextIcon className="h-5 w-5 text-info" />
</div>
<span className="text-sm font-medium text-foreground">View Invoices</span>
<ArrowRightIcon className="h-4 w-4 text-muted-foreground group-hover:text-info group-hover:translate-x-0.5 transition-all" />
</Link>
<Link
href="/support"
className="group flex flex-col items-center gap-2 p-4 rounded-xl bg-surface/80 backdrop-blur-sm border border-border/60 hover:border-warning/40 hover:shadow-lg transition-all"
>
<div className="w-10 h-10 rounded-lg bg-warning/10 flex items-center justify-center group-hover:bg-warning/20 transition-colors">
<ChatBubbleLeftRightIcon className="h-5 w-5 text-warning" />
</div>
<span className="text-sm font-medium text-foreground">Get Support</span>
<ArrowRightIcon className="h-4 w-4 text-muted-foreground group-hover:text-warning group-hover:translate-x-0.5 transition-all" />
</Link>
</div>
</div>
</div>
);
}
/**
* Loading state for task list
*/
function TaskListLoading({ count = 2 }: { count?: number }) {
return (
<div className="space-y-4">
{Array.from({ length: count }).map((_, i) => (
<TaskCardSkeleton key={i} />
))}
</div>
);
}
/**
* TaskList component that renders prioritized tasks
*/
export function TaskList({ tasks, isLoading = false, maxTasks = 4 }: TaskListProps) {
const [loadingTaskId, setLoadingTaskId] = useState<string | null>(null);
const createInvoiceSsoLink = useCreateInvoiceSsoLink();
const createPaymentMethodsSsoLink = useCreatePaymentMethodsSsoLink();
if (isLoading) {
return <TaskListLoading count={2} />;
}
if (tasks.length === 0) {
return <AllCaughtUp />;
}
const visibleTasks = tasks.slice(0, maxTasks);
const handleTaskAction = async (task: DashboardTask) => {
// If task doesn't require SSO action, just let the link handle navigation
if (!task.requiresSsoAction) {
return;
}
setLoadingTaskId(task.id);
try {
if (task.type === "invoice" && task.metadata?.invoiceId) {
const ssoLink = await createInvoiceSsoLink.mutateAsync({
invoiceId: task.metadata.invoiceId,
target: "pay",
});
window.open(ssoLink.url, "_blank", "noopener,noreferrer");
} else if (task.type === "payment_method") {
const ssoLink = await createPaymentMethodsSsoLink.mutateAsync();
window.open(ssoLink.url, "_blank", "noopener,noreferrer");
}
} catch (error) {
log.error("Failed to handle task action", {
taskId: task.id,
taskType: task.type,
error: error instanceof Error ? error.message : String(error),
});
} finally {
setLoadingTaskId(null);
}
};
return (
<div className="space-y-4">
{visibleTasks.map(task => (
<TaskCard
key={task.id}
id={task.id}
icon={task.icon}
title={task.title}
description={task.description}
actionLabel={task.actionLabel}
detailHref={task.detailHref}
onAction={task.requiresSsoAction ? () => void handleTaskAction(task) : undefined}
tone={task.tone}
isLoading={loadingTaskId === task.id}
loadingText="Opening..."
/>
))}
</div>
);
}

View File

@ -3,3 +3,7 @@ export * from "./QuickAction";
export * from "./DashboardActivityItem";
export * from "./AccountStatusCard";
export * from "./ActivityTimeline";
export * from "./TaskCard";
export * from "./TaskList";
export * from "./QuickStats";
export * from "./ActivityFeed";

View File

@ -1 +1,2 @@
export * from "./useDashboardSummary";
export * from "./useDashboardTasks";

View File

@ -0,0 +1,207 @@
"use client";
import { useMemo } from "react";
import { formatDistanceToNow, format } from "date-fns";
import {
ExclamationCircleIcon,
CreditCardIcon,
ClockIcon,
SparklesIcon,
} from "@heroicons/react/24/outline";
import type { DashboardSummary } from "@customer-portal/domain/dashboard";
import type { PaymentMethodList } from "@customer-portal/domain/payments";
import type { OrderSummary } from "@customer-portal/domain/orders";
import type { TaskTone } from "../components/TaskCard";
import { useDashboardSummary } from "./useDashboardSummary";
import { usePaymentMethods } from "@/features/billing/hooks/useBilling";
import { useOrdersList } from "@/features/orders/hooks/useOrdersList";
import { useFormatCurrency } from "@/lib/hooks/useFormatCurrency";
/**
* Task type for dashboard actions
*/
export type DashboardTaskType = "invoice" | "payment_method" | "order" | "onboarding";
/**
* Dashboard task structure
*/
export interface DashboardTask {
id: string;
priority: 1 | 2 | 3 | 4;
type: DashboardTaskType;
title: string;
description: string;
/** Label for the action button */
actionLabel: string;
/** Link for card click (navigates to detail page) */
detailHref?: string;
/** Whether the action opens an external SSO link */
requiresSsoAction?: boolean;
tone: TaskTone;
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
metadata?: {
invoiceId?: number;
orderId?: string;
amount?: number;
currency?: string;
};
}
interface ComputeTasksParams {
summary: DashboardSummary | undefined;
paymentMethods: PaymentMethodList | undefined;
orders: OrderSummary[] | undefined;
formatCurrency: (amount: number, options?: { currency?: string }) => string;
}
/**
* Compute dashboard tasks based on user's account state
*/
function computeTasks({
summary,
paymentMethods,
orders,
formatCurrency,
}: ComputeTasksParams): DashboardTask[] {
const tasks: DashboardTask[] = [];
if (!summary) return tasks;
// Priority 1: Unpaid invoices
if (summary.nextInvoice) {
const dueDate = new Date(summary.nextInvoice.dueDate);
const isOverdue = dueDate < new Date();
const dueText = isOverdue
? `Overdue since ${format(dueDate, "MMM d")}`
: `Due ${formatDistanceToNow(dueDate, { addSuffix: true })}`;
tasks.push({
id: `invoice-${summary.nextInvoice.id}`,
priority: 1,
type: "invoice",
title: isOverdue ? "Pay overdue invoice" : "Pay upcoming invoice",
description: `Invoice #${summary.nextInvoice.id} · ${formatCurrency(summary.nextInvoice.amount, { currency: summary.nextInvoice.currency })} · ${dueText}`,
actionLabel: "Pay now",
detailHref: `/billing/invoices/${summary.nextInvoice.id}`,
requiresSsoAction: true,
tone: "critical",
icon: ExclamationCircleIcon,
metadata: {
invoiceId: summary.nextInvoice.id,
amount: summary.nextInvoice.amount,
currency: summary.nextInvoice.currency,
},
});
}
// Priority 2: No payment method
if (paymentMethods && paymentMethods.totalCount === 0) {
tasks.push({
id: "add-payment-method",
priority: 2,
type: "payment_method",
title: "Add a payment method",
description: "Required to place orders and process invoices",
actionLabel: "Add method",
detailHref: "/billing/payments",
requiresSsoAction: true,
tone: "warning",
icon: CreditCardIcon,
});
}
// Priority 3: Pending orders (Draft, Pending, or Activated but not yet complete)
if (orders && orders.length > 0) {
const pendingOrders = orders.filter(
o =>
o.status === "Draft" ||
o.status === "Pending" ||
(o.status === "Activated" && o.activationStatus !== "Completed")
);
if (pendingOrders.length > 0) {
const order = pendingOrders[0];
const statusText =
order.status === "Pending"
? "awaiting review"
: order.status === "Draft"
? "in draft"
: "being activated";
tasks.push({
id: `order-${order.id}`,
priority: 3,
type: "order",
title: "Order in progress",
description: `${order.orderType || "Your"} order is ${statusText}`,
actionLabel: "View details",
detailHref: `/orders/${order.id}`,
tone: "info",
icon: ClockIcon,
metadata: { orderId: order.id },
});
}
}
// Priority 4: No subscriptions (onboarding) - only show if no other tasks
if (summary.stats.activeSubscriptions === 0 && tasks.length === 0) {
tasks.push({
id: "start-subscription",
priority: 4,
type: "onboarding",
title: "Start your first service",
description: "Browse our catalog and subscribe to internet, SIM, or VPN",
actionLabel: "Browse catalog",
detailHref: "/catalog",
tone: "neutral",
icon: SparklesIcon,
});
}
return tasks.sort((a, b) => a.priority - b.priority);
}
export interface UseDashboardTasksResult {
tasks: DashboardTask[];
isLoading: boolean;
hasError: boolean;
taskCount: number;
}
/**
* Hook to compute and return prioritized dashboard tasks
*/
export function useDashboardTasks(): UseDashboardTasksResult {
const { formatCurrency } = useFormatCurrency();
const { data: summary, isLoading: summaryLoading, error: summaryError } = useDashboardSummary();
const {
data: paymentMethods,
isLoading: paymentMethodsLoading,
error: paymentMethodsError,
} = usePaymentMethods();
const { data: orders, isLoading: ordersLoading, error: ordersError } = useOrdersList();
const isLoading = summaryLoading || paymentMethodsLoading || ordersLoading;
const hasError = Boolean(summaryError || paymentMethodsError || ordersError);
const tasks = useMemo(
() =>
computeTasks({
summary,
paymentMethods,
orders,
formatCurrency,
}),
[summary, paymentMethods, orders, formatCurrency]
);
return {
tasks,
isLoading,
hasError,
taskCount: tasks.length,
};
}

View File

@ -1,83 +1,49 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import type { Activity, DashboardSummary } from "@customer-portal/domain/dashboard";
import { ChevronRightIcon } from "@heroicons/react/24/outline";
import {
CreditCardIcon as CreditCardIconSolid,
ServerIcon as ServerIconSolid,
ChatBubbleLeftRightIcon as ChatBubbleLeftRightIconSolid,
ClipboardDocumentListIcon as ClipboardDocumentListIconSolid,
Squares2X2Icon as Squares2X2IconSolid,
} from "@heroicons/react/24/solid";
import { format, formatDistanceToNow } from "date-fns";
import { useEffect } from "react";
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
import { useAuthStore } from "@/features/auth/services/auth.store";
import { useDashboardSummary } from "@/features/dashboard/hooks";
import { StatCard, ActivityTimeline } from "@/features/dashboard/components";
import { LoadingStats, LoadingTable } from "@/components/atoms";
import { useDashboardSummary, useDashboardTasks } from "@/features/dashboard/hooks";
import { TaskList, QuickStats, ActivityFeed } from "@/features/dashboard/components";
import { ErrorState } from "@/components/atoms/error-state";
import { PageLayout } from "@/components/templates";
import { Button } from "@/components/atoms/button";
import { useFormatCurrency } from "@/lib/hooks/useFormatCurrency";
import { log } from "@/lib/logger";
import { useCreateInvoiceSsoLink } from "@/features/billing/hooks/useBilling";
import { cn } from "@/lib/utils";
export function DashboardView() {
const { formatCurrency } = useFormatCurrency();
const router = useRouter();
const { user, isAuthenticated, loading: authLoading, clearLoading } = useAuthStore();
// Clear auth loading state when dashboard loads (after successful login)
useEffect(() => {
clearLoading();
}, [clearLoading]);
const { data: summary, isLoading: summaryLoading, error } = useDashboardSummary();
const upcomingInvoice = summary?.nextInvoice ?? null;
const createSsoLinkMutation = useCreateInvoiceSsoLink();
const { tasks, isLoading: tasksLoading, taskCount } = useDashboardTasks();
const [paymentLoading, setPaymentLoading] = useState(false);
const [paymentError, setPaymentError] = useState<string | null>(null);
// Combined loading state
const isLoading = authLoading || summaryLoading || !isAuthenticated;
// Handle Pay Now functionality
const handlePayNow = (invoiceId: number) => {
setPaymentLoading(true);
setPaymentError(null);
void (async () => {
try {
const ssoLink = await createSsoLinkMutation.mutateAsync({ invoiceId, target: "pay" });
window.open(ssoLink.url, "_blank", "noopener,noreferrer");
} catch (error) {
log.error("Failed to create payment link", {
invoiceId,
error: error instanceof Error ? error.message : String(error),
});
setPaymentError(error instanceof Error ? error.message : "Failed to open payment page");
} finally {
setPaymentLoading(false);
}
})();
};
// Handle activity item clicks
const handleActivityClick = (activity: Activity) => {
if (activity.type === "invoice_created" || activity.type === "invoice_paid") {
if (activity.relatedId) {
router.push(`/billing/invoices/${activity.relatedId}`);
}
}
};
if (authLoading || summaryLoading || !isAuthenticated) {
if (isLoading) {
return (
<PageLayout title="Dashboard" description="Overview of your account" loading>
<div className="space-y-6">
<LoadingStats />
<LoadingTable />
<p className="text-muted-foreground">Loading dashboard...</p>
<div className="space-y-8">
{/* Greeting skeleton */}
<div className="animate-pulse">
<div className="h-4 bg-muted rounded w-24 mb-3" />
<div className="h-10 bg-muted rounded w-56 mb-2" />
<div className="h-4 bg-muted rounded w-40" />
</div>
{/* Tasks skeleton */}
<div className="space-y-4">
<div className="h-24 bg-muted rounded-2xl" />
<div className="h-24 bg-muted rounded-2xl" />
</div>
{/* Bottom section skeleton */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="h-44 bg-muted rounded-2xl" />
<div className="h-44 bg-muted rounded-2xl" />
</div>
</div>
</PageLayout>
);
@ -101,182 +67,59 @@ export function DashboardView() {
);
}
// Get user's display name
const displayName = user?.firstname || user?.email?.split("@")[0] || "there";
// Determine urgency level for task badge
const hasUrgentTask = tasks.some(t => t.tone === "critical");
return (
<PageLayout
title="Dashboard"
description="Overview of your account"
actions={<TasksChip summaryLoading={summaryLoading} summary={summary} />}
>
{/* Greeting */}
<div className="mb-6">
<PageLayout title="Dashboard" description="Overview of your account">
{/* Greeting Section */}
<div className="mb-8">
<p className="text-sm font-medium text-muted-foreground">Welcome back</p>
<h2 className="text-2xl sm:text-3xl font-bold text-foreground truncate mt-1">
{user?.firstname || user?.email?.split("@")[0] || "User"}
</h2>
</div>
<h2 className="text-3xl sm:text-4xl font-bold text-foreground mt-1">{displayName}</h2>
{/* Overview Stats Card */}
<div className="cp-card rounded-2xl">
<div className="mb-2">
<h3 className="text-sm font-semibold text-foreground">Overview</h3>
<p className="text-xs text-muted-foreground mt-0.5">Quick snapshot of your account</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 divide-y sm:divide-y-0 sm:divide-x divide-border/60 -mx-4 sm:-mx-0">
<StatCard
title="Recent Orders"
value={summary?.stats?.recentOrders ?? 0}
icon={ClipboardDocumentListIconSolid}
tone="primary"
href="/orders"
/>
<StatCard
title="Pending Invoices"
value={summary?.stats?.unpaidInvoices ?? 0}
icon={CreditCardIconSolid}
tone={(summary?.stats?.unpaidInvoices ?? 0) > 0 ? "warning" : "neutral"}
href="/billing/invoices"
/>
<StatCard
title="Active Services"
value={summary?.stats?.activeSubscriptions ?? 0}
icon={ServerIconSolid}
tone="info"
href="/subscriptions"
/>
<StatCard
title="Support Cases"
value={summary?.stats?.openCases ?? 0}
icon={ChatBubbleLeftRightIconSolid}
tone={(summary?.stats?.openCases ?? 0) > 0 ? "info" : "neutral"}
href="/support/cases"
/>
<StatCard
title="Browse Catalog"
value="→"
icon={Squares2X2IconSolid}
tone="primary"
href="/catalog"
/>
</div>
</div>
{/* Billing Card - only shown when there's an upcoming invoice */}
{upcomingInvoice && (
<div className="cp-card rounded-2xl" id="attention">
<div className="flex items-center justify-between gap-4 mb-4">
<div>
<h3 className="text-sm font-semibold text-foreground">Upcoming Payment</h3>
<p className="text-xs text-muted-foreground mt-0.5">Invoice due soon</p>
</div>
<Link
href="/billing/invoices"
className="text-sm font-medium text-primary hover:text-primary-hover transition-colors"
{/* Task status badge */}
{taskCount > 0 ? (
<div className="flex items-center gap-2 mt-3">
<span
className={cn(
"inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-sm font-medium",
hasUrgentTask ? "bg-error/10 text-error" : "bg-warning/10 text-warning"
)}
>
View all invoices
</Link>
{hasUrgentTask && <ExclamationTriangleIcon className="h-4 w-4" />}
{taskCount === 1 ? "1 task needs attention" : `${taskCount} tasks need attention`}
</span>
</div>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 p-4 rounded-xl bg-muted/30 border border-border/60">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-warning/10 text-warning">
Due soon
</span>
<span className="text-sm text-muted-foreground">Invoice #{upcomingInvoice.id}</span>
</div>
<div className="mt-2 text-2xl font-bold text-foreground">
{formatCurrency(upcomingInvoice.amount, {
currency: upcomingInvoice.currency,
})}
</div>
<p className="mt-1 text-sm text-muted-foreground">
Due{" "}
{formatDistanceToNow(new Date(upcomingInvoice.dueDate), {
addSuffix: true,
})}{" "}
· {format(new Date(upcomingInvoice.dueDate), "MMMM d, yyyy")}
</p>
</div>
<div className="flex items-center gap-3">
<Link
href={`/billing/invoices/${upcomingInvoice.id}`}
className="text-sm font-medium text-primary hover:text-primary-hover transition-colors"
>
View details
</Link>
<Button
onClick={() => handlePayNow(upcomingInvoice.id)}
isLoading={paymentLoading}
loadingText="Opening…"
rightIcon={!paymentLoading ? <ChevronRightIcon className="h-4 w-4" /> : undefined}
>
Pay now
</Button>
</div>
</div>
</div>
)}
{/* Payment Error Display */}
{paymentError && (
<ErrorState
title="Payment Error"
message={paymentError}
variant="inline"
onRetry={() => setPaymentError(null)}
retryLabel="Dismiss"
/>
)}
{/* Recent Activity Card */}
<div className="cp-card rounded-2xl">
<div className="flex items-center justify-between gap-4 mb-5">
<div>
<h3 className="text-sm font-semibold text-foreground">Recent Activity</h3>
<p className="text-xs text-muted-foreground mt-0.5">Your latest account updates</p>
</div>
</div>
<ActivityTimeline
activities={summary?.recentActivity || []}
onItemClick={handleActivityClick}
maxItems={8}
/>
) : (
<p className="text-sm text-muted-foreground mt-2">Everything is up to date</p>
)}
</div>
{/* Tasks Section - Main focus area */}
<section className="mb-10" aria-labelledby="tasks-heading">
<h3 id="tasks-heading" className="sr-only">
Your Tasks
</h3>
<TaskList tasks={tasks} isLoading={tasksLoading} maxTasks={4} />
</section>
{/* Bottom Section: Quick Stats + Recent Activity */}
<section className="grid grid-cols-1 lg:grid-cols-2 gap-6" aria-label="Account overview">
<QuickStats
activeSubscriptions={summary?.stats?.activeSubscriptions ?? 0}
openCases={summary?.stats?.openCases ?? 0}
recentOrders={summary?.stats?.recentOrders}
isLoading={summaryLoading}
/>
<ActivityFeed
activities={summary?.recentActivity || []}
maxItems={5}
isLoading={summaryLoading}
/>
</section>
</PageLayout>
);
}
// Helpers
function TasksChip({
summaryLoading,
summary,
}: {
summaryLoading: boolean;
summary: DashboardSummary | undefined;
}) {
const router = useRouter();
if (summaryLoading) return null;
const tasks: Array<{ label: string; href: string }> = [];
if (summary?.nextInvoice) tasks.push({ label: "Pay upcoming invoice", href: "#attention" });
const count = tasks.length;
if (count === 0) return null;
return (
<button
onClick={() => {
const first = tasks[0];
if (first.href.startsWith("#")) {
const el = document.querySelector(first.href);
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
} else {
router.push(first.href);
}
}}
className="inline-flex items-center rounded-full bg-muted text-foreground px-2.5 py-1 text-xs font-medium hover:bg-muted/80 transition-colors"
title={tasks.map(t => t.label).join(" • ")}
>
{count} tasks
</button>
);
}

View File

@ -93,46 +93,118 @@
--cp-transition-slow: var(--cp-duration-slow);
/* ============= COLOR (LIGHT) ============= */
/* Core neutrals (light) - clean white main area */
/* Core neutrals */
--cp-bg: oklch(1 0 0); /* pure white for main content area */
--cp-fg: oklch(0.16 0 0);
--cp-surface: oklch(1 0 0); /* pure white for cards */
--cp-muted: oklch(0.97 0.003 250); /* subtle cool gray for table headers */
--cp-border: oklch(0.9 0 0);
--cp-border-muted: oklch(0.92 0 0);
--cp-muted: oklch(0.97 0.008 234.4); /* subtle brand-tinted gray */
--cp-border: oklch(0.9 0.005 234.4);
--cp-border-muted: oklch(0.92 0.003 234.4);
/* Brand & focus - matches logo cyan blue */
--cp-primary: oklch(0.68 0.14 220); /* bright cyan blue from logo */
/* ============= BRAND COLORS (from logo) ============= */
/* Primary Light Blue - Brand Accent */
--cp-primary: oklch(0.6884 0.1342 234.4);
--cp-on-primary: oklch(0.99 0 0);
--cp-primary-hover: oklch(0.63 0.14 220);
--cp-primary-active: oklch(0.58 0.14 220);
--cp-ring: oklch(0.72 0.12 220);
--cp-primary-hover: oklch(0.62 0.1342 234.4);
--cp-primary-active: oklch(0.56 0.1342 234.4);
--cp-ring: oklch(0.72 0.12 234.4);
/* Semantic */
--cp-success: oklch(0.67 0.14 150);
/* ============= SEMANTIC STATUS COLORS ============= */
/* Three-tier system: text (dark readable), bg (almost white), border (subtle accent) */
/* Success - #166534 text, #F0FDF4 bg, #BBF7D0 border */
--cp-success: oklch(0.42 0.1 145);
--cp-on-success: oklch(0.99 0 0);
--cp-success-soft: oklch(0.95 0.05 150);
--cp-success-bg: oklch(0.98 0.02 145);
--cp-success-border: oklch(0.93 0.08 150);
--cp-warning: oklch(0.78 0.16 90);
--cp-on-warning: oklch(0.16 0 0);
--cp-warning-soft: oklch(0.96 0.07 90);
--cp-error: oklch(0.62 0.21 27);
--cp-on-error: oklch(0.99 0 0);
--cp-error-soft: oklch(0.96 0.05 27);
--cp-info: oklch(0.68 0.14 220); /* matches primary */
/* Info - uses primary brand hue */
--cp-info: oklch(0.48 0.1 234.4);
--cp-on-info: oklch(0.99 0 0);
--cp-info-soft: oklch(0.95 0.06 220);
--cp-info-bg: oklch(0.97 0.02 234.4);
--cp-info-border: oklch(0.91 0.05 234.4);
/* Sidebar - Brand navy blue from logo */
--cp-sidebar-bg: oklch(0.28 0.1 265); /* deep navy blue from logo */
--cp-sidebar-border: oklch(0.35 0.08 265); /* lighter navy for borders */
--cp-sidebar-text: oklch(1 0 0); /* pure white text */
--cp-sidebar-text-hover: oklch(1 0 0); /* pure white on hover */
--cp-sidebar-hover-bg: oklch(0.35 0.09 265); /* lighter navy on hover */
--cp-sidebar-active-bg: oklch(1 0 0 / 0.18); /* white overlay for active */
--cp-sidebar-active-text: oklch(1 0 0); /* white for active text */
/* Warning - #92400E text, #FFFBEB bg, #FDE68A border */
--cp-warning: oklch(0.45 0.12 55);
--cp-on-warning: oklch(0.99 0 0);
--cp-warning-bg: oklch(0.99 0.02 90);
--cp-warning-border: oklch(0.92 0.12 90);
/* Error - #9F1239 text, #FFF1F2 bg, #FECDD3 border */
--cp-error: oklch(0.42 0.16 0);
--cp-on-error: oklch(0.99 0 0);
--cp-error-bg: oklch(0.98 0.01 0);
--cp-error-border: oklch(0.89 0.06 0);
/* Destructive - #991B1B text, #FEF2F2 bg, #FECACA border */
--cp-destructive: oklch(0.43 0.15 25);
--cp-on-destructive: oklch(0.99 0 0);
--cp-destructive-bg: oklch(0.98 0.01 20);
--cp-destructive-border: oklch(0.89 0.06 20);
/* Neutral - uses sidebar brand hue */
--cp-neutral: oklch(0.36 0.03 272.34);
--cp-on-neutral: oklch(0.99 0 0);
--cp-neutral-bg: oklch(0.97 0.008 272.34);
--cp-neutral-border: oklch(0.87 0.02 272.34);
/* ============= GRANULAR STATUS COLORS ============= */
/* Three-tier: text (dark readable), bg (almost white), border (subtle accent) */
/* Active - uses primary brand hue */
--cp-status-active: oklch(0.48 0.1 234.4);
--cp-status-active-bg: oklch(0.97 0.02 234.4);
--cp-status-active-border: oklch(0.91 0.05 234.4);
/* Completed - uses sidebar brand hue */
--cp-status-completed: oklch(0.2754 0.1199 272.34);
--cp-status-completed-bg: oklch(0.96 0.02 272.34);
--cp-status-completed-border: oklch(0.87 0.07 272.34);
/* Paid - #0F766E text, #ECFDF5 bg, #A7F3D0 border (teal, closer to brand) */
--cp-status-paid: oklch(0.48 0.08 175);
--cp-status-paid-bg: oklch(0.98 0.02 165);
--cp-status-paid-border: oklch(0.92 0.08 160);
/* Success - #166534 text, #F0FDF4 bg, #BBF7D0 border */
--cp-status-success: oklch(0.42 0.1 145);
--cp-status-success-bg: oklch(0.98 0.02 145);
--cp-status-success-border: oklch(0.93 0.08 150);
/* Pending - #92400E text, #FFFBEB bg, #FDE68A border */
--cp-status-pending: oklch(0.45 0.12 55);
--cp-status-pending-bg: oklch(0.99 0.02 90);
--cp-status-pending-border: oklch(0.92 0.12 90);
/* Suspended - uses sidebar brand hue */
--cp-status-suspended: oklch(0.36 0.03 272.34);
--cp-status-suspended-bg: oklch(0.97 0.008 272.34);
--cp-status-suspended-border: oklch(0.87 0.02 272.34);
/* Unpaid - #9A3412 text, #FFF7ED bg, #FED7AA border */
--cp-status-unpaid: oklch(0.46 0.13 45);
--cp-status-unpaid-bg: oklch(0.98 0.02 70);
--cp-status-unpaid-border: oklch(0.9 0.08 65);
/* Terminated - #991B1B text, #FEF2F2 bg, #FECACA border */
--cp-status-terminated: oklch(0.43 0.15 25);
--cp-status-terminated-bg: oklch(0.98 0.01 20);
--cp-status-terminated-border: oklch(0.89 0.06 20);
/* Overdue - #9F1239 text, #FFF1F2 bg, #FECDD3 border */
--cp-status-overdue: oklch(0.42 0.16 0);
--cp-status-overdue-bg: oklch(0.98 0.01 0);
--cp-status-overdue-border: oklch(0.89 0.06 0);
/* ============= SIDEBAR ============= */
/* Primary Dark Blue - Brand Anchor (Logo Color) */
--cp-sidebar-bg: oklch(0.2754 0.1199 272.34);
--cp-sidebar-border: oklch(0.36 0.1 272.34);
--cp-sidebar-text: oklch(1 0 0);
--cp-sidebar-text-hover: oklch(1 0 0);
--cp-sidebar-hover-bg: oklch(0.36 0.1199 272.34);
--cp-sidebar-active-bg: oklch(0.6884 0.1342 234.4 / 0.25); /* primary brand blue tint */
--cp-sidebar-active-text: oklch(1 0 0);
/* Header */
--cp-header-bg: oklch(1 0 0 / 0.95); /* slightly more opaque white */
@ -165,42 +237,95 @@
/* ============= DARK MODE OVERRIDES ============= */
.dark {
--cp-bg: oklch(0.15 0 0);
--cp-bg: oklch(0.15 0.01 272.34);
--cp-fg: oklch(0.98 0 0);
--cp-surface: oklch(0.18 0 0);
--cp-muted: oklch(0.22 0 0);
--cp-border: oklch(0.32 0 0);
--cp-border-muted: oklch(0.28 0 0);
--cp-surface: oklch(0.18 0.01 272.34);
--cp-muted: oklch(0.22 0.01 272.34);
--cp-border: oklch(0.32 0.02 272.34);
--cp-border-muted: oklch(0.28 0.01 272.34);
--cp-primary: oklch(0.75 0.12 220); /* lighter cyan for dark mode */
/* Brand - lighter for dark mode visibility */
--cp-primary: oklch(0.76 0.12 234.4);
--cp-on-primary: oklch(0.15 0 0);
--cp-primary-hover: oklch(0.7 0.12 220);
--cp-primary-active: oklch(0.65 0.12 220);
--cp-ring: oklch(0.78 0.1 220);
--cp-primary-hover: oklch(0.7 0.12 234.4);
--cp-primary-active: oklch(0.65 0.12 234.4);
--cp-ring: oklch(0.78 0.11 234.4);
--cp-success: oklch(0.76 0.14 150);
/* Semantic colors - dark mode (three-tier) */
--cp-success: oklch(0.72 0.1 145);
--cp-on-success: oklch(0.15 0 0);
--cp-success-soft: oklch(0.24 0.05 150);
--cp-success-bg: oklch(0.24 0.04 145);
--cp-success-border: oklch(0.38 0.08 150);
--cp-warning: oklch(0.86 0.16 90);
--cp-on-warning: oklch(0.15 0 0);
--cp-warning-soft: oklch(0.26 0.07 90);
--cp-error: oklch(0.7 0.21 27);
--cp-on-error: oklch(0.15 0 0);
--cp-error-soft: oklch(0.25 0.05 27);
--cp-info: oklch(0.75 0.12 220);
--cp-info: oklch(0.72 0.1 234.4);
--cp-on-info: oklch(0.15 0 0);
--cp-info-soft: oklch(0.24 0.05 220);
--cp-info-bg: oklch(0.24 0.04 234.4);
--cp-info-border: oklch(0.38 0.07 234.4);
--cp-warning: oklch(0.78 0.12 55);
--cp-on-warning: oklch(0.15 0 0);
--cp-warning-bg: oklch(0.26 0.04 90);
--cp-warning-border: oklch(0.42 0.1 90);
--cp-error: oklch(0.72 0.14 0);
--cp-on-error: oklch(0.15 0 0);
--cp-error-bg: oklch(0.24 0.03 0);
--cp-error-border: oklch(0.38 0.08 0);
--cp-destructive: oklch(0.7 0.14 25);
--cp-on-destructive: oklch(0.15 0 0);
--cp-destructive-bg: oklch(0.24 0.03 20);
--cp-destructive-border: oklch(0.38 0.08 20);
--cp-neutral: oklch(0.7 0.03 272.34);
--cp-on-neutral: oklch(0.15 0 0);
--cp-neutral-bg: oklch(0.24 0.02 272.34);
--cp-neutral-border: oklch(0.38 0.03 272.34);
/* Granular status colors - dark mode (three-tier) */
--cp-status-active: oklch(0.72 0.1 234.4);
--cp-status-active-bg: oklch(0.24 0.04 234.4);
--cp-status-active-border: oklch(0.38 0.07 234.4);
--cp-status-completed: oklch(0.55 0.1 272.34);
--cp-status-completed-bg: oklch(0.24 0.04 272.34);
--cp-status-completed-border: oklch(0.38 0.07 272.34);
--cp-status-paid: oklch(0.7 0.08 175);
--cp-status-paid-bg: oklch(0.24 0.04 165);
--cp-status-paid-border: oklch(0.4 0.08 160);
--cp-status-success: oklch(0.72 0.1 145);
--cp-status-success-bg: oklch(0.24 0.04 145);
--cp-status-success-border: oklch(0.38 0.08 150);
--cp-status-pending: oklch(0.78 0.12 55);
--cp-status-pending-bg: oklch(0.26 0.04 90);
--cp-status-pending-border: oklch(0.42 0.1 90);
--cp-status-suspended: oklch(0.7 0.03 272.34);
--cp-status-suspended-bg: oklch(0.24 0.02 272.34);
--cp-status-suspended-border: oklch(0.38 0.03 272.34);
--cp-status-unpaid: oklch(0.76 0.12 45);
--cp-status-unpaid-bg: oklch(0.26 0.04 70);
--cp-status-unpaid-border: oklch(0.42 0.08 65);
--cp-status-terminated: oklch(0.7 0.14 25);
--cp-status-terminated-bg: oklch(0.24 0.03 20);
--cp-status-terminated-border: oklch(0.38 0.08 20);
--cp-status-overdue: oklch(0.72 0.14 0);
--cp-status-overdue-bg: oklch(0.24 0.03 0);
--cp-status-overdue-border: oklch(0.38 0.08 0);
/* Sidebar - Dark navy from logo */
--cp-sidebar-bg: oklch(0.2 0.08 265); /* darker navy */
--cp-sidebar-border: oklch(0.28 0.06 265);
--cp-sidebar-hover-bg: oklch(0.28 0.07 265);
--cp-sidebar-text: oklch(1 0 0); /* pure white */
--cp-sidebar-bg: oklch(0.22 0.1199 272.34);
--cp-sidebar-border: oklch(0.3 0.08 272.34);
--cp-sidebar-hover-bg: oklch(0.32 0.1 272.34);
--cp-sidebar-text: oklch(1 0 0);
--cp-sidebar-text-hover: oklch(1 0 0);
--cp-sidebar-active-bg: oklch(1 0 0 / 0.18);
--cp-sidebar-active-bg: oklch(0.6884 0.1342 234.4 / 0.25); /* primary brand blue tint */
--cp-sidebar-active-text: oklch(1 0 0);
--cp-header-bg: oklch(0.18 0 0 / 0.95);