mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-24 15:51:13 -05:00
feat: Implement comprehensive entity filtering
This commit is contained in:
@@ -1,15 +1,18 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { RotateCcw } from 'lucide-react';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { Park } from '@/types/database';
|
||||
import { FilterState } from '@/pages/Parks';
|
||||
import { MonthYearPicker } from '@/components/ui/month-year-picker';
|
||||
import { FilterRangeSlider } from '@/components/filters/FilterRangeSlider';
|
||||
import { FilterDateRangePicker } from '@/components/filters/FilterDateRangePicker';
|
||||
import { FilterSection } from '@/components/filters/FilterSection';
|
||||
import { FilterMultiSelectCombobox } from '@/components/filters/FilterMultiSelectCombobox';
|
||||
import { MultiSelectOption } from '@/components/ui/multi-select-combobox';
|
||||
|
||||
interface ParkFiltersProps {
|
||||
filters: FilterState;
|
||||
@@ -18,15 +21,66 @@ interface ParkFiltersProps {
|
||||
}
|
||||
|
||||
export function ParkFilters({ filters, onFiltersChange, parks }: ParkFiltersProps) {
|
||||
const countries = useMemo(() => {
|
||||
const countrySet = new Set<string>();
|
||||
parks.forEach(park => {
|
||||
if (park.location?.country) {
|
||||
countrySet.add(park.location.country);
|
||||
}
|
||||
});
|
||||
return Array.from(countrySet).sort();
|
||||
}, [parks]);
|
||||
const { data: locations } = useQuery({
|
||||
queryKey: ['filter-locations'],
|
||||
queryFn: async () => {
|
||||
const { data } = await supabase
|
||||
.from('locations')
|
||||
.select('country, state_province, city')
|
||||
.not('country', 'is', null);
|
||||
return data || [];
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
const { data: operators } = useQuery({
|
||||
queryKey: ['filter-operators'],
|
||||
queryFn: async () => {
|
||||
const { data } = await supabase
|
||||
.from('companies')
|
||||
.select('id, name')
|
||||
.eq('company_type', 'operator')
|
||||
.order('name');
|
||||
return data || [];
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
const { data: propertyOwners } = useQuery({
|
||||
queryKey: ['filter-property-owners'],
|
||||
queryFn: async () => {
|
||||
const { data } = await supabase
|
||||
.from('companies')
|
||||
.select('id, name')
|
||||
.eq('company_type', 'property_owner')
|
||||
.order('name');
|
||||
return data || [];
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
const countryOptions: MultiSelectOption[] = useMemo(() => {
|
||||
const countries = new Set(locations?.map(l => l.country).filter(Boolean) || []);
|
||||
return Array.from(countries).sort().map(c => ({ label: c, value: c }));
|
||||
}, [locations]);
|
||||
|
||||
const stateOptions: MultiSelectOption[] = useMemo(() => {
|
||||
const states = new Set(locations?.map(l => l.state_province).filter(Boolean) || []);
|
||||
return Array.from(states).sort().map(s => ({ label: s, value: s }));
|
||||
}, [locations]);
|
||||
|
||||
const cityOptions: MultiSelectOption[] = useMemo(() => {
|
||||
const cities = new Set(locations?.map(l => l.city).filter(Boolean) || []);
|
||||
return Array.from(cities).sort().map(c => ({ label: c, value: c }));
|
||||
}, [locations]);
|
||||
|
||||
const operatorOptions: MultiSelectOption[] = useMemo(() => {
|
||||
return (operators || []).map(o => ({ label: o.name, value: o.id }));
|
||||
}, [operators]);
|
||||
|
||||
const propertyOwnerOptions: MultiSelectOption[] = useMemo(() => {
|
||||
return (propertyOwners || []).map(p => ({ label: p.name, value: p.id }));
|
||||
}, [propertyOwners]);
|
||||
|
||||
const parkTypes = [
|
||||
{ value: 'all', label: 'All Types' },
|
||||
@@ -48,8 +102,13 @@ export function ParkFilters({ filters, onFiltersChange, parks }: ParkFiltersProp
|
||||
return Math.max(...parks.map(p => p.ride_count || 0), 100);
|
||||
}, [parks]);
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
const minYear = 1900;
|
||||
const maxCoasters = useMemo(() => {
|
||||
return Math.max(...parks.map(p => p.coaster_count || 0), 50);
|
||||
}, [parks]);
|
||||
|
||||
const maxReviews = useMemo(() => {
|
||||
return Math.max(...parks.map(p => p.review_count || 0), 1000);
|
||||
}, [parks]);
|
||||
|
||||
const resetFilters = () => {
|
||||
onFiltersChange({
|
||||
@@ -57,10 +116,18 @@ export function ParkFilters({ filters, onFiltersChange, parks }: ParkFiltersProp
|
||||
parkType: 'all',
|
||||
status: 'all',
|
||||
country: 'all',
|
||||
states: [],
|
||||
cities: [],
|
||||
operators: [],
|
||||
propertyOwners: [],
|
||||
minRating: 0,
|
||||
maxRating: 5,
|
||||
minRides: 0,
|
||||
maxRides: maxRides,
|
||||
minCoasters: 0,
|
||||
maxCoasters: maxCoasters,
|
||||
minReviews: 0,
|
||||
maxReviews: maxReviews,
|
||||
openingYearStart: null,
|
||||
openingYearEnd: null,
|
||||
});
|
||||
@@ -76,155 +143,169 @@ export function ParkFilters({ filters, onFiltersChange, parks }: ParkFiltersProp
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
{/* Park Type */}
|
||||
<div className="space-y-2">
|
||||
<Label>Park Type</Label>
|
||||
<Select
|
||||
value={filters.parkType}
|
||||
onValueChange={(value) => onFiltersChange({ ...filters, parkType: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{parkTypes.map(type => (
|
||||
<SelectItem key={type.value} value={type.value}>
|
||||
{type.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div className="space-y-2">
|
||||
<Label>Status</Label>
|
||||
<Select
|
||||
value={filters.status}
|
||||
onValueChange={(value) => onFiltersChange({ ...filters, status: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{statusOptions.map(status => (
|
||||
<SelectItem key={status.value} value={status.value}>
|
||||
{status.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Country */}
|
||||
<div className="space-y-2">
|
||||
<Label>Country</Label>
|
||||
<Select
|
||||
value={filters.country}
|
||||
onValueChange={(value) => onFiltersChange({ ...filters, country: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Countries</SelectItem>
|
||||
{countries.map(country => (
|
||||
<SelectItem key={country} value={country}>
|
||||
{country}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Opening Year Range */}
|
||||
<div className="space-y-2">
|
||||
<Label>Opening Year</Label>
|
||||
<div className="flex gap-2">
|
||||
<MonthYearPicker
|
||||
date={filters.openingYearStart ? new Date(filters.openingYearStart, 0, 1) : undefined}
|
||||
onSelect={(date) => onFiltersChange({
|
||||
...filters,
|
||||
openingYearStart: date ? date.getFullYear() : null
|
||||
})}
|
||||
placeholder="From year"
|
||||
fromYear={minYear}
|
||||
toYear={currentYear}
|
||||
/>
|
||||
<MonthYearPicker
|
||||
date={filters.openingYearEnd ? new Date(filters.openingYearEnd, 0, 1) : undefined}
|
||||
onSelect={(date) => onFiltersChange({
|
||||
...filters,
|
||||
openingYearEnd: date ? date.getFullYear() : null
|
||||
})}
|
||||
placeholder="To year"
|
||||
fromYear={minYear}
|
||||
toYear={currentYear}
|
||||
/>
|
||||
<FilterSection>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-4">
|
||||
{/* Park Type */}
|
||||
<div className="space-y-2">
|
||||
<Label>Park Type</Label>
|
||||
<Select
|
||||
value={filters.parkType}
|
||||
onValueChange={(value) => onFiltersChange({ ...filters, parkType: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{parkTypes.map(type => (
|
||||
<SelectItem key={type.value} value={type.value}>
|
||||
{type.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div className="space-y-2">
|
||||
<Label>Status</Label>
|
||||
<Select
|
||||
value={filters.status}
|
||||
onValueChange={(value) => onFiltersChange({ ...filters, status: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{statusOptions.map(status => (
|
||||
<SelectItem key={status.value} value={status.value}>
|
||||
{status.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Country */}
|
||||
<div className="space-y-2">
|
||||
<Label>Country</Label>
|
||||
<Select
|
||||
value={filters.country}
|
||||
onValueChange={(value) => onFiltersChange({ ...filters, country: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Countries</SelectItem>
|
||||
{Array.from(new Set(locations?.map(l => l.country).filter(Boolean) || [])).sort().map(country => (
|
||||
<SelectItem key={country} value={country}>
|
||||
{country}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<FilterMultiSelectCombobox
|
||||
label="States/Provinces"
|
||||
options={stateOptions}
|
||||
value={filters.states || []}
|
||||
onChange={(value) => onFiltersChange({ ...filters, states: value })}
|
||||
placeholder="Select states"
|
||||
/>
|
||||
|
||||
<FilterMultiSelectCombobox
|
||||
label="Cities"
|
||||
options={cityOptions}
|
||||
value={filters.cities || []}
|
||||
onChange={(value) => onFiltersChange({ ...filters, cities: value })}
|
||||
placeholder="Select cities"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</FilterSection>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Rating Range */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Rating Range</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline">
|
||||
{filters.minRating.toFixed(1)} - {filters.maxRating.toFixed(1)} stars
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-2">
|
||||
<Slider
|
||||
value={[filters.minRating, filters.maxRating]}
|
||||
onValueChange={([min, max]) =>
|
||||
onFiltersChange({ ...filters, minRating: min, maxRating: max })
|
||||
}
|
||||
min={0}
|
||||
max={5}
|
||||
step={0.1}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-muted-foreground mt-1">
|
||||
<span>0 stars</span>
|
||||
<span>5 stars</span>
|
||||
</div>
|
||||
</div>
|
||||
<FilterSection title="Operators & Owners">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<FilterMultiSelectCombobox
|
||||
label="Operators"
|
||||
options={operatorOptions}
|
||||
value={filters.operators || []}
|
||||
onChange={(value) => onFiltersChange({ ...filters, operators: value })}
|
||||
placeholder="Select operators"
|
||||
/>
|
||||
<FilterMultiSelectCombobox
|
||||
label="Property Owners"
|
||||
options={propertyOwnerOptions}
|
||||
value={filters.propertyOwners || []}
|
||||
onChange={(value) => onFiltersChange({ ...filters, propertyOwners: value })}
|
||||
placeholder="Select owners"
|
||||
/>
|
||||
</div>
|
||||
</FilterSection>
|
||||
|
||||
{/* Ride Count Range */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Number of Rides</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline">
|
||||
{filters.minRides} - {filters.maxRides} rides
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-2">
|
||||
<Slider
|
||||
value={[filters.minRides, filters.maxRides]}
|
||||
onValueChange={([min, max]) =>
|
||||
onFiltersChange({ ...filters, minRides: min, maxRides: max })
|
||||
}
|
||||
min={0}
|
||||
max={maxRides}
|
||||
step={1}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-muted-foreground mt-1">
|
||||
<span>0 rides</span>
|
||||
<span>{maxRides} rides</span>
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
|
||||
<FilterSection title="Opening Dates">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<FilterDateRangePicker
|
||||
label="Opening Year"
|
||||
fromDate={filters.openingYearStart ? new Date(filters.openingYearStart, 0, 1) : null}
|
||||
toDate={filters.openingYearEnd ? new Date(filters.openingYearEnd, 0, 1) : null}
|
||||
onFromChange={(date) => onFiltersChange({
|
||||
...filters,
|
||||
openingYearStart: date ? date.getFullYear() : null
|
||||
})}
|
||||
onToChange={(date) => onFiltersChange({
|
||||
...filters,
|
||||
openingYearEnd: date ? date.getFullYear() : null
|
||||
})}
|
||||
fromPlaceholder="From year"
|
||||
toPlaceholder="To year"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</FilterSection>
|
||||
|
||||
<Separator />
|
||||
|
||||
<FilterSection title="Statistics">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<FilterRangeSlider
|
||||
label="Rating"
|
||||
value={[filters.minRating, filters.maxRating]}
|
||||
onChange={([min, max]) => onFiltersChange({ ...filters, minRating: min, maxRating: max })}
|
||||
min={0}
|
||||
max={5}
|
||||
step={0.1}
|
||||
formatValue={(v) => `${v.toFixed(1)} stars`}
|
||||
/>
|
||||
<FilterRangeSlider
|
||||
label="Ride Count"
|
||||
value={[filters.minRides, filters.maxRides]}
|
||||
onChange={([min, max]) => onFiltersChange({ ...filters, minRides: min, maxRides: max })}
|
||||
min={0}
|
||||
max={maxRides}
|
||||
step={1}
|
||||
/>
|
||||
<FilterRangeSlider
|
||||
label="Coaster Count"
|
||||
value={[filters.minCoasters || 0, filters.maxCoasters || maxCoasters]}
|
||||
onChange={([min, max]) => onFiltersChange({ ...filters, minCoasters: min, maxCoasters: max })}
|
||||
min={0}
|
||||
max={maxCoasters}
|
||||
step={1}
|
||||
/>
|
||||
<FilterRangeSlider
|
||||
label="Review Count"
|
||||
value={[filters.minReviews || 0, filters.maxReviews || maxReviews]}
|
||||
onChange={([min, max]) => onFiltersChange({ ...filters, minReviews: min, maxReviews: max })}
|
||||
min={0}
|
||||
max={maxReviews}
|
||||
step={10}
|
||||
/>
|
||||
</div>
|
||||
</FilterSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user