165 lines
5.7 KiB
TypeScript
165 lines
5.7 KiB
TypeScript
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>
|
|
);
|
|
} |