From bda883d34d582de1b55a72727863fbafeadc4e85 Mon Sep 17 00:00:00 2001 From: Sergei Date: Sun, 1 Feb 2026 08:26:31 -0800 Subject: [PATCH] Add Beneficiary Detail Page with tabs and status components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create BeneficiaryDetailPage with Overview, Sensors, and Activity tabs - Add StatusBadge and SensorStatusBadge UI components - Add Tabs component (Tabs, TabsList, TabsTrigger, TabsContent) - Add getBeneficiary API method - Include comprehensive tests for all new components - Update ESLint config with root:true to prevent config inheritance 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- admin/.eslintrc.json | 3 +- admin/app/beneficiaries/[id]/page.tsx | 454 ++++++++++++++++++ .../__tests__/BeneficiaryDetailPage.test.tsx | 129 +++++ admin/components/ui/StatusBadge.tsx | 65 +++ admin/components/ui/Tabs.tsx | 87 ++++ .../ui/__tests__/StatusBadge.test.tsx | 111 +++++ admin/components/ui/__tests__/Tabs.test.tsx | 94 ++++ admin/components/ui/index.ts | 2 + admin/lib/api.js | 1 + 9 files changed, 945 insertions(+), 1 deletion(-) create mode 100644 admin/app/beneficiaries/[id]/page.tsx create mode 100644 admin/app/beneficiaries/__tests__/BeneficiaryDetailPage.test.tsx create mode 100644 admin/components/ui/StatusBadge.tsx create mode 100644 admin/components/ui/Tabs.tsx create mode 100644 admin/components/ui/__tests__/StatusBadge.test.tsx create mode 100644 admin/components/ui/__tests__/Tabs.test.tsx diff --git a/admin/.eslintrc.json b/admin/.eslintrc.json index bffb357..7c1a3ad 100644 --- a/admin/.eslintrc.json +++ b/admin/.eslintrc.json @@ -1,3 +1,4 @@ { - "extends": "next/core-web-vitals" + "extends": "next/core-web-vitals", + "root": true } diff --git a/admin/app/beneficiaries/[id]/page.tsx b/admin/app/beneficiaries/[id]/page.tsx new file mode 100644 index 0000000..a342ced --- /dev/null +++ b/admin/app/beneficiaries/[id]/page.tsx @@ -0,0 +1,454 @@ +'use client'; + +import React, { useEffect, useState, useCallback } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import Image from 'next/image'; +import AdminLayout from '../../../components/AdminLayout'; +import { + Card, + CardContent, + LoadingSpinner, + ErrorMessage, + Button, + Tabs, + TabsList, + TabsTrigger, + TabsContent, + StatusBadge, + SensorStatusBadge, +} from '../../../components/ui'; +import { getBeneficiary, getDevices } from '../../../lib/api'; + +interface Beneficiary { + id: number; + first_name: string | null; + last_name: string | null; + email: string; + phone: string | null; + address_street: string | null; + address_city: string | null; + address_country: string | null; + created_at: string; + updated_at: string; + subscription_status?: string; + subscription_plan?: string; + devices_count?: number; + avatar_url?: string | null; +} + +interface Device { + id: number; + name: string; + mac_address: string; + device_type: string; + status: 'online' | 'offline' | 'warning' | 'error'; + last_seen: string | null; + firmware_version: string | null; +} + +interface SensorData { + motion_detected: boolean; + last_motion: string | null; + door_status: 'open' | 'closed' | null; + temperature: number | null; + humidity: number | null; +} + +export default function BeneficiaryDetailPage() { + const params = useParams(); + const router = useRouter(); + const beneficiaryId = params?.id as string; + + const [beneficiary, setBeneficiary] = useState(null); + const [devices, setDevices] = useState([]); + const [sensorData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const loadBeneficiaryData = useCallback(async () => { + if (!beneficiaryId) return; + + setLoading(true); + setError(null); + + try { + const [beneficiaryData, devicesData] = await Promise.all([ + getBeneficiary(beneficiaryId), + getDevices(beneficiaryId).catch(() => ({ devices: [] })), + ]); + + setBeneficiary(beneficiaryData.beneficiary || beneficiaryData); + setDevices(devicesData.devices || []); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load beneficiary'); + } finally { + setLoading(false); + } + }, [beneficiaryId]); + + useEffect(() => { + loadBeneficiaryData(); + }, [loadBeneficiaryData]); + + const getDisplayName = (ben: Beneficiary) => { + const name = [ben.first_name, ben.last_name].filter(Boolean).join(' '); + return name || 'Unknown'; + }; + + const getInitials = (ben: Beneficiary) => { + const first = ben.first_name?.[0] || ''; + const last = ben.last_name?.[0] || ''; + return (first + last).toUpperCase() || '?'; + }; + + const getAddress = (ben: Beneficiary) => { + return [ben.address_street, ben.address_city, ben.address_country] + .filter(Boolean) + .join(', '); + }; + + const formatDate = (dateString: string | null) => { + if (!dateString) return 'N/A'; + return new Date(dateString).toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + }); + }; + + const formatDateTime = (dateString: string | null) => { + if (!dateString) return 'Never'; + return new Date(dateString).toLocaleString('en-US', { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + }; + + if (loading) { + return ( + + + + ); + } + + if (error || !beneficiary) { + return ( + + +
+ +
+
+ ); + } + + return ( + + {/* Header with back button */} +
+ + + {/* Beneficiary Info Header */} +
+ {/* Avatar */} + {beneficiary.avatar_url ? ( + {getDisplayName(beneficiary)} + ) : ( +
+ {getInitials(beneficiary)} +
+ )} + +
+

+ {getDisplayName(beneficiary)} +

+

{beneficiary.email}

+ {beneficiary.phone && ( +

{beneficiary.phone}

+ )} +
+ + {/* Status Badge */} +
+ {beneficiary.subscription_status && ( + + {beneficiary.subscription_status} + + )} + 0 && devices.some(d => d.status === 'online') ? 'online' : 'offline'} /> +
+
+
+ + {/* Tabs */} + + + Overview + Sensors ({devices.length}) + Activity History + + + {/* Overview Tab */} + +
+ {/* Personal Information */} + + +

+ Personal Information +

+
+ + + + + +
+
+
+ + {/* Subscription Info */} + + +

+ Subscription +

+
+ + + {beneficiary.subscription_status || 'Inactive'} + + } + /> + +
+
+
+ + {/* Live Sensor Data */} + + +

+ Live Sensor Data +

+ {sensorData ? ( +
+ + + + +
+ ) : ( +
+

No sensor data available

+

+ {devices.length === 0 + ? 'Connect sensors to see live data' + : 'Waiting for sensor data...'} +

+
+ )} +
+
+
+
+ + {/* Sensors Tab */} + + {devices.length === 0 ? ( + + +
+ + + +
+

+ No Sensors Connected +

+

+ This beneficiary doesn't have any sensors connected yet. +

+
+
+ ) : ( +
+ {devices.map((device) => ( + + +
+
+
+ +
+
+

{device.name}

+

{device.device_type}

+
+
+ +
+ +
+
+ MAC Address + + {device.mac_address} + +
+
+ Last Seen + + {formatDateTime(device.last_seen)} + +
+ {device.firmware_version && ( +
+ Firmware + {device.firmware_version} +
+ )} +
+
+
+ ))} +
+ )} +
+ + {/* Activity History Tab */} + + + +
+ + + +
+

+ Activity History +

+

+ Activity history will be available in a future update. +

+
+
+
+
+
+ ); +} + +// Helper Components +interface InfoRowProps { + label: string; + value: React.ReactNode; +} + +function InfoRow({ label, value }: InfoRowProps) { + return ( +
+ {label} + + {typeof value === 'string' ? value : value} + +
+ ); +} + +interface SensorCardProps { + icon: string; + label: string; + value: string; + subValue?: string; + status?: 'success' | 'warning' | 'default'; +} + +function SensorCard({ icon, label, value, subValue, status = 'default' }: SensorCardProps) { + const statusColors = { + success: 'bg-green-50 border-green-200', + warning: 'bg-yellow-50 border-yellow-200', + default: 'bg-gray-50 border-gray-200', + }; + + return ( +
+
{icon}
+
{label}
+
{value}
+ {subValue &&
{subValue}
} +
+ ); +} + +interface DeviceIconProps { + type: string; +} + +function DeviceIcon({ type }: DeviceIconProps) { + const icons: Record = { + motion: '🚶', + door: '🚪', + temperature: '🌡️', + humidity: '💧', + camera: '📷', + default: '📡', + }; + + return {icons[type.toLowerCase()] || icons.default}; +} diff --git a/admin/app/beneficiaries/__tests__/BeneficiaryDetailPage.test.tsx b/admin/app/beneficiaries/__tests__/BeneficiaryDetailPage.test.tsx new file mode 100644 index 0000000..a6c7895 --- /dev/null +++ b/admin/app/beneficiaries/__tests__/BeneficiaryDetailPage.test.tsx @@ -0,0 +1,129 @@ +import React from 'react'; +import { render, screen, waitFor, act } from '@testing-library/react'; +import { useParams, useRouter } from 'next/navigation'; +import BeneficiaryDetailPage from '../[id]/page'; + +// Mock next/navigation +jest.mock('next/navigation', () => ({ + useParams: jest.fn(), + useRouter: jest.fn(), +})); + +// Mock AdminLayout +jest.mock('../../../components/AdminLayout', () => { + return function MockAdminLayout({ children }: { children: React.ReactNode }) { + return
{children}
; + }; +}); + +// Mock API calls +jest.mock('../../../lib/api', () => ({ + getBeneficiary: jest.fn(), + getDevices: jest.fn(), +})); + +import { getBeneficiary, getDevices } from '../../../lib/api'; + +const mockBeneficiary = { + id: 1, + first_name: 'John', + last_name: 'Doe', + email: 'john@example.com', + phone: '+1234567890', + address_street: '123 Main St', + address_city: 'New York', + address_country: 'USA', + created_at: '2024-01-15T10:00:00Z', + updated_at: '2024-01-20T10:00:00Z', + subscription_status: 'active', + subscription_plan: 'Premium', +}; + +const mockDevices = [ + { + id: 1, + name: 'Living Room Sensor', + mac_address: 'AA:BB:CC:DD:EE:FF', + device_type: 'motion', + status: 'online', + last_seen: '2024-01-20T09:00:00Z', + firmware_version: '1.2.3', + }, +]; + +describe('BeneficiaryDetailPage', () => { + const mockPush = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + (useParams as jest.Mock).mockReturnValue({ id: '1' }); + (useRouter as jest.Mock).mockReturnValue({ push: mockPush }); + (getBeneficiary as jest.Mock).mockResolvedValue({ beneficiary: mockBeneficiary }); + (getDevices as jest.Mock).mockResolvedValue({ devices: mockDevices }); + }); + + it('renders beneficiary information after loading', async () => { + await act(async () => { + render(); + }); + + // Wait for loading to complete - check for Overview tab which appears after load + await waitFor( + () => { + expect(screen.getByText('Overview')).toBeInTheDocument(); + }, + { timeout: 3000 } + ); + + // Verify beneficiary data is rendered (may appear multiple times in different sections) + const nameElements = screen.getAllByText('John Doe'); + expect(nameElements.length).toBeGreaterThanOrEqual(1); + + const emailElements = screen.getAllByText('john@example.com'); + expect(emailElements.length).toBeGreaterThanOrEqual(1); + }); + + it('renders tabs structure', async () => { + await act(async () => { + render(); + }); + + await waitFor( + () => { + expect(screen.getByText('Overview')).toBeInTheDocument(); + }, + { timeout: 3000 } + ); + + expect(screen.getByText(/Sensors/)).toBeInTheDocument(); + expect(screen.getByText('Activity History')).toBeInTheDocument(); + }); + + it('renders error state when API fails', async () => { + (getBeneficiary as jest.Mock).mockRejectedValue(new Error('API Error')); + + await act(async () => { + render(); + }); + + await waitFor( + () => { + expect(screen.getByText('API Error')).toBeInTheDocument(); + }, + { timeout: 3000 } + ); + }); + + it('renders back button navigation', async () => { + await act(async () => { + render(); + }); + + await waitFor( + () => { + expect(screen.getByText('Back to Beneficiaries')).toBeInTheDocument(); + }, + { timeout: 3000 } + ); + }); +}); diff --git a/admin/components/ui/StatusBadge.tsx b/admin/components/ui/StatusBadge.tsx new file mode 100644 index 0000000..c0ec541 --- /dev/null +++ b/admin/components/ui/StatusBadge.tsx @@ -0,0 +1,65 @@ +import React from 'react'; + +type BadgeVariant = + | 'success' + | 'warning' + | 'error' + | 'info' + | 'default' + | 'online' + | 'offline'; + +interface StatusBadgeProps { + variant?: BadgeVariant; + children: React.ReactNode; + className?: string; + dot?: boolean; +} + +const variantStyles: Record = { + success: { bg: 'bg-green-100', text: 'text-green-700', dot: 'bg-green-500' }, + warning: { bg: 'bg-yellow-100', text: 'text-yellow-700', dot: 'bg-yellow-500' }, + error: { bg: 'bg-red-100', text: 'text-red-700', dot: 'bg-red-500' }, + info: { bg: 'bg-blue-100', text: 'text-blue-700', dot: 'bg-blue-500' }, + default: { bg: 'bg-gray-100', text: 'text-gray-700', dot: 'bg-gray-500' }, + online: { bg: 'bg-green-100', text: 'text-green-700', dot: 'bg-green-500' }, + offline: { bg: 'bg-gray-100', text: 'text-gray-500', dot: 'bg-gray-400' }, +}; + +export function StatusBadge({ + variant = 'default', + children, + className = '', + dot = false, +}: StatusBadgeProps) { + const styles = variantStyles[variant]; + + return ( + + {dot && } + {children} + + ); +} + +interface SensorStatusBadgeProps { + status: 'online' | 'offline' | 'warning' | 'error'; + className?: string; +} + +export function SensorStatusBadge({ status, className = '' }: SensorStatusBadgeProps) { + const labels: Record = { + online: 'Online', + offline: 'Offline', + warning: 'Warning', + error: 'Error', + }; + + return ( + + {labels[status] || status} + + ); +} diff --git a/admin/components/ui/Tabs.tsx b/admin/components/ui/Tabs.tsx new file mode 100644 index 0000000..2fe1d72 --- /dev/null +++ b/admin/components/ui/Tabs.tsx @@ -0,0 +1,87 @@ +import React, { useState, createContext, useContext } from 'react'; + +interface TabsContextType { + activeTab: string; + setActiveTab: (tab: string) => void; +} + +const TabsContext = createContext(null); + +interface TabsProps { + defaultValue: string; + children: React.ReactNode; + className?: string; +} + +export function Tabs({ defaultValue, children, className = '' }: TabsProps) { + const [activeTab, setActiveTab] = useState(defaultValue); + + return ( + +
{children}
+
+ ); +} + +interface TabsListProps { + children: React.ReactNode; + className?: string; +} + +export function TabsList({ children, className = '' }: TabsListProps) { + return ( +
+ {children} +
+ ); +} + +interface TabsTriggerProps { + value: string; + children: React.ReactNode; + className?: string; +} + +export function TabsTrigger({ value, children, className = '' }: TabsTriggerProps) { + const context = useContext(TabsContext); + if (!context) throw new Error('TabsTrigger must be used within Tabs'); + + const isActive = context.activeTab === value; + + return ( + + ); +} + +interface TabsContentProps { + value: string; + children: React.ReactNode; + className?: string; +} + +export function TabsContent({ value, children, className = '' }: TabsContentProps) { + const context = useContext(TabsContext); + if (!context) throw new Error('TabsContent must be used within Tabs'); + + if (context.activeTab !== value) return null; + + return ( +
+ {children} +
+ ); +} diff --git a/admin/components/ui/__tests__/StatusBadge.test.tsx b/admin/components/ui/__tests__/StatusBadge.test.tsx new file mode 100644 index 0000000..51b5663 --- /dev/null +++ b/admin/components/ui/__tests__/StatusBadge.test.tsx @@ -0,0 +1,111 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { StatusBadge, SensorStatusBadge } from '../StatusBadge'; + +describe('StatusBadge', () => { + it('renders children text', () => { + render(Active); + expect(screen.getByText('Active')).toBeInTheDocument(); + }); + + it('applies default variant styles', () => { + render(Default); + const badge = screen.getByText('Default'); + expect(badge).toHaveClass('bg-gray-100'); + expect(badge).toHaveClass('text-gray-700'); + }); + + it('applies success variant styles', () => { + render(Success); + const badge = screen.getByText('Success'); + expect(badge).toHaveClass('bg-green-100'); + expect(badge).toHaveClass('text-green-700'); + }); + + it('applies warning variant styles', () => { + render(Warning); + const badge = screen.getByText('Warning'); + expect(badge).toHaveClass('bg-yellow-100'); + expect(badge).toHaveClass('text-yellow-700'); + }); + + it('applies error variant styles', () => { + render(Error); + const badge = screen.getByText('Error'); + expect(badge).toHaveClass('bg-red-100'); + expect(badge).toHaveClass('text-red-700'); + }); + + it('applies info variant styles', () => { + render(Info); + const badge = screen.getByText('Info'); + expect(badge).toHaveClass('bg-blue-100'); + expect(badge).toHaveClass('text-blue-700'); + }); + + it('renders dot indicator when dot prop is true', () => { + const { container } = render(With Dot); + const dot = container.querySelector('.w-1\\.5.h-1\\.5.rounded-full'); + expect(dot).toBeInTheDocument(); + }); + + it('does not render dot indicator by default', () => { + const { container } = render(Without Dot); + const dot = container.querySelector('.w-1\\.5.h-1\\.5.rounded-full'); + expect(dot).not.toBeInTheDocument(); + }); + + it('applies custom className', () => { + render(Custom); + const badge = screen.getByText('Custom'); + expect(badge).toHaveClass('custom-class'); + }); + + it('applies online variant styles', () => { + render(Online); + const badge = screen.getByText('Online'); + expect(badge).toHaveClass('bg-green-100'); + expect(badge).toHaveClass('text-green-700'); + }); + + it('applies offline variant styles', () => { + render(Offline); + const badge = screen.getByText('Offline'); + expect(badge).toHaveClass('bg-gray-100'); + expect(badge).toHaveClass('text-gray-500'); + }); +}); + +describe('SensorStatusBadge', () => { + it('renders Online text for online status', () => { + render(); + expect(screen.getByText('Online')).toBeInTheDocument(); + }); + + it('renders Offline text for offline status', () => { + render(); + expect(screen.getByText('Offline')).toBeInTheDocument(); + }); + + it('renders Warning text for warning status', () => { + render(); + expect(screen.getByText('Warning')).toBeInTheDocument(); + }); + + it('renders Error text for error status', () => { + render(); + expect(screen.getByText('Error')).toBeInTheDocument(); + }); + + it('includes dot indicator', () => { + const { container } = render(); + const dot = container.querySelector('.w-1\\.5.h-1\\.5.rounded-full'); + expect(dot).toBeInTheDocument(); + }); + + it('applies custom className', () => { + render(); + const badge = screen.getByText('Online'); + expect(badge).toHaveClass('custom-sensor-class'); + }); +}); diff --git a/admin/components/ui/__tests__/Tabs.test.tsx b/admin/components/ui/__tests__/Tabs.test.tsx new file mode 100644 index 0000000..2549c6d --- /dev/null +++ b/admin/components/ui/__tests__/Tabs.test.tsx @@ -0,0 +1,94 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { Tabs, TabsList, TabsTrigger, TabsContent } from '../Tabs'; + +describe('Tabs', () => { + const TestTabs = () => ( + + + Tab 1 + Tab 2 + Tab 3 + + Content 1 + Content 2 + Content 3 + + ); + + it('renders with default tab selected', () => { + render(); + + expect(screen.getByText('Tab 1')).toBeInTheDocument(); + expect(screen.getByText('Tab 2')).toBeInTheDocument(); + expect(screen.getByText('Tab 3')).toBeInTheDocument(); + expect(screen.getByText('Content 1')).toBeInTheDocument(); + expect(screen.queryByText('Content 2')).not.toBeInTheDocument(); + expect(screen.queryByText('Content 3')).not.toBeInTheDocument(); + }); + + it('switches tab content when clicking triggers', () => { + render(); + + // Click Tab 2 + fireEvent.click(screen.getByText('Tab 2')); + expect(screen.queryByText('Content 1')).not.toBeInTheDocument(); + expect(screen.getByText('Content 2')).toBeInTheDocument(); + expect(screen.queryByText('Content 3')).not.toBeInTheDocument(); + + // Click Tab 3 + fireEvent.click(screen.getByText('Tab 3')); + expect(screen.queryByText('Content 1')).not.toBeInTheDocument(); + expect(screen.queryByText('Content 2')).not.toBeInTheDocument(); + expect(screen.getByText('Content 3')).toBeInTheDocument(); + + // Click back to Tab 1 + fireEvent.click(screen.getByText('Tab 1')); + expect(screen.getByText('Content 1')).toBeInTheDocument(); + expect(screen.queryByText('Content 2')).not.toBeInTheDocument(); + expect(screen.queryByText('Content 3')).not.toBeInTheDocument(); + }); + + it('applies correct aria attributes', () => { + render(); + + const tab1 = screen.getByText('Tab 1'); + const tab2 = screen.getByText('Tab 2'); + + expect(tab1).toHaveAttribute('role', 'tab'); + expect(tab1).toHaveAttribute('aria-selected', 'true'); + expect(tab2).toHaveAttribute('role', 'tab'); + expect(tab2).toHaveAttribute('aria-selected', 'false'); + + fireEvent.click(tab2); + expect(tab1).toHaveAttribute('aria-selected', 'false'); + expect(tab2).toHaveAttribute('aria-selected', 'true'); + }); + + it('renders content with tabpanel role', () => { + render(); + + const content = screen.getByRole('tabpanel'); + expect(content).toHaveTextContent('Content 1'); + }); + + it('applies custom className to components', () => { + render( + + + + Test + + + + Test Content + + + ); + + expect(document.querySelector('.custom-tabs')).toBeInTheDocument(); + expect(document.querySelector('.custom-list')).toBeInTheDocument(); + expect(document.querySelector('.custom-trigger')).toBeInTheDocument(); + expect(document.querySelector('.custom-content')).toBeInTheDocument(); + }); +}); diff --git a/admin/components/ui/index.ts b/admin/components/ui/index.ts index 2febf54..5210328 100644 --- a/admin/components/ui/index.ts +++ b/admin/components/ui/index.ts @@ -10,3 +10,5 @@ export { } from './Card'; export { LoadingSpinner } from './LoadingSpinner'; export { ErrorMessage, FullScreenError } from './ErrorMessage'; +export { Tabs, TabsList, TabsTrigger, TabsContent } from './Tabs'; +export { StatusBadge, SensorStatusBadge } from './StatusBadge'; diff --git a/admin/lib/api.js b/admin/lib/api.js index 8909768..908c2a1 100644 --- a/admin/lib/api.js +++ b/admin/lib/api.js @@ -63,6 +63,7 @@ export const getUsers = () => apiRequest('/api/admin/users'); export const getUser = (id) => apiRequest(`/api/admin/users/${id}`); export const getBeneficiaries = () => apiRequest('/api/admin/beneficiaries'); +export const getBeneficiary = (id) => apiRequest(`/api/admin/beneficiaries/${id}`); export const getSubscriptions = () => apiRequest('/api/admin/subscriptions'); // Deployments