Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions backend/docs/swagger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1613,6 +1613,14 @@ paths:
in: query
name: user_id
type: string
- description: If true, return only requests with no assigned user
in: query
name: unassigned
type: boolean
- description: 'Sort order: priority (default), newest, oldest'
in: query
name: sort
type: string
produces:
- application/json
responses:
Expand Down
198 changes: 198 additions & 0 deletions clients/web/src/components/guests/GuestFilterPopover.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
import { Filter } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/Button";
import { cn } from "@/lib/utils";

type GuestFilterPopoverProps = {
availableFloors: Array<number>;
availableGroupSizes: Array<number>;
selectedFloors: Array<number>;
selectedGroupSizes: Array<number>;
onApply: (floors: Array<number>, groupSizes: Array<number>) => void;
};

function FilterChip({
label,
selected,
onClick,
}: {
label: string;
selected: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"rounded-lg border px-4 py-1.5 text-sm transition-colors",
selected
? "border-primary bg-primary/10 text-primary"
: "border-stroke-subtle bg-white text-text-default hover:border-primary",
)}
>
{label}
</button>
);
}

function toggleItem(arr: Array<number>, item: number): Array<number> {
return arr.includes(item) ? arr.filter((v) => v !== item) : [...arr, item];
}

export function GuestFilterPopover({
availableFloors,
availableGroupSizes,
selectedFloors,
selectedGroupSizes,
onApply,
}: GuestFilterPopoverProps) {
const [open, setOpen] = useState(false);
const [pendingFloors, setPendingFloors] =
useState<Array<number>>(selectedFloors);
const [pendingGroupSizes, setPendingGroupSizes] =
useState<Array<number>>(selectedGroupSizes);
const containerRef = useRef<HTMLDivElement>(null);

const handleOpen = () => {
setPendingFloors(selectedFloors);
setPendingGroupSizes(selectedGroupSizes);
setOpen(true);
};

const handleClose = () => {
setOpen(false);
};

const handleApply = () => {
onApply(pendingFloors, pendingGroupSizes);
setOpen(false);
};

const handleReset = () => {
onApply([], []);
setOpen(false);
};

// Close on click-outside or Escape
useEffect(() => {
if (!open) return;

const handleMouseDown = (e: MouseEvent) => {
if (
containerRef.current &&
!containerRef.current.contains(e.target as Node)
) {
handleClose();
}
};

const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
handleClose();
}
};

document.addEventListener("mousedown", handleMouseDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("mousedown", handleMouseDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [open]);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Feels like there's a most modern approach to doing key listens aside from a document get. If not, this is good


const activeFilterCount = selectedFloors.length + selectedGroupSizes.length;

return (
<div className="relative shrink-0" ref={containerRef}>
<button
type="button"
onClick={open ? handleClose : handleOpen}
aria-expanded={open}
className={cn(
"flex h-11 items-center gap-2 rounded-lg border px-4 text-sm font-medium transition-colors",
activeFilterCount > 0
? "border-primary bg-primary/5 text-primary"
: "border-stroke-subtle text-text-default hover:bg-primary/5",
)}
>
<Filter className="h-4 w-4" />
Filter
{activeFilterCount > 0 && (
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-primary text-xs text-white">
{activeFilterCount}
</span>
)}
</button>

{open && (
<div className="absolute right-0 top-[calc(100%+0.5rem)] z-50 flex w-80 flex-col overflow-hidden rounded-2xl bg-white px-6 shadow-xl">
<div className="flex items-center justify-between pt-5">
<span className="text-base font-medium text-text-default">
Filters
</span>
<button
type="button"
onClick={handleReset}
className="text-sm text-text-subtle transition-colors hover:text-text-default"
>
Reset
</button>
</div>

<div className="flex flex-col gap-5 py-5">
{availableFloors.length > 0 && (
<section className="flex flex-col gap-3">
<h3 className="text-sm font-medium text-text-default">Floor</h3>
<div className="flex flex-wrap gap-2">
{availableFloors.map((floor) => (
<FilterChip
key={floor}
label={`Floor ${floor}`}
selected={pendingFloors.includes(floor)}
onClick={() =>
setPendingFloors(toggleItem(pendingFloors, floor))
}
/>
))}
</div>
</section>
)}

{availableGroupSizes.length > 0 && (
<section className="flex flex-col gap-3">
<h3 className="text-sm font-medium text-text-default">
Group Size
</h3>
<div className="flex flex-wrap gap-2">
{availableGroupSizes.map((size) => (
<FilterChip
key={size}
label={String(size)}
selected={pendingGroupSizes.includes(size)}
onClick={() =>
setPendingGroupSizes(
toggleItem(pendingGroupSizes, size),
)
}
/>
))}
</div>
</section>
)}
</div>

<div className="border-t border-stroke-subtle" />
<div className="flex justify-end gap-3 py-4">
<Button variant="secondary" onClick={handleClose}>
Cancel
</Button>
<Button variant="primary" onClick={handleApply}>
Apply
</Button>
</div>
</div>
)}
</div>
);
}
37 changes: 37 additions & 0 deletions clients/web/src/components/guests/GuestListHeader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { GuestFilterPopover } from "./GuestFilterPopover";
import { GuestSearchBar } from "./GuestSearchBar";

type GuestListHeaderProps = {
searchTerm: string;
onSearchChange: (value: string) => void;
availableFloors: Array<number>;
availableGroupSizes: Array<number>;
selectedFloors: Array<number>;
selectedGroupSizes: Array<number>;
onApplyFilters: (floors: Array<number>, groupSizes: Array<number>) => void;
};

export function GuestListHeader({
searchTerm,
onSearchChange,
availableFloors,
availableGroupSizes,
selectedFloors,
selectedGroupSizes,
onApplyFilters,
}: GuestListHeaderProps) {
return (
<div className="flex items-center gap-3">
<div className="min-w-0 flex-1">
<GuestSearchBar value={searchTerm} onChange={onSearchChange} />
</div>
<GuestFilterPopover
availableFloors={availableFloors}
availableGroupSizes={availableGroupSizes}
selectedFloors={selectedFloors}
selectedGroupSizes={selectedGroupSizes}
onApply={onApplyFilters}
/>
</div>
);
}
Loading
Loading