mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 06:51:12 -05:00
Implement admin database stats dashboard
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
This commit is contained in:
@@ -69,6 +69,7 @@ const AdminSystemLog = lazy(() => import("./pages/AdminSystemLog"));
|
||||
const AdminUsers = lazy(() => import("./pages/AdminUsers"));
|
||||
const AdminBlog = lazy(() => import("./pages/AdminBlog"));
|
||||
const AdminSettings = lazy(() => import("./pages/AdminSettings"));
|
||||
const AdminDatabaseStats = lazy(() => import("./pages/AdminDatabaseStats"));
|
||||
const AdminContact = lazy(() => import("./pages/admin/AdminContact"));
|
||||
const AdminEmailSettings = lazy(() => import("./pages/admin/AdminEmailSettings"));
|
||||
const ErrorMonitoring = lazy(() => import("./pages/admin/ErrorMonitoring"));
|
||||
@@ -414,6 +415,14 @@ function AppContent(): React.JSX.Element {
|
||||
</AdminErrorBoundary>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/database-stats"
|
||||
element={
|
||||
<AdminErrorBoundary section="Database Statistics">
|
||||
<AdminDatabaseStats />
|
||||
</AdminErrorBoundary>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Utility routes - lazy loaded */}
|
||||
<Route path="/force-logout" element={<ForceLogout />} />
|
||||
|
||||
45
src/components/admin/database-stats/DatabaseStatsCard.tsx
Normal file
45
src/components/admin/database-stats/DatabaseStatsCard.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface DatabaseStatsCardProps {
|
||||
title: string;
|
||||
icon: LucideIcon;
|
||||
stats: Array<{
|
||||
label: string;
|
||||
value: number | string;
|
||||
trend?: {
|
||||
value: number;
|
||||
period: string;
|
||||
};
|
||||
}>;
|
||||
iconClassName?: string;
|
||||
}
|
||||
|
||||
export function DatabaseStatsCard({ title, icon: Icon, stats, iconClassName }: DatabaseStatsCardProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">{title}</CardTitle>
|
||||
<Icon className={cn("h-4 w-4 text-muted-foreground", iconClassName)} />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{stats.map((stat, index) => (
|
||||
<div key={index} className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">{stat.label}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold">{stat.value.toLocaleString()}</span>
|
||||
{stat.trend && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
+{stat.trend.value} ({stat.trend.period})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
221
src/components/admin/database-stats/RecentAdditionsTable.tsx
Normal file
221
src/components/admin/database-stats/RecentAdditionsTable.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { LayoutDashboard, FileText, Flag, Users, Settings, ArrowLeft, ScrollText, BookOpen, Inbox, Mail, AlertTriangle, Shield, Activity } from 'lucide-react';
|
||||
import { LayoutDashboard, FileText, Flag, Users, Settings, ArrowLeft, ScrollText, BookOpen, Inbox, Mail, AlertTriangle, Shield, Activity, BarChart } from 'lucide-react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { useUserRole } from '@/hooks/useUserRole';
|
||||
import { useSidebar } from '@/hooks/useSidebar';
|
||||
@@ -68,6 +68,11 @@ export function AdminSidebar() {
|
||||
url: '/admin/rate-limit-metrics',
|
||||
icon: Shield,
|
||||
},
|
||||
{
|
||||
title: 'Database Stats',
|
||||
url: '/admin/database-stats',
|
||||
icon: BarChart,
|
||||
},
|
||||
{
|
||||
title: 'Users',
|
||||
url: '/admin/users',
|
||||
|
||||
21
src/hooks/useAdminDatabaseStats.ts
Normal file
21
src/hooks/useAdminDatabaseStats.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { queryKeys } from '@/lib/queryKeys';
|
||||
import type { DatabaseStatistics } from '@/types/database-stats';
|
||||
|
||||
export function useAdminDatabaseStats() {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.admin.databaseStats(),
|
||||
queryFn: async () => {
|
||||
const { data, error } = await supabase.rpc('get_database_statistics');
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data as unknown as DatabaseStatistics;
|
||||
},
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
refetchInterval: 60 * 1000, // Auto-refetch every 60 seconds
|
||||
});
|
||||
}
|
||||
74
src/hooks/useRecentAdditions.ts
Normal file
74
src/hooks/useRecentAdditions.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { queryKeys } from '@/lib/queryKeys';
|
||||
import type { RecentAddition } from '@/types/database-stats';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export function useRecentAdditions(limit: number = 50, entityTypeFilter?: string) {
|
||||
const query = useQuery({
|
||||
queryKey: queryKeys.admin.recentAdditions(limit),
|
||||
queryFn: async () => {
|
||||
const { data, error } = await supabase.rpc('get_recent_additions', {
|
||||
limit_count: limit
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data as unknown as RecentAddition[];
|
||||
},
|
||||
staleTime: 2 * 60 * 1000, // 2 minutes
|
||||
refetchInterval: 30 * 1000, // Auto-refetch every 30 seconds
|
||||
});
|
||||
|
||||
// Set up real-time subscriptions
|
||||
useEffect(() => {
|
||||
const channels = [
|
||||
supabase
|
||||
.channel('recent_additions_parks')
|
||||
.on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'parks' }, () => {
|
||||
query.refetch();
|
||||
})
|
||||
.subscribe(),
|
||||
supabase
|
||||
.channel('recent_additions_rides')
|
||||
.on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'rides' }, () => {
|
||||
query.refetch();
|
||||
})
|
||||
.subscribe(),
|
||||
supabase
|
||||
.channel('recent_additions_companies')
|
||||
.on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'companies' }, () => {
|
||||
query.refetch();
|
||||
})
|
||||
.subscribe(),
|
||||
supabase
|
||||
.channel('recent_additions_ride_models')
|
||||
.on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'ride_models' }, () => {
|
||||
query.refetch();
|
||||
})
|
||||
.subscribe(),
|
||||
supabase
|
||||
.channel('recent_additions_photos')
|
||||
.on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'entity_photos' }, () => {
|
||||
query.refetch();
|
||||
})
|
||||
.subscribe(),
|
||||
];
|
||||
|
||||
return () => {
|
||||
channels.forEach(channel => channel.unsubscribe());
|
||||
};
|
||||
}, [query]);
|
||||
|
||||
// Filter by entity type on client side
|
||||
const filteredData = entityTypeFilter && query.data
|
||||
? query.data.filter(item => item.entity_type === entityTypeFilter)
|
||||
: query.data;
|
||||
|
||||
return {
|
||||
...query,
|
||||
data: filteredData,
|
||||
};
|
||||
}
|
||||
@@ -6829,6 +6829,7 @@ export type Database = {
|
||||
generate_ticket_number: { Args: never; Returns: string }
|
||||
get_auth0_sub_from_jwt: { Args: never; Returns: string }
|
||||
get_current_user_id: { Args: never; Returns: string }
|
||||
get_database_statistics: { Args: never; Returns: Json }
|
||||
get_email_change_status: { Args: never; Returns: Json }
|
||||
get_filtered_profile: {
|
||||
Args: { _profile_user_id: string; _viewer_id?: string }
|
||||
@@ -6853,6 +6854,21 @@ export type Database = {
|
||||
id: string
|
||||
}[]
|
||||
}
|
||||
get_recent_additions: {
|
||||
Args: { limit_count?: number }
|
||||
Returns: {
|
||||
created_at: string
|
||||
created_by_avatar: string
|
||||
created_by_id: string
|
||||
created_by_username: string
|
||||
entity_id: string
|
||||
entity_name: string
|
||||
entity_slug: string
|
||||
entity_type: string
|
||||
image_url: string
|
||||
park_slug: string
|
||||
}[]
|
||||
}
|
||||
get_recent_changes: {
|
||||
Args: { limit_count?: number }
|
||||
Returns: {
|
||||
|
||||
@@ -98,4 +98,10 @@ export const queryKeys = {
|
||||
anomalyDetections: () => ['monitoring', 'anomaly-detections'] as const,
|
||||
dataRetentionStats: () => ['monitoring', 'data-retention-stats'] as const,
|
||||
},
|
||||
|
||||
// Admin queries
|
||||
admin: {
|
||||
databaseStats: () => ['admin', 'database-stats'] as const,
|
||||
recentAdditions: (limit: number) => ['admin', 'recent-additions', limit] as const,
|
||||
},
|
||||
} as const;
|
||||
|
||||
161
src/pages/AdminDatabaseStats.tsx
Normal file
161
src/pages/AdminDatabaseStats.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import { Building2, Bike, Factory, Users, FileText, TrendingUp, Box, MapPin, Calendar, Image as ImageIcon } from 'lucide-react';
|
||||
import { AdminLayout } from '@/components/layout/AdminLayout';
|
||||
import { useAdminGuard } from '@/hooks/useAdminGuard';
|
||||
import { DatabaseStatsCard } from '@/components/admin/database-stats/DatabaseStatsCard';
|
||||
import { RecentAdditionsTable } from '@/components/admin/database-stats/RecentAdditionsTable';
|
||||
import { useAdminDatabaseStats } from '@/hooks/useAdminDatabaseStats';
|
||||
import { useRecentAdditions } from '@/hooks/useRecentAdditions';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
|
||||
export default function AdminDatabaseStats() {
|
||||
const { isLoading, isAuthorized, needsMFA } = useAdminGuard();
|
||||
const { data: stats, isLoading: statsLoading, error: statsError } = useAdminDatabaseStats();
|
||||
const { data: recentAdditions, isLoading: additionsLoading } = useRecentAdditions(50);
|
||||
|
||||
if (isLoading || statsLoading) {
|
||||
return (
|
||||
<AdminLayout>
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthorized || needsMFA) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (statsError) {
|
||||
return (
|
||||
<AdminLayout>
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
Failed to load database statistics. Please try again later.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const totalEntities = stats
|
||||
? stats.parks.total + stats.rides.total + stats.companies.total + stats.ride_models.total + stats.locations.total
|
||||
: 0;
|
||||
|
||||
const recentAdditions7d = stats
|
||||
? stats.parks.added_7d + stats.rides.added_7d + stats.companies.added_7d + stats.ride_models.added_7d + stats.photos.added_7d
|
||||
: 0;
|
||||
|
||||
const recentAdditions30d = stats
|
||||
? stats.parks.added_30d + stats.rides.added_30d + stats.companies.added_30d + stats.ride_models.added_30d + stats.photos.added_30d
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<AdminLayout>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Database Statistics</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Complete overview of database content and activity
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<DatabaseStatsCard
|
||||
title="Total Entities"
|
||||
icon={Box}
|
||||
iconClassName="text-blue-500"
|
||||
stats={[
|
||||
{ label: 'All Entities', value: totalEntities },
|
||||
{ label: 'Parks', value: stats?.parks.total || 0 },
|
||||
{ label: 'Rides', value: stats?.rides.total || 0 },
|
||||
{ label: 'Companies', value: stats?.companies.total || 0 },
|
||||
{ label: 'Ride Models', value: stats?.ride_models.total || 0 },
|
||||
]}
|
||||
/>
|
||||
|
||||
<DatabaseStatsCard
|
||||
title="Recent Activity"
|
||||
icon={TrendingUp}
|
||||
iconClassName="text-green-500"
|
||||
stats={[
|
||||
{
|
||||
label: 'Added (7 days)',
|
||||
value: recentAdditions7d,
|
||||
},
|
||||
{
|
||||
label: 'Added (30 days)',
|
||||
value: recentAdditions30d,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<DatabaseStatsCard
|
||||
title="Parks & Rides"
|
||||
icon={Building2}
|
||||
iconClassName="text-purple-500"
|
||||
stats={[
|
||||
{ label: 'Active Parks', value: stats?.parks.active || 0 },
|
||||
{ label: 'Historical Parks', value: stats?.parks.historical || 0 },
|
||||
{ label: 'Active Rides', value: stats?.rides.active || 0 },
|
||||
{ label: 'Historical Rides', value: stats?.rides.historical || 0 },
|
||||
]}
|
||||
/>
|
||||
|
||||
<DatabaseStatsCard
|
||||
title="Content"
|
||||
icon={ImageIcon}
|
||||
iconClassName="text-orange-500"
|
||||
stats={[
|
||||
{ label: 'Photos', value: stats?.photos.total || 0 },
|
||||
{ label: 'Locations', value: stats?.locations.total || 0 },
|
||||
{ label: 'Timeline Events', value: stats?.timeline_events.total || 0 },
|
||||
]}
|
||||
/>
|
||||
|
||||
<DatabaseStatsCard
|
||||
title="Companies"
|
||||
icon={Factory}
|
||||
iconClassName="text-amber-500"
|
||||
stats={[
|
||||
{ label: 'Total', value: stats?.companies.total || 0 },
|
||||
{ label: 'Manufacturers', value: stats?.companies.manufacturers || 0 },
|
||||
{ label: 'Operators', value: stats?.companies.operators || 0 },
|
||||
{ label: 'Designers', value: stats?.companies.designers || 0 },
|
||||
]}
|
||||
/>
|
||||
|
||||
<DatabaseStatsCard
|
||||
title="User Activity"
|
||||
icon={Users}
|
||||
iconClassName="text-teal-500"
|
||||
stats={[
|
||||
{ label: 'Total Users', value: stats?.users.total || 0 },
|
||||
{ label: 'Active (30 days)', value: stats?.users.active_30d || 0 },
|
||||
]}
|
||||
/>
|
||||
|
||||
<DatabaseStatsCard
|
||||
title="Submissions"
|
||||
icon={FileText}
|
||||
iconClassName="text-pink-500"
|
||||
stats={[
|
||||
{ label: 'Pending', value: stats?.submissions.pending || 0 },
|
||||
{ label: 'Approved', value: stats?.submissions.approved || 0 },
|
||||
{ label: 'Rejected', value: stats?.submissions.rejected || 0 },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Recent Additions Table */}
|
||||
<RecentAdditionsTable
|
||||
additions={recentAdditions || []}
|
||||
isLoading={additionsLoading}
|
||||
/>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
62
src/types/database-stats.ts
Normal file
62
src/types/database-stats.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
export interface DatabaseStatistics {
|
||||
parks: {
|
||||
total: number;
|
||||
active: number;
|
||||
historical: number;
|
||||
added_7d: number;
|
||||
added_30d: number;
|
||||
};
|
||||
rides: {
|
||||
total: number;
|
||||
active: number;
|
||||
historical: number;
|
||||
added_7d: number;
|
||||
added_30d: number;
|
||||
};
|
||||
companies: {
|
||||
total: number;
|
||||
manufacturers: number;
|
||||
operators: number;
|
||||
designers: number;
|
||||
added_7d: number;
|
||||
added_30d: number;
|
||||
};
|
||||
ride_models: {
|
||||
total: number;
|
||||
added_7d: number;
|
||||
added_30d: number;
|
||||
};
|
||||
locations: {
|
||||
total: number;
|
||||
};
|
||||
timeline_events: {
|
||||
total: number;
|
||||
};
|
||||
photos: {
|
||||
total: number;
|
||||
added_7d: number;
|
||||
added_30d: number;
|
||||
};
|
||||
users: {
|
||||
total: number;
|
||||
active_30d: number;
|
||||
};
|
||||
submissions: {
|
||||
pending: number;
|
||||
approved: number;
|
||||
rejected: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface RecentAddition {
|
||||
entity_id: string;
|
||||
entity_type: 'park' | 'ride' | 'company' | 'ride_model' | 'location' | 'timeline_event' | 'photo';
|
||||
entity_name: string;
|
||||
entity_slug: string | null;
|
||||
park_slug: string | null;
|
||||
image_url: string | null;
|
||||
created_at: string;
|
||||
created_by_id: string | null;
|
||||
created_by_username: string | null;
|
||||
created_by_avatar: string | null;
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
-- Function: Get comprehensive database statistics
|
||||
CREATE OR REPLACE FUNCTION public.get_database_statistics()
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
STABLE
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_stats jsonb;
|
||||
v_parks_total integer;
|
||||
v_parks_active integer;
|
||||
v_parks_historical integer;
|
||||
v_parks_7d integer;
|
||||
v_parks_30d integer;
|
||||
v_rides_total integer;
|
||||
v_rides_active integer;
|
||||
v_rides_historical integer;
|
||||
v_rides_7d integer;
|
||||
v_rides_30d integer;
|
||||
v_companies_total integer;
|
||||
v_manufacturers integer;
|
||||
v_operators integer;
|
||||
v_designers integer;
|
||||
v_companies_7d integer;
|
||||
v_companies_30d integer;
|
||||
v_ride_models_total integer;
|
||||
v_ride_models_7d integer;
|
||||
v_ride_models_30d integer;
|
||||
v_locations_total integer;
|
||||
v_timeline_events_total integer;
|
||||
v_photos_total integer;
|
||||
v_photos_7d integer;
|
||||
v_photos_30d integer;
|
||||
v_users_total integer;
|
||||
v_users_active_30d integer;
|
||||
v_submissions_pending integer;
|
||||
v_submissions_approved integer;
|
||||
v_submissions_rejected integer;
|
||||
BEGIN
|
||||
-- Parks statistics
|
||||
SELECT COUNT(*) INTO v_parks_total FROM parks;
|
||||
SELECT COUNT(*) INTO v_parks_active FROM parks WHERE status = 'operating';
|
||||
SELECT COUNT(*) INTO v_parks_historical FROM parks WHERE status IN ('closed', 'historical');
|
||||
SELECT COUNT(*) INTO v_parks_7d FROM parks WHERE created_at > NOW() - INTERVAL '7 days';
|
||||
SELECT COUNT(*) INTO v_parks_30d FROM parks WHERE created_at > NOW() - INTERVAL '30 days';
|
||||
|
||||
-- Rides statistics
|
||||
SELECT COUNT(*) INTO v_rides_total FROM rides;
|
||||
SELECT COUNT(*) INTO v_rides_active FROM rides WHERE status = 'operating';
|
||||
SELECT COUNT(*) INTO v_rides_historical FROM rides WHERE status IN ('closed', 'removed', 'relocated');
|
||||
SELECT COUNT(*) INTO v_rides_7d FROM rides WHERE created_at > NOW() - INTERVAL '7 days';
|
||||
SELECT COUNT(*) INTO v_rides_30d FROM rides WHERE created_at > NOW() - INTERVAL '30 days';
|
||||
|
||||
-- Companies statistics
|
||||
SELECT COUNT(*) INTO v_companies_total FROM companies;
|
||||
SELECT COUNT(*) INTO v_manufacturers FROM companies WHERE company_type = 'manufacturer';
|
||||
SELECT COUNT(*) INTO v_operators FROM companies WHERE company_type = 'operator';
|
||||
SELECT COUNT(*) INTO v_designers FROM companies WHERE company_type = 'designer';
|
||||
SELECT COUNT(*) INTO v_companies_7d FROM companies WHERE created_at > NOW() - INTERVAL '7 days';
|
||||
SELECT COUNT(*) INTO v_companies_30d FROM companies WHERE created_at > NOW() - INTERVAL '30 days';
|
||||
|
||||
-- Ride models statistics
|
||||
SELECT COUNT(*) INTO v_ride_models_total FROM ride_models;
|
||||
SELECT COUNT(*) INTO v_ride_models_7d FROM ride_models WHERE created_at > NOW() - INTERVAL '7 days';
|
||||
SELECT COUNT(*) INTO v_ride_models_30d FROM ride_models WHERE created_at > NOW() - INTERVAL '30 days';
|
||||
|
||||
-- Locations statistics
|
||||
SELECT COUNT(*) INTO v_locations_total FROM locations;
|
||||
|
||||
-- Timeline events statistics
|
||||
SELECT COUNT(*) INTO v_timeline_events_total FROM entity_timeline_events;
|
||||
|
||||
-- Photos statistics
|
||||
SELECT COUNT(*) INTO v_photos_total FROM entity_photos;
|
||||
SELECT COUNT(*) INTO v_photos_7d FROM entity_photos WHERE uploaded_at > NOW() - INTERVAL '7 days';
|
||||
SELECT COUNT(*) INTO v_photos_30d FROM entity_photos WHERE uploaded_at > NOW() - INTERVAL '30 days';
|
||||
|
||||
-- Users statistics
|
||||
SELECT COUNT(*) INTO v_users_total FROM profiles;
|
||||
SELECT COUNT(*) INTO v_users_active_30d FROM profiles WHERE updated_at > NOW() - INTERVAL '30 days';
|
||||
|
||||
-- Submissions statistics
|
||||
SELECT COUNT(*) INTO v_submissions_pending FROM content_submissions WHERE status = 'pending';
|
||||
SELECT COUNT(*) INTO v_submissions_approved FROM content_submissions WHERE status = 'approved';
|
||||
SELECT COUNT(*) INTO v_submissions_rejected FROM content_submissions WHERE status = 'rejected';
|
||||
|
||||
-- Build result JSON
|
||||
v_stats := jsonb_build_object(
|
||||
'parks', jsonb_build_object(
|
||||
'total', v_parks_total,
|
||||
'active', v_parks_active,
|
||||
'historical', v_parks_historical,
|
||||
'added_7d', v_parks_7d,
|
||||
'added_30d', v_parks_30d
|
||||
),
|
||||
'rides', jsonb_build_object(
|
||||
'total', v_rides_total,
|
||||
'active', v_rides_active,
|
||||
'historical', v_rides_historical,
|
||||
'added_7d', v_rides_7d,
|
||||
'added_30d', v_rides_30d
|
||||
),
|
||||
'companies', jsonb_build_object(
|
||||
'total', v_companies_total,
|
||||
'manufacturers', v_manufacturers,
|
||||
'operators', v_operators,
|
||||
'designers', v_designers,
|
||||
'added_7d', v_companies_7d,
|
||||
'added_30d', v_companies_30d
|
||||
),
|
||||
'ride_models', jsonb_build_object(
|
||||
'total', v_ride_models_total,
|
||||
'added_7d', v_ride_models_7d,
|
||||
'added_30d', v_ride_models_30d
|
||||
),
|
||||
'locations', jsonb_build_object(
|
||||
'total', v_locations_total
|
||||
),
|
||||
'timeline_events', jsonb_build_object(
|
||||
'total', v_timeline_events_total
|
||||
),
|
||||
'photos', jsonb_build_object(
|
||||
'total', v_photos_total,
|
||||
'added_7d', v_photos_7d,
|
||||
'added_30d', v_photos_30d
|
||||
),
|
||||
'users', jsonb_build_object(
|
||||
'total', v_users_total,
|
||||
'active_30d', v_users_active_30d
|
||||
),
|
||||
'submissions', jsonb_build_object(
|
||||
'pending', v_submissions_pending,
|
||||
'approved', v_submissions_approved,
|
||||
'rejected', v_submissions_rejected
|
||||
)
|
||||
);
|
||||
|
||||
RETURN v_stats;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Function: Get recent additions across all entity types
|
||||
CREATE OR REPLACE FUNCTION public.get_recent_additions(limit_count integer DEFAULT 50)
|
||||
RETURNS TABLE(
|
||||
entity_id uuid,
|
||||
entity_type text,
|
||||
entity_name text,
|
||||
entity_slug text,
|
||||
park_slug text,
|
||||
image_url text,
|
||||
created_at timestamptz,
|
||||
created_by_id uuid,
|
||||
created_by_username text,
|
||||
created_by_avatar text
|
||||
)
|
||||
LANGUAGE plpgsql
|
||||
STABLE
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
RETURN QUERY
|
||||
SELECT * FROM (
|
||||
-- Parks
|
||||
SELECT
|
||||
p.id as entity_id,
|
||||
'park'::text as entity_type,
|
||||
p.name as entity_name,
|
||||
p.slug as entity_slug,
|
||||
NULL::text as park_slug,
|
||||
p.card_image_url as image_url,
|
||||
p.created_at,
|
||||
p.created_by as created_by_id,
|
||||
prof.username as created_by_username,
|
||||
prof.avatar_url as created_by_avatar
|
||||
FROM parks p
|
||||
LEFT JOIN profiles prof ON prof.user_id = p.created_by
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- Rides
|
||||
SELECT
|
||||
r.id as entity_id,
|
||||
'ride'::text as entity_type,
|
||||
r.name as entity_name,
|
||||
r.slug as entity_slug,
|
||||
pk.slug as park_slug,
|
||||
r.card_image_url as image_url,
|
||||
r.created_at,
|
||||
r.created_by as created_by_id,
|
||||
prof.username as created_by_username,
|
||||
prof.avatar_url as created_by_avatar
|
||||
FROM rides r
|
||||
LEFT JOIN parks pk ON pk.id = r.park_id
|
||||
LEFT JOIN profiles prof ON prof.user_id = r.created_by
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- Companies
|
||||
SELECT
|
||||
c.id as entity_id,
|
||||
'company'::text as entity_type,
|
||||
c.name as entity_name,
|
||||
c.slug as entity_slug,
|
||||
NULL::text as park_slug,
|
||||
c.card_image_url as image_url,
|
||||
c.created_at,
|
||||
c.created_by as created_by_id,
|
||||
prof.username as created_by_username,
|
||||
prof.avatar_url as created_by_avatar
|
||||
FROM companies c
|
||||
LEFT JOIN profiles prof ON prof.user_id = c.created_by
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- Ride Models
|
||||
SELECT
|
||||
rm.id as entity_id,
|
||||
'ride_model'::text as entity_type,
|
||||
rm.name as entity_name,
|
||||
rm.slug as entity_slug,
|
||||
NULL::text as park_slug,
|
||||
rm.card_image_url as image_url,
|
||||
rm.created_at,
|
||||
rm.created_by as created_by_id,
|
||||
prof.username as created_by_username,
|
||||
prof.avatar_url as created_by_avatar
|
||||
FROM ride_models rm
|
||||
LEFT JOIN profiles prof ON prof.user_id = rm.created_by
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- Locations
|
||||
SELECT
|
||||
l.id as entity_id,
|
||||
'location'::text as entity_type,
|
||||
COALESCE(l.city || ', ' || l.country, l.country, 'Location') as entity_name,
|
||||
NULL::text as entity_slug,
|
||||
NULL::text as park_slug,
|
||||
NULL::text as image_url,
|
||||
l.created_at,
|
||||
NULL::uuid as created_by_id,
|
||||
NULL::text as created_by_username,
|
||||
NULL::text as created_by_avatar
|
||||
FROM locations l
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- Timeline Events
|
||||
SELECT
|
||||
te.id as entity_id,
|
||||
'timeline_event'::text as entity_type,
|
||||
te.event_title as entity_name,
|
||||
NULL::text as entity_slug,
|
||||
NULL::text as park_slug,
|
||||
NULL::text as image_url,
|
||||
te.created_at,
|
||||
te.created_by as created_by_id,
|
||||
prof.username as created_by_username,
|
||||
prof.avatar_url as created_by_avatar
|
||||
FROM entity_timeline_events te
|
||||
LEFT JOIN profiles prof ON prof.user_id = te.created_by
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- Photos
|
||||
SELECT
|
||||
ep.id as entity_id,
|
||||
'photo'::text as entity_type,
|
||||
COALESCE(ep.title, 'Photo') as entity_name,
|
||||
NULL::text as entity_slug,
|
||||
NULL::text as park_slug,
|
||||
ep.cloudflare_url as image_url,
|
||||
ep.uploaded_at as created_at,
|
||||
ep.uploaded_by as created_by_id,
|
||||
prof.username as created_by_username,
|
||||
prof.avatar_url as created_by_avatar
|
||||
FROM entity_photos ep
|
||||
LEFT JOIN profiles prof ON prof.user_id = ep.uploaded_by
|
||||
) combined
|
||||
ORDER BY created_at DESC
|
||||
LIMIT limit_count;
|
||||
END;
|
||||
$$;
|
||||
Reference in New Issue
Block a user