- 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>
455 lines
16 KiB
TypeScript
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'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>;
|
|
}
|