Add Beneficiary Detail Page with tabs and status components

- 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 <noreply@anthropic.com>
This commit is contained in:
Sergei 2026-02-01 08:26:31 -08:00
parent 2d0c7c2051
commit bda883d34d
9 changed files with 945 additions and 1 deletions

View File

@ -1,3 +1,4 @@
{
"extends": "next/core-web-vitals"
"extends": "next/core-web-vitals",
"root": true
}

View File

@ -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<Beneficiary | null>(null);
const [devices, setDevices] = useState<Device[]>([]);
const [sensorData] = useState<SensorData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<AdminLayout>
<LoadingSpinner message="Loading beneficiary data..." />
</AdminLayout>
);
}
if (error || !beneficiary) {
return (
<AdminLayout>
<ErrorMessage
message={error || 'Beneficiary not found'}
onRetry={loadBeneficiaryData}
/>
<div className="mt-4">
<Button variant="ghost" onClick={() => router.push('/beneficiaries')}>
Back to Beneficiaries
</Button>
</div>
</AdminLayout>
);
}
return (
<AdminLayout>
{/* Header with back button */}
<div className="mb-6">
<button
onClick={() => router.push('/beneficiaries')}
className="flex items-center gap-2 text-textSecondary hover:text-textPrimary transition-colors mb-4"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Back to Beneficiaries
</button>
{/* Beneficiary Info Header */}
<div className="flex items-start gap-4">
{/* Avatar */}
{beneficiary.avatar_url ? (
<Image
src={beneficiary.avatar_url}
alt={getDisplayName(beneficiary)}
width={64}
height={64}
className="w-16 h-16 rounded-full object-cover"
/>
) : (
<div className="w-16 h-16 rounded-full bg-primary flex items-center justify-center text-white text-xl font-semibold">
{getInitials(beneficiary)}
</div>
)}
<div className="flex-1">
<h1 className="text-2xl font-semibold text-textPrimary">
{getDisplayName(beneficiary)}
</h1>
<p className="text-textSecondary">{beneficiary.email}</p>
{beneficiary.phone && (
<p className="text-textMuted text-sm mt-1">{beneficiary.phone}</p>
)}
</div>
{/* Status Badge */}
<div className="flex items-center gap-2">
{beneficiary.subscription_status && (
<StatusBadge
variant={beneficiary.subscription_status === 'active' ? 'success' : 'warning'}
>
{beneficiary.subscription_status}
</StatusBadge>
)}
<SensorStatusBadge status={devices.length > 0 && devices.some(d => d.status === 'online') ? 'online' : 'offline'} />
</div>
</div>
</div>
{/* Tabs */}
<Tabs defaultValue="overview">
<TabsList className="mb-6">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="sensors">Sensors ({devices.length})</TabsTrigger>
<TabsTrigger value="history">Activity History</TabsTrigger>
</TabsList>
{/* Overview Tab */}
<TabsContent value="overview">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Personal Information */}
<Card>
<CardContent>
<h3 className="text-lg font-semibold text-textPrimary mb-4">
Personal Information
</h3>
<div className="space-y-3">
<InfoRow label="Full Name" value={getDisplayName(beneficiary)} />
<InfoRow label="Email" value={beneficiary.email} />
<InfoRow label="Phone" value={beneficiary.phone || 'Not provided'} />
<InfoRow label="Address" value={getAddress(beneficiary) || 'Not provided'} />
<InfoRow label="Member Since" value={formatDate(beneficiary.created_at)} />
</div>
</CardContent>
</Card>
{/* Subscription Info */}
<Card>
<CardContent>
<h3 className="text-lg font-semibold text-textPrimary mb-4">
Subscription
</h3>
<div className="space-y-3">
<InfoRow
label="Plan"
value={beneficiary.subscription_plan || 'No active plan'}
/>
<InfoRow
label="Status"
value={
<StatusBadge
variant={beneficiary.subscription_status === 'active' ? 'success' : 'default'}
>
{beneficiary.subscription_status || 'Inactive'}
</StatusBadge>
}
/>
<InfoRow
label="Connected Devices"
value={`${devices.length} device${devices.length !== 1 ? 's' : ''}`}
/>
</div>
</CardContent>
</Card>
{/* Live Sensor Data */}
<Card className="md:col-span-2">
<CardContent>
<h3 className="text-lg font-semibold text-textPrimary mb-4">
Live Sensor Data
</h3>
{sensorData ? (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<SensorCard
icon="🚶"
label="Motion"
value={sensorData.motion_detected ? 'Detected' : 'No motion'}
subValue={sensorData.last_motion || undefined}
status={sensorData.motion_detected ? 'success' : 'default'}
/>
<SensorCard
icon="🚪"
label="Door"
value={sensorData.door_status === 'open' ? 'Open' : 'Closed'}
status={sensorData.door_status === 'open' ? 'warning' : 'success'}
/>
<SensorCard
icon="🌡️"
label="Temperature"
value={sensorData.temperature ? `${sensorData.temperature}°C` : 'N/A'}
/>
<SensorCard
icon="💧"
label="Humidity"
value={sensorData.humidity ? `${sensorData.humidity}%` : 'N/A'}
/>
</div>
) : (
<div className="text-center py-8 text-textMuted">
<p>No sensor data available</p>
<p className="text-sm mt-1">
{devices.length === 0
? 'Connect sensors to see live data'
: 'Waiting for sensor data...'}
</p>
</div>
)}
</CardContent>
</Card>
</div>
</TabsContent>
{/* Sensors Tab */}
<TabsContent value="sensors">
{devices.length === 0 ? (
<Card>
<CardContent className="text-center py-12">
<div className="w-16 h-16 bg-surface rounded-full flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-textMuted" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
</svg>
</div>
<h3 className="text-lg font-semibold text-textPrimary mb-2">
No Sensors Connected
</h3>
<p className="text-textSecondary mb-4">
This beneficiary doesn&apos;t have any sensors connected yet.
</p>
</CardContent>
</Card>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{devices.map((device) => (
<Card key={device.id} hover>
<CardContent>
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-surface rounded-lg flex items-center justify-center">
<DeviceIcon type={device.device_type} />
</div>
<div>
<h4 className="font-medium text-textPrimary">{device.name}</h4>
<p className="text-xs text-textMuted">{device.device_type}</p>
</div>
</div>
<SensorStatusBadge status={device.status} />
</div>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-textMuted">MAC Address</span>
<span className="text-textSecondary font-mono text-xs">
{device.mac_address}
</span>
</div>
<div className="flex justify-between">
<span className="text-textMuted">Last Seen</span>
<span className="text-textSecondary">
{formatDateTime(device.last_seen)}
</span>
</div>
{device.firmware_version && (
<div className="flex justify-between">
<span className="text-textMuted">Firmware</span>
<span className="text-textSecondary">{device.firmware_version}</span>
</div>
)}
</div>
</CardContent>
</Card>
))}
</div>
)}
</TabsContent>
{/* Activity History Tab */}
<TabsContent value="history">
<Card>
<CardContent className="text-center py-12">
<div className="w-16 h-16 bg-surface rounded-full flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-textMuted" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<h3 className="text-lg font-semibold text-textPrimary mb-2">
Activity History
</h3>
<p className="text-textSecondary">
Activity history will be available in a future update.
</p>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</AdminLayout>
);
}
// Helper Components
interface InfoRowProps {
label: string;
value: React.ReactNode;
}
function InfoRow({ label, value }: InfoRowProps) {
return (
<div className="flex justify-between items-center">
<span className="text-textMuted text-sm">{label}</span>
<span className="text-textPrimary text-sm font-medium">
{typeof value === 'string' ? value : value}
</span>
</div>
);
}
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 (
<div className={`p-4 rounded-lg border ${statusColors[status]}`}>
<div className="text-2xl mb-2">{icon}</div>
<div className="text-xs text-textMuted mb-1">{label}</div>
<div className="text-lg font-semibold text-textPrimary">{value}</div>
{subValue && <div className="text-xs text-textMuted mt-1">{subValue}</div>}
</div>
);
}
interface DeviceIconProps {
type: string;
}
function DeviceIcon({ type }: DeviceIconProps) {
const icons: Record<string, string> = {
motion: '🚶',
door: '🚪',
temperature: '🌡️',
humidity: '💧',
camera: '📷',
default: '📡',
};
return <span className="text-xl">{icons[type.toLowerCase()] || icons.default}</span>;
}

View File

@ -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 <div data-testid="admin-layout">{children}</div>;
};
});
// 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(<BeneficiaryDetailPage />);
});
// 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(<BeneficiaryDetailPage />);
});
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(<BeneficiaryDetailPage />);
});
await waitFor(
() => {
expect(screen.getByText('API Error')).toBeInTheDocument();
},
{ timeout: 3000 }
);
});
it('renders back button navigation', async () => {
await act(async () => {
render(<BeneficiaryDetailPage />);
});
await waitFor(
() => {
expect(screen.getByText('Back to Beneficiaries')).toBeInTheDocument();
},
{ timeout: 3000 }
);
});
});

View File

@ -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<BadgeVariant, { bg: string; text: string; dot: string }> = {
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 (
<span
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium ${styles.bg} ${styles.text} ${className}`}
>
{dot && <span className={`w-1.5 h-1.5 rounded-full ${styles.dot}`} />}
{children}
</span>
);
}
interface SensorStatusBadgeProps {
status: 'online' | 'offline' | 'warning' | 'error';
className?: string;
}
export function SensorStatusBadge({ status, className = '' }: SensorStatusBadgeProps) {
const labels: Record<string, string> = {
online: 'Online',
offline: 'Offline',
warning: 'Warning',
error: 'Error',
};
return (
<StatusBadge variant={status} dot className={className}>
{labels[status] || status}
</StatusBadge>
);
}

View File

@ -0,0 +1,87 @@
import React, { useState, createContext, useContext } from 'react';
interface TabsContextType {
activeTab: string;
setActiveTab: (tab: string) => void;
}
const TabsContext = createContext<TabsContextType | null>(null);
interface TabsProps {
defaultValue: string;
children: React.ReactNode;
className?: string;
}
export function Tabs({ defaultValue, children, className = '' }: TabsProps) {
const [activeTab, setActiveTab] = useState(defaultValue);
return (
<TabsContext.Provider value={{ activeTab, setActiveTab }}>
<div className={className}>{children}</div>
</TabsContext.Provider>
);
}
interface TabsListProps {
children: React.ReactNode;
className?: string;
}
export function TabsList({ children, className = '' }: TabsListProps) {
return (
<div
className={`flex border-b border-gray-200 ${className}`}
role="tablist"
>
{children}
</div>
);
}
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 (
<button
role="tab"
aria-selected={isActive}
onClick={() => context.setActiveTab(value)}
className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
isActive
? 'border-primary text-primary'
: 'border-transparent text-textSecondary hover:text-textPrimary hover:border-gray-300'
} ${className}`}
>
{children}
</button>
);
}
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 (
<div role="tabpanel" className={`pt-4 ${className}`}>
{children}
</div>
);
}

View File

@ -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(<StatusBadge>Active</StatusBadge>);
expect(screen.getByText('Active')).toBeInTheDocument();
});
it('applies default variant styles', () => {
render(<StatusBadge>Default</StatusBadge>);
const badge = screen.getByText('Default');
expect(badge).toHaveClass('bg-gray-100');
expect(badge).toHaveClass('text-gray-700');
});
it('applies success variant styles', () => {
render(<StatusBadge variant="success">Success</StatusBadge>);
const badge = screen.getByText('Success');
expect(badge).toHaveClass('bg-green-100');
expect(badge).toHaveClass('text-green-700');
});
it('applies warning variant styles', () => {
render(<StatusBadge variant="warning">Warning</StatusBadge>);
const badge = screen.getByText('Warning');
expect(badge).toHaveClass('bg-yellow-100');
expect(badge).toHaveClass('text-yellow-700');
});
it('applies error variant styles', () => {
render(<StatusBadge variant="error">Error</StatusBadge>);
const badge = screen.getByText('Error');
expect(badge).toHaveClass('bg-red-100');
expect(badge).toHaveClass('text-red-700');
});
it('applies info variant styles', () => {
render(<StatusBadge variant="info">Info</StatusBadge>);
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(<StatusBadge dot>With Dot</StatusBadge>);
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(<StatusBadge>Without Dot</StatusBadge>);
const dot = container.querySelector('.w-1\\.5.h-1\\.5.rounded-full');
expect(dot).not.toBeInTheDocument();
});
it('applies custom className', () => {
render(<StatusBadge className="custom-class">Custom</StatusBadge>);
const badge = screen.getByText('Custom');
expect(badge).toHaveClass('custom-class');
});
it('applies online variant styles', () => {
render(<StatusBadge variant="online">Online</StatusBadge>);
const badge = screen.getByText('Online');
expect(badge).toHaveClass('bg-green-100');
expect(badge).toHaveClass('text-green-700');
});
it('applies offline variant styles', () => {
render(<StatusBadge variant="offline">Offline</StatusBadge>);
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(<SensorStatusBadge status="online" />);
expect(screen.getByText('Online')).toBeInTheDocument();
});
it('renders Offline text for offline status', () => {
render(<SensorStatusBadge status="offline" />);
expect(screen.getByText('Offline')).toBeInTheDocument();
});
it('renders Warning text for warning status', () => {
render(<SensorStatusBadge status="warning" />);
expect(screen.getByText('Warning')).toBeInTheDocument();
});
it('renders Error text for error status', () => {
render(<SensorStatusBadge status="error" />);
expect(screen.getByText('Error')).toBeInTheDocument();
});
it('includes dot indicator', () => {
const { container } = render(<SensorStatusBadge status="online" />);
const dot = container.querySelector('.w-1\\.5.h-1\\.5.rounded-full');
expect(dot).toBeInTheDocument();
});
it('applies custom className', () => {
render(<SensorStatusBadge status="online" className="custom-sensor-class" />);
const badge = screen.getByText('Online');
expect(badge).toHaveClass('custom-sensor-class');
});
});

View File

@ -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 = () => (
<Tabs defaultValue="tab1">
<TabsList>
<TabsTrigger value="tab1">Tab 1</TabsTrigger>
<TabsTrigger value="tab2">Tab 2</TabsTrigger>
<TabsTrigger value="tab3">Tab 3</TabsTrigger>
</TabsList>
<TabsContent value="tab1">Content 1</TabsContent>
<TabsContent value="tab2">Content 2</TabsContent>
<TabsContent value="tab3">Content 3</TabsContent>
</Tabs>
);
it('renders with default tab selected', () => {
render(<TestTabs />);
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(<TestTabs />);
// 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(<TestTabs />);
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(<TestTabs />);
const content = screen.getByRole('tabpanel');
expect(content).toHaveTextContent('Content 1');
});
it('applies custom className to components', () => {
render(
<Tabs defaultValue="test" className="custom-tabs">
<TabsList className="custom-list">
<TabsTrigger value="test" className="custom-trigger">
Test
</TabsTrigger>
</TabsList>
<TabsContent value="test" className="custom-content">
Test Content
</TabsContent>
</Tabs>
);
expect(document.querySelector('.custom-tabs')).toBeInTheDocument();
expect(document.querySelector('.custom-list')).toBeInTheDocument();
expect(document.querySelector('.custom-trigger')).toBeInTheDocument();
expect(document.querySelector('.custom-content')).toBeInTheDocument();
});
});

View File

@ -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';

View File

@ -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