202 lines
6.3 KiB
TypeScript
202 lines
6.3 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useMemo, useState } from "react";
|
||
import { useRouter } from "next/navigation";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Input } from "@/components/ui/input";
|
||
import {
|
||
Select,
|
||
SelectContent,
|
||
SelectGroup,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
} from "@/components/ui/select";
|
||
import { Calendar } from "@/components/ui/calendar";
|
||
import {
|
||
Popover,
|
||
PopoverContent,
|
||
PopoverTrigger,
|
||
} from "@/components/ui/popover";
|
||
import type { ResultFilters } from "@/lib/admin-form-results";
|
||
import { Download, X, CalendarDays } from "lucide-react";
|
||
import { format, parseISO } from "date-fns";
|
||
|
||
function DateFilterPicker({
|
||
label,
|
||
value,
|
||
onChange,
|
||
}: {
|
||
label: string;
|
||
value: string;
|
||
onChange: (value: string) => void;
|
||
}) {
|
||
const selected = value ? parseISO(value) : undefined;
|
||
|
||
return (
|
||
<Popover>
|
||
<PopoverTrigger asChild>
|
||
<Button
|
||
variant="outline"
|
||
data-empty={!selected}
|
||
className="w-full justify-start text-left font-normal data-[empty=true]:text-muted-foreground lg:w-44"
|
||
>
|
||
<CalendarDays data-icon="inline-start" />
|
||
{selected ? format(selected, "PP") : label}
|
||
</Button>
|
||
</PopoverTrigger>
|
||
<PopoverContent className="w-auto p-0" align="start">
|
||
<Calendar
|
||
mode="single"
|
||
selected={selected}
|
||
onSelect={(date) => onChange(date ? format(date, "yyyy-MM-dd") : "")}
|
||
/>
|
||
{selected && (
|
||
<div className="border-t p-2">
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="sm"
|
||
className="w-full"
|
||
onClick={() => onChange("")}
|
||
>
|
||
Clear date
|
||
</Button>
|
||
</div>
|
||
)}
|
||
</PopoverContent>
|
||
</Popover>
|
||
);
|
||
}
|
||
|
||
export function ResultsFilters({
|
||
formId,
|
||
filters,
|
||
}: {
|
||
formId: string;
|
||
filters: ResultFilters;
|
||
}) {
|
||
const router = useRouter();
|
||
const [values, setValues] = useState(filters);
|
||
const base = `/admin/form/${formId}/result`;
|
||
|
||
const filterQuery = useMemo(() => {
|
||
const params = new URLSearchParams();
|
||
if (values.q) params.set("q", values.q);
|
||
if (values.sort !== "submittedAt") params.set("sort", values.sort);
|
||
if (values.direction !== "desc") params.set("direction", values.direction);
|
||
if (values.edited !== "all") params.set("edited", values.edited);
|
||
if (values.dateFrom) params.set("dateFrom", values.dateFrom);
|
||
if (values.dateTo) params.set("dateTo", values.dateTo);
|
||
return params.toString();
|
||
}, [values]);
|
||
|
||
const appliedQuery = useMemo(() => {
|
||
const params = new URLSearchParams();
|
||
if (filters.q) params.set("q", filters.q);
|
||
if (filters.sort !== "submittedAt") params.set("sort", filters.sort);
|
||
if (filters.direction !== "desc") params.set("direction", filters.direction);
|
||
if (filters.edited !== "all") params.set("edited", filters.edited);
|
||
if (filters.dateFrom) params.set("dateFrom", filters.dateFrom);
|
||
if (filters.dateTo) params.set("dateTo", filters.dateTo);
|
||
return params.toString();
|
||
}, [filters]);
|
||
|
||
useEffect(() => {
|
||
if (filterQuery === appliedQuery) return;
|
||
|
||
const timeout = window.setTimeout(() => {
|
||
router.replace(`${base}${filterQuery ? `?${filterQuery}` : ""}`, {
|
||
scroll: false,
|
||
});
|
||
}, 350);
|
||
|
||
return () => window.clearTimeout(timeout);
|
||
}, [appliedQuery, base, filterQuery, router]);
|
||
|
||
return (
|
||
<div className="flex flex-col gap-3">
|
||
<div className="flex flex-col gap-2 lg:flex-row">
|
||
<Input
|
||
value={values.q}
|
||
onChange={(event) =>
|
||
setValues((current) => ({ ...current, q: event.target.value }))
|
||
}
|
||
placeholder="Search name or Discord ID"
|
||
aria-label="Search responses"
|
||
/>
|
||
<Select
|
||
value={values.edited}
|
||
onValueChange={(edited: ResultFilters["edited"]) =>
|
||
setValues((current) => ({ ...current, edited }))
|
||
}
|
||
>
|
||
<SelectTrigger className="w-full lg:w-40" aria-label="Edit status">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectGroup>
|
||
<SelectItem value="all">All responses</SelectItem>
|
||
<SelectItem value="edited">Edited</SelectItem>
|
||
<SelectItem value="unedited">Not edited</SelectItem>
|
||
</SelectGroup>
|
||
</SelectContent>
|
||
</Select>
|
||
<Select
|
||
value={`${values.sort}-${values.direction}`}
|
||
onValueChange={(value) => {
|
||
const [sort, direction] = value.split("-") as [
|
||
ResultFilters["sort"],
|
||
ResultFilters["direction"],
|
||
];
|
||
setValues((current) => ({ ...current, sort, direction }));
|
||
}}
|
||
>
|
||
<SelectTrigger className="w-full lg:w-44" aria-label="Sort responses">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectGroup>
|
||
<SelectItem value="submittedAt-desc">Newest first</SelectItem>
|
||
<SelectItem value="submittedAt-asc">Oldest first</SelectItem>
|
||
<SelectItem value="respondent-asc">Respondent A–Z</SelectItem>
|
||
<SelectItem value="respondent-desc">Respondent Z–A</SelectItem>
|
||
</SelectGroup>
|
||
</SelectContent>
|
||
</Select>
|
||
<DateFilterPicker
|
||
label="From date"
|
||
value={values.dateFrom}
|
||
onChange={(dateFrom) =>
|
||
setValues((current) => ({ ...current, dateFrom }))
|
||
}
|
||
/>
|
||
<DateFilterPicker
|
||
label="Through date"
|
||
value={values.dateTo}
|
||
onChange={(dateTo) =>
|
||
setValues((current) => ({ ...current, dateTo }))
|
||
}
|
||
/>
|
||
<Button variant="outline" asChild>
|
||
<a href={`${base}/export${filterQuery ? `?${filterQuery}` : ""}`}>
|
||
<Download data-icon="inline-start" />
|
||
CSV
|
||
</a>
|
||
</Button>
|
||
</div>
|
||
{(filters.q ||
|
||
filters.edited !== "all" ||
|
||
filters.dateFrom ||
|
||
filters.dateTo) && (
|
||
<Button variant="ghost" size="sm" className="self-start" asChild>
|
||
<a href={base}>
|
||
<X data-icon="inline-start" />
|
||
Clear filters
|
||
</a>
|
||
</Button>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|