From 2d0c7c205149203af459bb53fc07d08e339f75f8 Mon Sep 17 00:00:00 2001 From: Sergei Date: Sun, 1 Feb 2026 08:16:19 -0800 Subject: [PATCH] Add comprehensive Dashboard Page with beneficiary management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Implement Dashboard Page with summary cards (total beneficiaries, equipment count, active subscriptions) - Add BeneficiaryCard component with status badges for equipment, subscription, and role - Display beneficiary list with avatar, name, email, and address - Handle empty state with call-to-action for adding first beneficiary - Add loading and error states with retry functionality - Include comprehensive test suite (33 tests covering all scenarios) - Fix TypeScript types for jest-dom matchers in tsconfig 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../(main)/dashboard/__tests__/page.test.tsx | 476 ++++++++++++++++++ web/app/(main)/dashboard/page.tsx | 278 +++++++++- web/tsconfig.json | 6 +- 3 files changed, 755 insertions(+), 5 deletions(-) create mode 100644 web/app/(main)/dashboard/__tests__/page.test.tsx diff --git a/web/app/(main)/dashboard/__tests__/page.test.tsx b/web/app/(main)/dashboard/__tests__/page.test.tsx new file mode 100644 index 0000000..382ef7a --- /dev/null +++ b/web/app/(main)/dashboard/__tests__/page.test.tsx @@ -0,0 +1,476 @@ +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; +import { useRouter } from 'next/navigation'; +import DashboardPage from '../page'; +import api from '@/lib/api'; +import { useAuthStore } from '@/stores/authStore'; +import type { Beneficiary } from '@/types'; + +// Mock dependencies +jest.mock('next/navigation', () => ({ + useRouter: jest.fn(), +})); + +jest.mock('@/lib/api'); +jest.mock('@/stores/authStore'); + +describe('DashboardPage', () => { + const mockRouter = { + push: jest.fn(), + }; + + const mockBeneficiaries: Beneficiary[] = [ + { + id: 1, + name: 'Maria Garcia', + displayName: 'Maria Garcia', + email: 'maria@example.com', + status: 'offline', + hasDevices: true, + equipmentStatus: 'active', + subscription: { + status: 'active', + planType: 'monthly', + endDate: '2025-12-31', + cancelAtPeriodEnd: false, + }, + role: 'custodian', + address: '123 Main St, City', + avatar: 'https://example.com/avatar.jpg', + }, + { + id: 2, + name: 'John Smith', + displayName: 'John Smith', + email: 'john@example.com', + status: 'offline', + hasDevices: false, + equipmentStatus: 'none', + role: 'caretaker', + }, + ]; + + beforeEach(() => { + jest.clearAllMocks(); + (useRouter as jest.Mock).mockReturnValue(mockRouter); + (useAuthStore as unknown as jest.Mock).mockReturnValue({ + isAuthenticated: true, + user: { firstName: 'Test', user_id: 1 }, + }); + }); + + describe('Loading State', () => { + it('should show loading spinner while fetching data', () => { + (api.getAllBeneficiaries as jest.Mock).mockImplementation( + () => new Promise(() => {}) // Never resolves + ); + + render(); + + expect(screen.getByRole('status')).toBeInTheDocument(); + }); + }); + + describe('Authentication', () => { + it('should redirect to login if not authenticated', () => { + (useAuthStore as unknown as jest.Mock).mockReturnValue({ + isAuthenticated: false, + user: null, + }); + + render(); + + expect(mockRouter.push).toHaveBeenCalledWith('/login'); + }); + + it('should not redirect if authenticated', async () => { + (api.getAllBeneficiaries as jest.Mock).mockResolvedValue({ + ok: true, + data: [], + }); + + render(); + + await waitFor(() => { + expect(mockRouter.push).not.toHaveBeenCalled(); + }); + }); + }); + + describe('Data Fetching', () => { + it('should fetch beneficiaries on mount', async () => { + (api.getAllBeneficiaries as jest.Mock).mockResolvedValue({ + ok: true, + data: mockBeneficiaries, + }); + + render(); + + await waitFor(() => { + expect(api.getAllBeneficiaries).toHaveBeenCalledTimes(1); + }); + }); + + it('should display beneficiaries after successful fetch', async () => { + (api.getAllBeneficiaries as jest.Mock).mockResolvedValue({ + ok: true, + data: mockBeneficiaries, + }); + + render(); + + await waitFor(() => { + expect(screen.getByText('Maria Garcia')).toBeInTheDocument(); + expect(screen.getByText('John Smith')).toBeInTheDocument(); + }); + }); + + it('should display error message on fetch failure', async () => { + const errorMessage = 'Failed to load beneficiaries'; + (api.getAllBeneficiaries as jest.Mock).mockResolvedValue({ + ok: false, + error: { message: errorMessage }, + }); + + render(); + + await waitFor(() => { + expect(screen.getByText(errorMessage)).toBeInTheDocument(); + }); + }); + + it('should handle network errors gracefully', async () => { + (api.getAllBeneficiaries as jest.Mock).mockRejectedValue( + new Error('Network error') + ); + + render(); + + await waitFor(() => { + expect(screen.getByText('Network error')).toBeInTheDocument(); + }); + }); + }); + + describe('Header and Welcome Message', () => { + it('should display welcome message with user first name', async () => { + (api.getAllBeneficiaries as jest.Mock).mockResolvedValue({ + ok: true, + data: [], + }); + + render(); + + await waitFor(() => { + expect(screen.getByText(/Welcome, Test/i)).toBeInTheDocument(); + }); + }); + + it('should display welcome message without name if not available', async () => { + (useAuthStore as unknown as jest.Mock).mockReturnValue({ + isAuthenticated: true, + user: { user_id: 1 }, + }); + + (api.getAllBeneficiaries as jest.Mock).mockResolvedValue({ + ok: true, + data: [], + }); + + render(); + + await waitFor(() => { + expect(screen.getByText(/Welcome$/i)).toBeInTheDocument(); + }); + }); + }); + + describe('Summary Cards', () => { + it('should display correct total beneficiaries count', async () => { + (api.getAllBeneficiaries as jest.Mock).mockResolvedValue({ + ok: true, + data: mockBeneficiaries, + }); + + render(); + + await waitFor(() => { + expect(screen.getByText('Total Beneficiaries')).toBeInTheDocument(); + const totalCard = screen.getByText('Total Beneficiaries').closest('div'); + expect(totalCard).toHaveTextContent('2'); + }); + }); + + it('should display correct equipment count', async () => { + (api.getAllBeneficiaries as jest.Mock).mockResolvedValue({ + ok: true, + data: mockBeneficiaries, + }); + + render(); + + await waitFor(() => { + expect(screen.getByText('With Equipment')).toBeInTheDocument(); + const equipmentCard = screen.getByText('With Equipment').closest('div'); + expect(equipmentCard).toHaveTextContent('1'); // Only Maria has equipment + }); + }); + + it('should display correct active subscriptions count', async () => { + (api.getAllBeneficiaries as jest.Mock).mockResolvedValue({ + ok: true, + data: mockBeneficiaries, + }); + + render(); + + await waitFor(() => { + expect(screen.getByText('Active Subscriptions')).toBeInTheDocument(); + const subscriptionCard = screen.getByText('Active Subscriptions').closest('div'); + expect(subscriptionCard).toHaveTextContent('1'); // Only Maria has active subscription + }); + }); + }); + + describe('Empty State', () => { + it('should display empty state when no beneficiaries', async () => { + (api.getAllBeneficiaries as jest.Mock).mockResolvedValue({ + ok: true, + data: [], + }); + + render(); + + await waitFor(() => { + expect(screen.getByText('No beneficiaries yet')).toBeInTheDocument(); + expect( + screen.getByText(/Add your first loved one to start monitoring/) + ).toBeInTheDocument(); + }); + }); + + it('should show add beneficiary button in empty state', async () => { + (api.getAllBeneficiaries as jest.Mock).mockResolvedValue({ + ok: true, + data: [], + }); + + render(); + + await waitFor(() => { + const addButton = screen.getByText('Add Your First Beneficiary'); + expect(addButton).toBeInTheDocument(); + }); + }); + }); + + describe('Beneficiary Cards', () => { + it('should display beneficiary avatar if available', async () => { + (api.getAllBeneficiaries as jest.Mock).mockResolvedValue({ + ok: true, + data: mockBeneficiaries, + }); + + render(); + + await waitFor(() => { + const avatar = screen.getByRole('img', { name: 'Maria Garcia' }); + expect(avatar).toBeInTheDocument(); + expect(avatar).toHaveAttribute('src', 'https://example.com/avatar.jpg'); + }); + }); + + it('should display default avatar icon if no image', async () => { + (api.getAllBeneficiaries as jest.Mock).mockResolvedValue({ + ok: true, + data: [mockBeneficiaries[1]], // John has no avatar + }); + + render(); + + await waitFor(() => { + const card = screen.getByText('John Smith').closest('button'); + expect(card).toHaveTextContent('👤'); + }); + }); + + it('should display equipment status badges correctly', async () => { + (api.getAllBeneficiaries as jest.Mock).mockResolvedValue({ + ok: true, + data: mockBeneficiaries, + }); + + render(); + + await waitFor(() => { + const activeBadges = screen.getAllByText('Active'); + expect(activeBadges.length).toBeGreaterThan(0); + expect(screen.getByText('No Equipment')).toBeInTheDocument(); + }); + }); + + it('should display subscription status badges', async () => { + (api.getAllBeneficiaries as jest.Mock).mockResolvedValue({ + ok: true, + data: mockBeneficiaries, + }); + + render(); + + await waitFor(() => { + // Maria has active subscription - "Active" text appears twice (equipment + subscription) + const activeBadges = screen.getAllByText('Active'); + expect(activeBadges.length).toBeGreaterThanOrEqual(1); + }); + }); + + it('should display role badges', async () => { + (api.getAllBeneficiaries as jest.Mock).mockResolvedValue({ + ok: true, + data: mockBeneficiaries, + }); + + render(); + + await waitFor(() => { + expect(screen.getByText('Custodian')).toBeInTheDocument(); + expect(screen.getByText('Caretaker')).toBeInTheDocument(); + }); + }); + + it('should display address if available', async () => { + (api.getAllBeneficiaries as jest.Mock).mockResolvedValue({ + ok: true, + data: mockBeneficiaries, + }); + + render(); + + await waitFor(() => { + expect(screen.getByText(/📍 123 Main St, City/)).toBeInTheDocument(); + }); + }); + }); + + describe('Navigation', () => { + it('should navigate to add beneficiary page when clicking add button', async () => { + (api.getAllBeneficiaries as jest.Mock).mockResolvedValue({ + ok: true, + data: mockBeneficiaries, + }); + + render(); + + await waitFor(() => { + const addButton = screen.getByText('+ Add Beneficiary'); + fireEvent.click(addButton); + }); + + expect(mockRouter.push).toHaveBeenCalledWith('/add-loved-one'); + }); + + it('should navigate to beneficiary detail when clicking card', async () => { + (api.getAllBeneficiaries as jest.Mock).mockResolvedValue({ + ok: true, + data: mockBeneficiaries, + }); + + render(); + + await waitFor(() => { + const mariaCard = screen.getByText('Maria Garcia').closest('button'); + if (mariaCard) { + fireEvent.click(mariaCard); + } + }); + + expect(mockRouter.push).toHaveBeenCalledWith('/beneficiaries/1'); + }); + }); + + describe('Equipment Status Badge Colors', () => { + it.each([ + ['none', 'No Equipment', 'bg-slate-100'], + ['ordered', 'Ordered', 'bg-blue-100'], + ['shipped', 'Shipped', 'bg-yellow-100'], + ['delivered', 'Delivered', 'bg-green-100'], + ['active', 'Active', 'bg-green-100'], + ['demo', 'Demo', 'bg-purple-100'], + ])( + 'should display %s status with correct badge', + async (status, text, colorClass) => { + const beneficiary: Beneficiary = { + ...mockBeneficiaries[0], + equipmentStatus: status as any, + // Remove subscription for "active" status test to avoid duplicate "Active" text + subscription: status === 'active' ? undefined : mockBeneficiaries[0].subscription, + }; + + (api.getAllBeneficiaries as jest.Mock).mockResolvedValue({ + ok: true, + data: [beneficiary], + }); + + const { container } = render(); + + await waitFor(() => { + // Use getAllByText for "Active" which may appear in both equipment and subscription + const badges = screen.getAllByText(text); + expect(badges.length).toBeGreaterThan(0); + expect(badges[0].className).toContain(colorClass); + }, { timeout: 3000 }); + } + ); + }); + + describe('Subscription Status Badge Colors', () => { + it.each([ + ['active', 'Active', 'bg-green-100'], + ['trial', 'Trial', 'bg-blue-100'], + ['expired', 'Expired', 'bg-red-100'], + ['cancelled', 'Cancelled', 'bg-slate-100'], + ])( + 'should display %s subscription with correct badge', + async (status, text, colorClass) => { + const beneficiary: Beneficiary = { + ...mockBeneficiaries[0], + subscription: { + status: status as any, + planType: 'monthly', + endDate: '2025-12-31', + cancelAtPeriodEnd: false, + }, + }; + + (api.getAllBeneficiaries as jest.Mock).mockResolvedValue({ + ok: true, + data: [beneficiary], + }); + + const { container } = render(); + + await waitFor(() => { + const badges = screen.getAllByText(text); + expect(badges.length).toBeGreaterThan(0); + expect(badges[0].className).toContain(colorClass); + }, { timeout: 3000 }); + } + ); + }); + + describe('Error Retry', () => { + it('should show retry button on error', async () => { + (api.getAllBeneficiaries as jest.Mock).mockResolvedValue({ + ok: false, + error: { message: 'Test error' }, + }); + + render(); + + await waitFor(() => { + // ErrorMessage component uses "Retry" button text + const retryButton = screen.getByText('Retry'); + expect(retryButton).toBeInTheDocument(); + }); + }); + }); +}); diff --git a/web/app/(main)/dashboard/page.tsx b/web/app/(main)/dashboard/page.tsx index b483935..1511b9e 100644 --- a/web/app/(main)/dashboard/page.tsx +++ b/web/app/(main)/dashboard/page.tsx @@ -1,23 +1,293 @@ 'use client'; +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import api from '@/lib/api'; +import { useAuthStore } from '@/stores/authStore'; +import { LoadingSpinner } from '@/components/ui/LoadingSpinner'; +import { ErrorMessage } from '@/components/ui/ErrorMessage'; +import type { Beneficiary } from '@/types'; + /** * Dashboard Page * * Main dashboard showing overview of beneficiaries and sensor status. + * Displays: + * - Summary statistics (total beneficiaries, active sensors, etc.) + * - List of all beneficiaries with their status + * - Quick actions (add beneficiary, view equipment) */ export default function DashboardPage() { + const router = useRouter(); + const { isAuthenticated, user } = useAuthStore(); + const [beneficiaries, setBeneficiaries] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + // Fetch beneficiaries on mount + useEffect(() => { + if (!isAuthenticated) { + router.push('/login'); + return; + } + + const fetchBeneficiaries = async () => { + setIsLoading(true); + setError(null); + + try { + const response = await api.getAllBeneficiaries(); + + if (response.ok && response.data) { + setBeneficiaries(response.data); + } else { + setError(response.error?.message || 'Failed to load beneficiaries'); + } + } catch (err) { + setError(err instanceof Error ? err.message : 'An unexpected error occurred'); + } finally { + setIsLoading(false); + } + }; + + fetchBeneficiaries(); + }, [isAuthenticated, router]); + + // Calculate summary statistics + const totalBeneficiaries = beneficiaries.length; + const withEquipment = beneficiaries.filter(b => b.hasDevices).length; + const activeSubscriptions = beneficiaries.filter(b => b.subscription?.status === 'active').length; + + const handleAddBeneficiary = () => { + router.push('/add-loved-one'); + }; + + const handleViewBeneficiary = (id: number) => { + router.push(`/beneficiaries/${id}`); + }; + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( + window.location.reload()} + /> + ); + } + return ( -
+
+ {/* Header */}
-

Dashboard

+

+ Welcome{user?.firstName ? `, ${user.firstName}` : ''} +

Monitor your loved ones and manage their health sensors

-
-

Dashboard content coming soon...

+ {/* Summary Cards */} +
+ + + +
+ + {/* Beneficiaries List */} +
+
+

Your Loved Ones

+ +
+ + {beneficiaries.length === 0 ? ( +
+
+ 👤 +
+

No beneficiaries yet

+

+ Add your first loved one to start monitoring their health and safety. +

+ +
+ ) : ( +
+ {beneficiaries.map((beneficiary) => ( + handleViewBeneficiary(beneficiary.id)} + /> + ))} +
+ )}
); } + +/** + * Summary Card Component + */ +interface SummaryCardProps { + title: string; + value: number; + icon: string; + color: 'blue' | 'green' | 'purple'; +} + +function SummaryCard({ title, value, icon, color }: SummaryCardProps) { + const colorClasses = { + blue: 'bg-blue-50 text-blue-600', + green: 'bg-green-50 text-green-600', + purple: 'bg-purple-50 text-purple-600', + }; + + return ( +
+
+
+

{title}

+

{value}

+
+
+ {icon} +
+
+
+ ); +} + +/** + * Beneficiary Card Component + */ +interface BeneficiaryCardProps { + beneficiary: Beneficiary; + onClick: () => void; +} + +function BeneficiaryCard({ beneficiary, onClick }: BeneficiaryCardProps) { + const getEquipmentStatusBadge = () => { + if (!beneficiary.equipmentStatus) { + return { text: 'No Equipment', color: 'bg-slate-100 text-slate-700' }; + } + + const statusMap: Record = { + none: { text: 'No Equipment', color: 'bg-slate-100 text-slate-700' }, + ordered: { text: 'Ordered', color: 'bg-blue-100 text-blue-700' }, + shipped: { text: 'Shipped', color: 'bg-yellow-100 text-yellow-700' }, + delivered: { text: 'Delivered', color: 'bg-green-100 text-green-700' }, + active: { text: 'Active', color: 'bg-green-100 text-green-700' }, + demo: { text: 'Demo', color: 'bg-purple-100 text-purple-700' }, + }; + + return statusMap[beneficiary.equipmentStatus] || statusMap.none; + }; + + const getSubscriptionBadge = () => { + if (!beneficiary.subscription) { + return null; + } + + const statusMap: Record = { + active: { text: 'Active', color: 'bg-green-100 text-green-700' }, + trial: { text: 'Trial', color: 'bg-blue-100 text-blue-700' }, + expired: { text: 'Expired', color: 'bg-red-100 text-red-700' }, + cancelled: { text: 'Cancelled', color: 'bg-slate-100 text-slate-700' }, + }; + + const badge = statusMap[beneficiary.subscription.status]; + if (!badge) return null; + + return ( + + {badge.text} + + ); + }; + + const equipmentBadge = getEquipmentStatusBadge(); + const subscriptionBadge = getSubscriptionBadge(); + + return ( + + ); +} diff --git a/web/tsconfig.json b/web/tsconfig.json index 1f51897..4c72822 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -26,7 +26,11 @@ "@/*": [ "./*" ] - } + }, + "types": [ + "jest", + "@testing-library/jest-dom" + ] }, "include": [ "next-env.d.ts",