- 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
232 lines
11 KiB
TypeScript
232 lines
11 KiB
TypeScript
'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>
|
|
);
|
|
}
|