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:
parent
2d0c7c2051
commit
bda883d34d
@ -1,3 +1,4 @@
|
|||||||
{
|
{
|
||||||
"extends": "next/core-web-vitals"
|
"extends": "next/core-web-vitals",
|
||||||
|
"root": true
|
||||||
}
|
}
|
||||||
|
|||||||
454
admin/app/beneficiaries/[id]/page.tsx
Normal file
454
admin/app/beneficiaries/[id]/page.tsx
Normal 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'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>;
|
||||||
|
}
|
||||||
129
admin/app/beneficiaries/__tests__/BeneficiaryDetailPage.test.tsx
Normal file
129
admin/app/beneficiaries/__tests__/BeneficiaryDetailPage.test.tsx
Normal 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 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
65
admin/components/ui/StatusBadge.tsx
Normal file
65
admin/components/ui/StatusBadge.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
87
admin/components/ui/Tabs.tsx
Normal file
87
admin/components/ui/Tabs.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
111
admin/components/ui/__tests__/StatusBadge.test.tsx
Normal file
111
admin/components/ui/__tests__/StatusBadge.test.tsx
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
94
admin/components/ui/__tests__/Tabs.test.tsx
Normal file
94
admin/components/ui/__tests__/Tabs.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -10,3 +10,5 @@ export {
|
|||||||
} from './Card';
|
} from './Card';
|
||||||
export { LoadingSpinner } from './LoadingSpinner';
|
export { LoadingSpinner } from './LoadingSpinner';
|
||||||
export { ErrorMessage, FullScreenError } from './ErrorMessage';
|
export { ErrorMessage, FullScreenError } from './ErrorMessage';
|
||||||
|
export { Tabs, TabsList, TabsTrigger, TabsContent } from './Tabs';
|
||||||
|
export { StatusBadge, SensorStatusBadge } from './StatusBadge';
|
||||||
|
|||||||
@ -63,6 +63,7 @@ export const getUsers = () => apiRequest('/api/admin/users');
|
|||||||
export const getUser = (id) => apiRequest(`/api/admin/users/${id}`);
|
export const getUser = (id) => apiRequest(`/api/admin/users/${id}`);
|
||||||
|
|
||||||
export const getBeneficiaries = () => apiRequest('/api/admin/beneficiaries');
|
export const getBeneficiaries = () => apiRequest('/api/admin/beneficiaries');
|
||||||
|
export const getBeneficiary = (id) => apiRequest(`/api/admin/beneficiaries/${id}`);
|
||||||
export const getSubscriptions = () => apiRequest('/api/admin/subscriptions');
|
export const getSubscriptions = () => apiRequest('/api/admin/subscriptions');
|
||||||
|
|
||||||
// Deployments
|
// Deployments
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user