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:
parent
0c801c3b19
commit
2d0c7c2051
476
web/app/(main)/dashboard/__tests__/page.test.tsx
Normal file
476
web/app/(main)/dashboard/__tests__/page.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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<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>
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<LoadingSpinner size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<ErrorMessage
|
||||
message={error}
|
||||
onRetry={() => window.location.reload()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<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">
|
||||
Monitor your loved ones and manage their health sensors
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-slate-200 bg-white p-8 text-center">
|
||||
<p className="text-slate-600">Dashboard content coming soon...</p>
|
||||
{/* Summary Cards */}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -26,7 +26,11 @@
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"types": [
|
||||
"jest",
|
||||
"@testing-library/jest-dom"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user