Add comprehensive Dashboard Page with beneficiary management

- 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 <noreply@anthropic.com>
This commit is contained in:
Sergei 2026-02-01 08:16:19 -08:00
parent 0c801c3b19
commit 2d0c7c2051
3 changed files with 755 additions and 5 deletions

View File

@ -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(<DashboardPage />);
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(<DashboardPage />);
expect(mockRouter.push).toHaveBeenCalledWith('/login');
});
it('should not redirect if authenticated', async () => {
(api.getAllBeneficiaries as jest.Mock).mockResolvedValue({
ok: true,
data: [],
});
render(<DashboardPage />);
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(<DashboardPage />);
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(<DashboardPage />);
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(<DashboardPage />);
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(<DashboardPage />);
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(<DashboardPage />);
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(<DashboardPage />);
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(<DashboardPage />);
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(<DashboardPage />);
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(<DashboardPage />);
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(<DashboardPage />);
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(<DashboardPage />);
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(<DashboardPage />);
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(<DashboardPage />);
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(<DashboardPage />);
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(<DashboardPage />);
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(<DashboardPage />);
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(<DashboardPage />);
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(<DashboardPage />);
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(<DashboardPage />);
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(<DashboardPage />);
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(<DashboardPage />);
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(<DashboardPage />);
await waitFor(() => {
// ErrorMessage component uses "Retry" button text
const retryButton = screen.getByText('Retry');
expect(retryButton).toBeInTheDocument();
});
});
});
});

View File

@ -1,23 +1,293 @@
'use client'; '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 * Dashboard Page
* *
* Main dashboard showing overview of beneficiaries and sensor status. * 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() { export default function DashboardPage() {
const router = useRouter();
const { isAuthenticated, user } = useAuthStore();
const [beneficiaries, setBeneficiaries] = useState<Beneficiary[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<div className="flex h-64 items-center justify-center">
<LoadingSpinner size="large" />
</div>
);
}
if (error) {
return (
<ErrorMessage
message={error}
onRetry={() => window.location.reload()}
/>
);
}
return ( return (
<div> <div className="space-y-6">
{/* Header */}
<div className="mb-6"> <div className="mb-6">
<h1 className="text-3xl font-bold text-slate-900">Dashboard</h1> <h1 className="text-3xl font-bold text-slate-900">
Welcome{user?.firstName ? `, ${user.firstName}` : ''}
</h1>
<p className="mt-2 text-slate-600"> <p className="mt-2 text-slate-600">
Monitor your loved ones and manage their health sensors Monitor your loved ones and manage their health sensors
</p> </p>
</div> </div>
<div className="rounded-lg border border-slate-200 bg-white p-8 text-center"> {/* Summary Cards */}
<p className="text-slate-600">Dashboard content coming soon...</p> <div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
<SummaryCard
title="Total Beneficiaries"
value={totalBeneficiaries}
icon="👥"
color="blue"
/>
<SummaryCard
title="With Equipment"
value={withEquipment}
icon="📡"
color="green"
/>
<SummaryCard
title="Active Subscriptions"
value={activeSubscriptions}
icon="✓"
color="purple"
/>
</div>
{/* Beneficiaries List */}
<div>
<div className="mb-4 flex items-center justify-between">
<h2 className="text-xl font-semibold text-slate-900">Your Loved Ones</h2>
<button
onClick={handleAddBeneficiary}
className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
+ Add Beneficiary
</button>
</div>
{beneficiaries.length === 0 ? (
<div className="rounded-lg border border-slate-200 bg-white p-12 text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-slate-100">
<span className="text-3xl">👤</span>
</div>
<h3 className="mb-2 text-lg font-semibold text-slate-900">No beneficiaries yet</h3>
<p className="mb-6 text-slate-600">
Add your first loved one to start monitoring their health and safety.
</p>
<button
onClick={handleAddBeneficiary}
className="rounded-lg bg-blue-600 px-6 py-3 font-medium text-white transition-colors hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
Add Your First Beneficiary
</button>
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{beneficiaries.map((beneficiary) => (
<BeneficiaryCard
key={beneficiary.id}
beneficiary={beneficiary}
onClick={() => handleViewBeneficiary(beneficiary.id)}
/>
))}
</div>
)}
</div> </div>
</div> </div>
); );
} }
/**
* 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 (
<div className="rounded-lg border border-slate-200 bg-white p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-slate-600">{title}</p>
<p className="mt-2 text-3xl font-bold text-slate-900">{value}</p>
</div>
<div className={`flex h-12 w-12 items-center justify-center rounded-lg ${colorClasses[color]}`}>
<span className="text-2xl">{icon}</span>
</div>
</div>
</div>
);
}
/**
* 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<string, { text: string; color: string }> = {
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<string, { text: string; color: string }> = {
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 (
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${badge.color}`}>
{badge.text}
</span>
);
};
const equipmentBadge = getEquipmentStatusBadge();
const subscriptionBadge = getSubscriptionBadge();
return (
<button
onClick={onClick}
className="group w-full rounded-lg border border-slate-200 bg-white p-6 text-left transition-all hover:border-blue-300 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
<div className="flex items-start justify-between">
<div className="flex items-center space-x-3">
{beneficiary.avatar ? (
<img
src={beneficiary.avatar}
alt={beneficiary.displayName}
className="h-12 w-12 rounded-full object-cover"
/>
) : (
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-slate-100">
<span className="text-xl">👤</span>
</div>
)}
<div>
<h3 className="font-semibold text-slate-900 group-hover:text-blue-600">
{beneficiary.displayName}
</h3>
{beneficiary.email && (
<p className="text-sm text-slate-600">{beneficiary.email}</p>
)}
</div>
</div>
</div>
<div className="mt-4 flex flex-wrap gap-2">
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${equipmentBadge.color}`}>
{equipmentBadge.text}
</span>
{subscriptionBadge}
{beneficiary.role && (
<span className="inline-flex items-center rounded-full bg-slate-100 px-2.5 py-0.5 text-xs font-medium text-slate-700">
{beneficiary.role === 'custodian' ? 'Custodian' : beneficiary.role === 'guardian' ? 'Guardian' : 'Caretaker'}
</span>
)}
</div>
{beneficiary.address && (
<p className="mt-3 text-sm text-slate-600">📍 {beneficiary.address}</p>
)}
</button>
);
}

View File

@ -26,7 +26,11 @@
"@/*": [ "@/*": [
"./*" "./*"
] ]
} },
"types": [
"jest",
"@testing-library/jest-dom"
]
}, },
"include": [ "include": [
"next-env.d.ts", "next-env.d.ts",