Sergei bda883d34d 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>
2026-02-01 08:26:31 -08:00

455 lines
16 KiB
TypeScript

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