mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 21:31:14 -05:00
Add admin-only database statistics dashboard - Introduces types for database statistics and recent additions - Implements hooks to fetch statistics and recent additions via RPCs - Adds UI components for stats cards and a recent additions table - Integrates new AdminDatabaseStats page and routing under /admin/database-stats - Updates admin sidebar and app routes to expose the new dashboard - Enables real-time updates and export capabilities for recent additions
222 lines
8.1 KiB
TypeScript
222 lines
8.1 KiB
TypeScript
import { useState, useMemo } from 'react';
|
|
import { Link } from 'react-router-dom';
|
|
import { formatDistanceToNow } from 'date-fns';
|
|
import {
|
|
Building2,
|
|
Bike,
|
|
Factory,
|
|
Box,
|
|
MapPin,
|
|
Calendar,
|
|
Image,
|
|
Download,
|
|
Search
|
|
} from 'lucide-react';
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
import type { RecentAddition } from '@/types/database-stats';
|
|
|
|
interface RecentAdditionsTableProps {
|
|
additions: RecentAddition[];
|
|
isLoading: boolean;
|
|
}
|
|
|
|
const entityTypeConfig = {
|
|
park: { icon: Building2, label: 'Park', color: 'bg-blue-500' },
|
|
ride: { icon: Bike, label: 'Ride', color: 'bg-purple-500' },
|
|
company: { icon: Factory, label: 'Company', color: 'bg-orange-500' },
|
|
ride_model: { icon: Box, label: 'Model', color: 'bg-green-500' },
|
|
location: { icon: MapPin, label: 'Location', color: 'bg-yellow-500' },
|
|
timeline_event: { icon: Calendar, label: 'Event', color: 'bg-pink-500' },
|
|
photo: { icon: Image, label: 'Photo', color: 'bg-teal-500' },
|
|
};
|
|
|
|
export function RecentAdditionsTable({ additions, isLoading }: RecentAdditionsTableProps) {
|
|
const [entityTypeFilter, setEntityTypeFilter] = useState<string>('all');
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
|
|
const filteredAdditions = useMemo(() => {
|
|
let filtered = additions;
|
|
|
|
if (entityTypeFilter !== 'all') {
|
|
filtered = filtered.filter(item => item.entity_type === entityTypeFilter);
|
|
}
|
|
|
|
if (searchQuery) {
|
|
const query = searchQuery.toLowerCase();
|
|
filtered = filtered.filter(item =>
|
|
item.entity_name.toLowerCase().includes(query) ||
|
|
item.created_by_username?.toLowerCase().includes(query)
|
|
);
|
|
}
|
|
|
|
return filtered;
|
|
}, [additions, entityTypeFilter, searchQuery]);
|
|
|
|
const exportToCSV = () => {
|
|
const headers = ['Type', 'Name', 'Added By', 'Added At'];
|
|
const rows = filteredAdditions.map(item => [
|
|
entityTypeConfig[item.entity_type].label,
|
|
item.entity_name,
|
|
item.created_by_username || 'System',
|
|
new Date(item.created_at).toISOString(),
|
|
]);
|
|
|
|
const csv = [headers, ...rows].map(row => row.join(',')).join('\n');
|
|
const blob = new Blob([csv], { type: 'text/csv' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `recent-additions-${new Date().toISOString()}.csv`;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
};
|
|
|
|
const getEntityLink = (item: RecentAddition) => {
|
|
if (item.entity_type === 'park' && item.entity_slug) {
|
|
return `/parks/${item.entity_slug}`;
|
|
}
|
|
if (item.entity_type === 'ride' && item.park_slug && item.entity_slug) {
|
|
return `/parks/${item.park_slug}/rides/${item.entity_slug}`;
|
|
}
|
|
if (item.entity_type === 'company' && item.entity_slug) {
|
|
return `/manufacturers/${item.entity_slug}`;
|
|
}
|
|
if (item.entity_type === 'ride_model' && item.entity_slug) {
|
|
return `/models/${item.entity_slug}`;
|
|
}
|
|
return null;
|
|
};
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Latest Additions</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="flex items-center justify-center py-8">
|
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle>Latest Additions (Newest First)</CardTitle>
|
|
<Button onClick={exportToCSV} variant="outline" size="sm">
|
|
<Download className="h-4 w-4 mr-2" />
|
|
Export CSV
|
|
</Button>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="flex flex-col sm:flex-row gap-4 mb-6">
|
|
<div className="flex-1 relative">
|
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
<Input
|
|
placeholder="Search by name or creator..."
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
className="pl-9"
|
|
/>
|
|
</div>
|
|
<Select value={entityTypeFilter} onValueChange={setEntityTypeFilter}>
|
|
<SelectTrigger className="w-[180px]">
|
|
<SelectValue placeholder="Filter by type" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">All Types</SelectItem>
|
|
<SelectItem value="park">Parks</SelectItem>
|
|
<SelectItem value="ride">Rides</SelectItem>
|
|
<SelectItem value="company">Companies</SelectItem>
|
|
<SelectItem value="ride_model">Ride Models</SelectItem>
|
|
<SelectItem value="location">Locations</SelectItem>
|
|
<SelectItem value="timeline_event">Timeline Events</SelectItem>
|
|
<SelectItem value="photo">Photos</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="space-y-4">
|
|
{filteredAdditions.length === 0 ? (
|
|
<div className="text-center py-8 text-muted-foreground">
|
|
No additions found matching your filters.
|
|
</div>
|
|
) : (
|
|
filteredAdditions.map((item) => {
|
|
const config = entityTypeConfig[item.entity_type];
|
|
const Icon = config.icon;
|
|
const link = getEntityLink(item);
|
|
|
|
return (
|
|
<div
|
|
key={`${item.entity_type}-${item.entity_id}`}
|
|
className="flex items-center gap-4 p-4 rounded-lg border bg-card hover:bg-accent/50 transition-colors"
|
|
>
|
|
<div className={`p-2 rounded-lg ${config.color} bg-opacity-10`}>
|
|
<Icon className="h-5 w-5" />
|
|
</div>
|
|
|
|
{item.image_url && (
|
|
<img
|
|
src={item.image_url}
|
|
alt={item.entity_name}
|
|
className="h-12 w-12 rounded object-cover"
|
|
/>
|
|
)}
|
|
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<Badge variant="outline" className="text-xs">
|
|
{config.label}
|
|
</Badge>
|
|
{link ? (
|
|
<Link
|
|
to={link}
|
|
className="font-medium text-sm hover:underline truncate"
|
|
>
|
|
{item.entity_name}
|
|
</Link>
|
|
) : (
|
|
<span className="font-medium text-sm truncate">
|
|
{item.entity_name}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
{item.created_by_username ? (
|
|
<>
|
|
<Avatar className="h-4 w-4">
|
|
<AvatarImage src={item.created_by_avatar || undefined} />
|
|
<AvatarFallback className="text-[8px]">
|
|
{item.created_by_username[0].toUpperCase()}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
<span>@{item.created_by_username}</span>
|
|
</>
|
|
) : (
|
|
<span>System</span>
|
|
)}
|
|
<span>•</span>
|
|
<span>{formatDistanceToNow(new Date(item.created_at), { addSuffix: true })}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|