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';
|
||||
export { LoadingSpinner } from './LoadingSpinner';
|
||||
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 getBeneficiaries = () => apiRequest('/api/admin/beneficiaries');
|
||||
export const getBeneficiary = (id) => apiRequest(`/api/admin/beneficiaries/${id}`);
|
||||
export const getSubscriptions = () => apiRequest('/api/admin/subscriptions');
|
||||
|
||||
// Deployments
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user