feat: Add admin pages for splits and tastings

- Create /admin/splits page showing all bottle splits
  - Stats: total splits, active, hosts, participants, volume
  - Filter by host, status (active/closed)
  - Progress bars showing reservation status

- Create /admin/tastings page showing all tasting notes
  - Stats: total tastings, users, avg rating, with notes, today
  - Filter by user, rating
  - Notes preview with star ratings

- Add navigation links to admin dashboard
This commit is contained in:
2026-01-19 11:37:00 +01:00
parent ef64c89e9b
commit f9192f2228
5 changed files with 749 additions and 0 deletions

View File

@@ -0,0 +1,231 @@
'use client';
import { useState, useMemo } from 'react';
import { Search, User, Star, Wine, MessageSquare } from 'lucide-react';
import Image from 'next/image';
interface Tasting {
id: string;
bottle_id: string;
user_id: string;
rating: number;
nose: string | null;
palate: string | null;
finish: string | null;
notes: string | null;
created_at: string;
user: { username: string; display_name: string | null };
bottle: { id: string; name: string; distillery: string | null; image_url: string | null } | null;
}
interface AdminTastingsListProps {
tastings: Tasting[];
}
export default function AdminTastingsList({ tastings }: AdminTastingsListProps) {
const [search, setSearch] = useState('');
const [filterUser, setFilterUser] = useState<string | null>(null);
const [filterRating, setFilterRating] = useState<number | null>(null);
// Get unique users for filter
const users = useMemo(() => {
const userMap = new Map<string, string>();
tastings.forEach(t => {
userMap.set(t.user_id, t.user.display_name || t.user.username);
});
return Array.from(userMap.entries()).sort((a, b) => a[1].localeCompare(b[1]));
}, [tastings]);
// Filter tastings
const filteredTastings = useMemo(() => {
let result = tastings;
if (search) {
const searchLower = search.toLowerCase();
result = result.filter(t =>
t.bottle?.name?.toLowerCase().includes(searchLower) ||
t.bottle?.distillery?.toLowerCase().includes(searchLower) ||
t.user.username.toLowerCase().includes(searchLower) ||
t.notes?.toLowerCase().includes(searchLower) ||
t.nose?.toLowerCase().includes(searchLower) ||
t.palate?.toLowerCase().includes(searchLower) ||
t.finish?.toLowerCase().includes(searchLower)
);
}
if (filterUser) {
result = result.filter(t => t.user_id === filterUser);
}
if (filterRating !== null) {
result = result.filter(t => Math.floor(t.rating) === filterRating);
}
return result;
}, [tastings, search, filterUser, filterRating]);
const renderStars = (rating: number) => {
return (
<div className="flex items-center gap-0.5">
{[1, 2, 3, 4, 5].map(star => (
<Star
key={star}
size={14}
className={star <= rating ? 'text-orange-500 fill-orange-500' : 'text-zinc-700'}
/>
))}
</div>
);
};
return (
<div className="space-y-4">
{/* Filters */}
<div className="flex flex-col lg:flex-row gap-4">
<div className="flex-1 relative">
<Search size={18} className="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-500" />
<input
type="text"
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Search bottles, users, or notes..."
className="w-full pl-12 pr-4 py-3 bg-zinc-900 border border-zinc-800 rounded-xl text-white placeholder-zinc-600 focus:outline-none focus:border-orange-600"
/>
</div>
<div className="flex gap-2">
<select
value={filterUser || ''}
onChange={e => setFilterUser(e.target.value || null)}
className="px-4 py-3 bg-zinc-900 border border-zinc-800 rounded-xl text-zinc-300 focus:outline-none"
>
<option value="">All Users</option>
{users.map(([id, name]) => (
<option key={id} value={id}>{name}</option>
))}
</select>
<select
value={filterRating ?? ''}
onChange={e => setFilterRating(e.target.value ? parseInt(e.target.value) : null)}
className="px-4 py-3 bg-zinc-900 border border-zinc-800 rounded-xl text-zinc-300 focus:outline-none"
>
<option value="">All Ratings</option>
<option value="5">5 Stars</option>
<option value="4">4 Stars</option>
<option value="3">3 Stars</option>
<option value="2">2 Stars</option>
<option value="1">1 Star</option>
</select>
</div>
</div>
{/* Results */}
<div className="text-sm text-zinc-500">
Showing {filteredTastings.length} of {tastings.length} tastings
</div>
{/* Tastings List */}
<div className="space-y-3">
{filteredTastings.map(tasting => {
const hasNotes = tasting.notes || tasting.nose || tasting.palate || tasting.finish;
return (
<div
key={tasting.id}
className="bg-zinc-900 rounded-2xl border border-zinc-800 p-4 hover:border-zinc-700 transition-colors"
>
<div className="flex gap-4">
{/* Bottle Image */}
<div className="w-16 h-16 rounded-xl overflow-hidden bg-zinc-800 flex-shrink-0">
{tasting.bottle?.image_url ? (
<Image
src={tasting.bottle.image_url}
alt={tasting.bottle.name || 'Bottle'}
width={64}
height={64}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<Wine size={24} className="text-zinc-700" />
</div>
)}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2 mb-1">
<div>
<h3 className="font-bold text-white truncate">
{tasting.bottle?.name || 'Unknown Bottle'}
</h3>
<p className="text-sm text-zinc-500 truncate">
{tasting.bottle?.distillery || 'Unknown Distillery'}
</p>
</div>
<div className="flex-shrink-0">
{tasting.rating > 0 ? renderStars(tasting.rating) : (
<span className="text-xs text-zinc-600">No rating</span>
)}
</div>
</div>
{/* Notes Preview */}
{hasNotes && (
<div className="mt-2 space-y-1">
{tasting.nose && (
<p className="text-xs text-zinc-400">
<span className="text-zinc-600">Nose:</span> {tasting.nose.slice(0, 80)}...
</p>
)}
{tasting.notes && (
<p className="text-xs text-zinc-400 line-clamp-2">
{tasting.notes.slice(0, 150)}...
</p>
)}
</div>
)}
{/* Meta */}
<div className="flex items-center gap-4 mt-2 text-xs text-zinc-600">
<span className="flex items-center gap-1">
<User size={12} />
{tasting.user.display_name || tasting.user.username}
</span>
<span>
{new Date(tasting.created_at).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</span>
{hasNotes && (
<span className="flex items-center gap-1 text-green-500">
<MessageSquare size={12} />
Has notes
</span>
)}
</div>
</div>
</div>
</div>
);
})}
</div>
{/* Empty State */}
{filteredTastings.length === 0 && (
<div className="text-center py-12">
<div className="w-16 h-16 mx-auto rounded-2xl bg-zinc-900 flex items-center justify-center mb-4">
<Star size={32} className="text-zinc-600" />
</div>
<p className="text-lg font-bold text-white mb-2">No Tastings Found</p>
<p className="text-sm text-zinc-500">No tastings match your filters.</p>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,142 @@
export const dynamic = 'force-dynamic';
import { createClient } from '@/lib/supabase/server';
import { redirect } from 'next/navigation';
import { checkIsAdmin } from '@/services/track-api-usage';
import Link from 'next/link';
import { ArrowLeft, Wine, User, Calendar, Star, MessageSquare, Sparkles } from 'lucide-react';
import AdminTastingsList from './AdminTastingsList';
export default async function AdminTastingsPage() {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
redirect('/');
}
const isAdmin = await checkIsAdmin(user.id);
if (!isAdmin) {
redirect('/');
}
// Fetch all tastings from all users
const { data: tastingsRaw, error } = await supabase
.from('tastings')
.select(`
id,
bottle_id,
user_id,
rating,
nose,
palate,
finish,
notes,
created_at,
bottles (id, name, distillery, image_url)
`)
.order('created_at', { ascending: false })
.limit(500);
// Get unique user IDs
const userIds = Array.from(new Set(tastingsRaw?.map(t => t.user_id) || []));
// Fetch profiles for users
const { data: profiles } = userIds.length > 0
? await supabase.from('profiles').select('id, username, display_name').in('id', userIds)
: { data: [] };
// Combine tastings with user info
const tastings = tastingsRaw?.map(tasting => ({
...tasting,
user: profiles?.find(p => p.id === tasting.user_id) || { username: 'Unknown', display_name: null },
bottle: tasting.bottles as any,
})) || [];
// Calculate stats
const stats = {
totalTastings: tastings.length,
totalUsers: userIds.length,
avgRating: tastings.length > 0
? tastings.reduce((sum, t) => sum + (t.rating || 0), 0) / tastings.filter(t => t.rating > 0).length
: 0,
withNotes: tastings.filter(t => t.notes || t.nose || t.palate || t.finish).length,
todayCount: tastings.filter(t => {
const today = new Date();
const tastingDate = new Date(t.created_at);
return tastingDate.toDateString() === today.toDateString();
}).length,
};
return (
<main className="min-h-screen bg-zinc-950 p-6 pb-24">
<div className="max-w-7xl mx-auto">
{/* Header */}
<div className="flex items-center gap-4 mb-8">
<Link
href="/admin"
className="p-2 rounded-xl bg-zinc-900 border border-zinc-800 text-zinc-400 hover:text-white hover:border-zinc-700 transition-colors"
>
<ArrowLeft size={20} />
</Link>
<div className="flex-1">
<h1 className="text-2xl font-bold text-white">All Tastings</h1>
<p className="text-sm text-zinc-500">
View all tasting notes from all users
</p>
</div>
</div>
{error && (
<div className="mb-6 p-4 bg-red-500/20 border border-red-500/50 rounded-xl text-red-400 text-sm">
Error loading tastings: {error.message}
</div>
)}
{/* Stats Cards */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-8">
<div className="bg-zinc-900 rounded-2xl p-4 border border-zinc-800">
<div className="flex items-center gap-2 mb-2">
<Sparkles size={18} className="text-purple-500" />
<span className="text-xs font-bold text-zinc-500 uppercase">Total Tastings</span>
</div>
<div className="text-2xl font-black text-white">{stats.totalTastings}</div>
</div>
<div className="bg-zinc-900 rounded-2xl p-4 border border-zinc-800">
<div className="flex items-center gap-2 mb-2">
<User size={18} className="text-blue-500" />
<span className="text-xs font-bold text-zinc-500 uppercase">Users</span>
</div>
<div className="text-2xl font-black text-white">{stats.totalUsers}</div>
</div>
<div className="bg-zinc-900 rounded-2xl p-4 border border-zinc-800">
<div className="flex items-center gap-2 mb-2">
<Star size={18} className="text-yellow-500" />
<span className="text-xs font-bold text-zinc-500 uppercase">Avg Rating</span>
</div>
<div className="text-2xl font-black text-white">
{stats.avgRating > 0 ? stats.avgRating.toFixed(1) : 'N/A'}
</div>
</div>
<div className="bg-zinc-900 rounded-2xl p-4 border border-zinc-800">
<div className="flex items-center gap-2 mb-2">
<MessageSquare size={18} className="text-green-500" />
<span className="text-xs font-bold text-zinc-500 uppercase">With Notes</span>
</div>
<div className="text-2xl font-black text-white">{stats.withNotes}</div>
</div>
<div className="bg-zinc-900 rounded-2xl p-4 border border-zinc-800">
<div className="flex items-center gap-2 mb-2">
<Calendar size={18} className="text-orange-500" />
<span className="text-xs font-bold text-zinc-500 uppercase">Today</span>
</div>
<div className="text-2xl font-black text-white">{stats.todayCount}</div>
</div>
</div>
{/* Tastings List */}
<AdminTastingsList tastings={tastings} />
</div>
</main>
);
}