feat(frontend): kind + since filters on ActivityLog page
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
import { X } from "lucide-react";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ActivityKind } from "@/types";
|
||||
|
||||
/**
|
||||
* URL-friendly relative time filters. The page converts each value into an
|
||||
* ISO timestamp before passing it to `useActivity` so the backend's
|
||||
* lexicographic `timestamp >= since` comparison works against the stored
|
||||
* ISO strings.
|
||||
*
|
||||
* `all` is the implicit default and is rendered as "All time".
|
||||
*/
|
||||
export type SinceValue = "1h" | "24h" | "7d" | "all";
|
||||
|
||||
const KIND_OPTIONS: { value: ActivityKind; label: string }[] = [
|
||||
{ value: "claim_submitted", label: "Submitted" },
|
||||
{ value: "claim_accepted", label: "Accepted" },
|
||||
{ value: "claim_paid", label: "Paid" },
|
||||
{ value: "claim_denied", label: "Denied" },
|
||||
{ value: "remit_received", label: "Remit received" },
|
||||
{ value: "provider_added", label: "Provider added" },
|
||||
];
|
||||
|
||||
const SINCE_OPTIONS: { value: SinceValue; label: string }[] = [
|
||||
{ value: "all", label: "All time" },
|
||||
{ value: "1h", label: "Last 1 hour" },
|
||||
{ value: "24h", label: "Last 24 hours" },
|
||||
{ value: "7d", label: "Last 7 days" },
|
||||
];
|
||||
|
||||
type ActivityFiltersProps = {
|
||||
/** Currently selected activity kinds (multi-select). Empty array = no filter. */
|
||||
selectedKinds: ActivityKind[];
|
||||
/** Toggle a kind in or out of `selectedKinds`. */
|
||||
onKindsChange: (kinds: ActivityKind[]) => void;
|
||||
/** Active "since" window; `"all"` means no time filter. */
|
||||
since: SinceValue;
|
||||
/** Update the "since" window. */
|
||||
onSinceChange: (since: SinceValue) => void;
|
||||
/** Reset both filters (and any other future filters). */
|
||||
onClear: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Filter strip for the Activity Log page.
|
||||
*
|
||||
* - "Kind": a row of toggleable chips (multi-select). Each chip uses
|
||||
* `role="checkbox"` + `aria-checked` so screen readers announce the
|
||||
* toggle state correctly. Selected chips lift to the electric blue
|
||||
* accent, matching the single-select `FilterChips` visual language.
|
||||
*
|
||||
* - "Since": a Radix Select dropdown with the four relative windows.
|
||||
* `SelectValue` renders the current label so the user always sees
|
||||
* what's applied (rather than a bare `1h` slug).
|
||||
*
|
||||
* - "Clear filters": a small X-pill appears once any filter is active.
|
||||
* Mirrors the single-select `FilterChips` "Clear" affordance so the
|
||||
* operator's mental model is consistent across pages.
|
||||
*
|
||||
* The component is purely presentational — it doesn't read or write URL
|
||||
* state itself. `ActivityLog` owns URL sync via `useSearchParams` so the
|
||||
* component is trivially testable in isolation if needed.
|
||||
*/
|
||||
export function ActivityFilters({
|
||||
selectedKinds,
|
||||
onKindsChange,
|
||||
since,
|
||||
onSinceChange,
|
||||
onClear,
|
||||
}: ActivityFiltersProps) {
|
||||
const hasFilters = selectedKinds.length > 0 || since !== "all";
|
||||
|
||||
const toggleKind = (value: ActivityKind) => {
|
||||
if (selectedKinds.includes(value)) {
|
||||
onKindsChange(selectedKinds.filter((k) => k !== value));
|
||||
} else {
|
||||
onKindsChange([...selectedKinds, value]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col gap-3"
|
||||
data-testid="activity-filters"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="mr-1 text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Kind
|
||||
</span>
|
||||
{KIND_OPTIONS.map((opt) => {
|
||||
const isActive = selectedKinds.includes(opt.value);
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={isActive}
|
||||
data-testid={`kind-chip-${opt.value}`}
|
||||
onClick={() => toggleKind(opt.value)}
|
||||
className={cn(
|
||||
"h-7 rounded-full px-3 text-xs font-medium transition-colors",
|
||||
"ring-1 ring-inset focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
||||
isActive
|
||||
? "bg-accent/15 text-accent ring-accent/40 hover:bg-accent/20"
|
||||
: "bg-transparent text-muted-foreground ring-border/60 hover:text-foreground hover:ring-border",
|
||||
)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Since
|
||||
</span>
|
||||
<Select
|
||||
value={since}
|
||||
onValueChange={(v) => onSinceChange(v as SinceValue)}
|
||||
>
|
||||
<SelectTrigger className="w-[180px]" aria-label="Since">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SINCE_OPTIONS.map((opt) => (
|
||||
<SelectItem
|
||||
key={opt.value}
|
||||
value={opt.value}
|
||||
data-testid={`since-option-${opt.value}`}
|
||||
>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{hasFilters ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClear}
|
||||
data-testid="clear-filters"
|
||||
aria-label="Clear filters"
|
||||
className={cn(
|
||||
"h-7 rounded-full px-2.5 text-xs font-medium transition-colors",
|
||||
"inline-flex items-center gap-1",
|
||||
"text-muted-foreground hover:text-foreground",
|
||||
"ring-1 ring-inset ring-border/40 hover:ring-border/60",
|
||||
)}
|
||||
>
|
||||
<X className="h-3 w-3" strokeWidth={1.75} aria-hidden />
|
||||
Clear filters
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user