Ctrl K
ComunidadGuías…

src/components/courses/courses-filters.tsx

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"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<T extends string>(
  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 = <K extends keyof CourseListFilters>(
    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.
    <div className="mb-4 grid grid-cols-1 items-end gap-3 sm:grid-cols-2 lg:grid-cols-4">
      <div className="space-y-2">
        <Label htmlFor="course-date-from">From</Label>
        <DatePicker
          id="course-date-from"
          value={filters.dateFrom}
          onChange={(dateFrom) =>
            onFiltersChange({
              ...filters,
              dateFrom,
              dateTo: rangeEndAfter(dateFrom, filters.dateTo),
            })
          }
          rangeEnd={filters.dateTo}
        />
      </div>

      <div className="space-y-2">
        <Label htmlFor="course-date-to">To</Label>
        <DatePicker
          id="course-date-to"
          value={filters.dateTo}
          onChange={(value) => set("dateTo", value)}
          rangeStart={filters.dateFrom}
        />
      </div>

      <div className="space-y-2">
        <Label htmlFor="course-agency">Agency</Label>
        <NativeSelect
          id="course-agency"
          value={filters.agency}
          onChange={(event) =>
            set("agency", event.target.value as CertificationAgency | "")
          }
        >
          <option value="">Any agency</option>
          {withPicked(agencies, filters.agency).map((agency) => (
            <option key={agency} value={agency}>
              {certificationAgencyLabel(agency)}
            </option>
          ))}
        </NativeSelect>
      </div>

      <div className="space-y-2">
        <Label htmlFor="course-status">Status</Label>
        <NativeSelect
          id="course-status"
          value={filters.status}
          onChange={(event) =>
            set("status", event.target.value as CourseStatus | "")
          }
        >
          <option value="">Any status</option>
          {withPicked(statuses, filters.status).map((status) => (
            <option key={status} value={status}>
              {courseStatusLabel(status)}
            </option>
          ))}
        </NativeSelect>
      </div>
    </div>
  );
}