"use client"; import { CERTIFICATION_AGENCIES, certificationAgencyLabel, type CertificationAgency, } from "@/lib/api/certifications"; import { COURSE_STATUSES, type CourseStatus } from "@/lib/api/courses"; import { courseStatusLabel } from "@/lib/course"; import { DatePicker, rangeEndAfter } from "@/components/ui/date-picker"; import { Label } from "@/components/ui/label"; import { NativeSelect } from "@/components/ui/native-select"; /** * What narrows the course list, as the controls hold it. `""` means "not * filtering on this" throughout - the state the row opens in and the one every * control has to be able to return to, and what the page drops rather than * sending to the API. */ export interface CourseListFilters { /** * Bounds of a window the course's dates must overlap. The semantics, and what * a dateless course does under them, are `CourseFilters`' in `lib/api/courses`. */ dateFrom: string; dateTo: string; agency: CertificationAgency | ""; status: CourseStatus | ""; } export const NO_COURSE_FILTERS: CourseListFilters = { dateFrom: "", dateTo: "", agency: "", status: "", }; /** Whether anything beyond the name search is narrowing the list. */ export function hasCourseFilters(filters: CourseListFilters): boolean { return Object.values(filters).some(Boolean); } export interface CoursesFiltersProps { filters: CourseListFilters; onFiltersChange: (filters: CourseListFilters) => void; /** * The agencies and statuses to offer, which are the ones the diver's own * courses carry rather than the whole vocabulary - `useCourseFilterOptions` * derives them. Default to everything, so the row is complete for a caller * that has not read them yet. */ agencies?: readonly CertificationAgency[]; statuses?: readonly CourseStatus[]; } // Keeps whatever is picked on the list even once it is no longer in use - the // last course with that agency deleted while the filter is set, say. A select // whose value is not among its options shows blank, which reads as a filter that // has quietly forgotten itself while the list stays narrowed by it. function withPicked( options: readonly T[], picked: T | "", ): readonly T[] { if (!picked) return options; return options.includes(picked as T) ? options : [...options, picked as T]; } // The four filters above the course list. They narrow the same query as the // header's search rather than competing: the API AND-s them, so a name and a // status answer the courses matching both. export function CoursesFilters({ filters, onFiltersChange, agencies = CERTIFICATION_AGENCIES, statuses = COURSE_STATUSES, }: CoursesFiltersProps) { const set = ( key: K, value: CourseListFilters[K], ) => onFiltersChange({ ...filters, [key]: value }); return ( // A row of four on a desktop, two pairs below `lg`, one per line on a phone.
onFiltersChange({ ...filters, dateFrom, dateTo: rangeEndAfter(dateFrom, filters.dateTo), }) } rangeEnd={filters.dateTo} />
set("dateTo", value)} rangeStart={filters.dateFrom} />
set("agency", event.target.value as CertificationAgency | "") } > {withPicked(agencies, filters.agency).map((agency) => ( ))}
set("status", event.target.value as CourseStatus | "") } > {withPicked(statuses, filters.status).map((status) => ( ))}
); }