Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
170 changes: 170 additions & 0 deletions clients/web/src/components/guests/GuestFilterPopover.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import { Filter } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/Button";
import { cn } from "@/lib/utils";

type GuestFilterPopoverProps = {
availableFloors: Array<number>;
availableGroupSizes: Array<number>;
selectedFloor: string;
selectedGroupSize: string;
onApply: (floor: string, groupSize: string) => 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>
);
}

export function GuestFilterPopover({
availableFloors,
availableGroupSizes,
selectedFloor,
selectedGroupSize,
onApply,
}: GuestFilterPopoverProps) {
const [open, setOpen] = useState(false);
const [pendingFloor, setPendingFloor] = useState(selectedFloor);
const [pendingGroupSize, setPendingGroupSize] = useState(selectedGroupSize);

const handleOpen = () => {
setPendingFloor(selectedFloor);
setPendingGroupSize(selectedGroupSize);
setOpen(true);
};

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

const handleApply = () => {
onApply(pendingFloor, pendingGroupSize);
setOpen(false);
};

const handleReset = () => {
setPendingFloor("all");
setPendingGroupSize("all");
};

const activeFilterCount =
(selectedFloor !== "all" ? 1 : 0) + (selectedGroupSize !== "all" ? 1 : 0);

return (
<div className="relative shrink-0">
<button
type="button"
onClick={open ? handleCancel : handleOpen}
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={pendingFloor === String(floor)}
onClick={() =>
setPendingFloor(
pendingFloor === String(floor)
? "all"
: String(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={pendingGroupSize === String(size)}
onClick={() =>
setPendingGroupSize(
pendingGroupSize === String(size)
? "all"
: String(size),
)
}
/>
))}
</div>
</section>
)}
</div>

<div className="border-t border-stroke-subtle" />
<div className="flex justify-end gap-3 py-4">
<Button variant="secondary" onClick={handleCancel}>
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>;
selectedFloor: string;
selectedGroupSize: string;
onApplyFilters: (floor: string, groupSize: string) => void;
};

export function GuestListHeader({
searchTerm,
onSearchChange,
availableFloors,
availableGroupSizes,
selectedFloor,
selectedGroupSize,
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}
selectedFloor={selectedFloor}
selectedGroupSize={selectedGroupSize}
onApply={onApplyFilters}
/>
</div>
);
}
109 changes: 33 additions & 76 deletions clients/web/src/components/guests/GuestQuickListTable.tsx
Original file line number Diff line number Diff line change
@@ -1,99 +1,56 @@
import { UserRound } from "lucide-react";
import type { GuestWithBooking } from "@shared";

type GuestQuickListTableProps = {
guests: Array<GuestWithBooking>;
floorOptions: Array<number>;
groupSizeOptions: Array<number>;
groupFilter: string;
floorFilter: string;
isLoading?: boolean;
onGroupFilterChange: (value: string) => void;
onFloorFilterChange: (value: string) => void;
onGuestClick: (guestId: string) => void;
};

function avatarPill() {
return (
<div className="flex h-[2vw] w-[2vw] items-center justify-center rounded-full border border-black">
<UserRound className="h-[2vh] w-[2vh] text-black" />
</div>
);
}
const COL_CLASSES =
"grid-cols-[minmax(0,3fr)_minmax(0,2fr)_minmax(0,5fr)_minmax(5rem,1fr)]";

export function GuestQuickListTable({
guests,
floorOptions,
groupSizeOptions,
groupFilter,
floorFilter,
isLoading = false,
onGroupFilterChange,
onFloorFilterChange,
onGuestClick,
}: GuestQuickListTableProps) {
return (
<section className="w-full">
<div className="mb-[1vh] grid grid-cols-[5fr_5fr_2fr_2fr_2fr] items-center gap-[1vw] px-[1vw] text-[1vw] text-black">
<p>Government Name</p>
<p>Preferred Name</p>
<select
value={groupFilter}
onChange={(event) => onGroupFilterChange(event.target.value)}
className="h-[3vh] min-h-[3vh] border border-black bg-white px-[1vw] text-[1vw]"
aria-label="Group filter"
>
<option value="all">Group</option>
{groupSizeOptions.map((size) => (
<option key={size} value={String(size)}>
{size}
</option>
))}
</select>
<select
value={floorFilter}
onChange={(event) => onFloorFilterChange(event.target.value)}
className="h-[3vh] min-h-[3vh] border border-black bg-white px-[1vw] text-[1vw]"
aria-label="Floor filter"
>
<option value="all">Floor</option>
{floorOptions.map((floor) => (
<option key={floor} value={String(floor)}>
{floor}
</option>
))}
</select>
<p>Room</p>
<div
className={`mb-2 grid ${COL_CLASSES} items-center gap-4 px-4 py-2 text-sm font-medium text-primary`}
>
<p>Guest</p>
<p>Specific Needs</p>
<p>Active Bookings</p>
<p>Requests</p>
</div>

<div className="overflow-hidden border border-black bg-white">
{guests.map((guest) => {
const groupSize = guest.group_size as number | null | undefined;
<div className="overflow-hidden rounded-xl border border-stroke-subtle bg-white">
{guests.map((guest) => (
<button
key={guest.id}
type="button"
onClick={() => onGuestClick(guest.id)}
className={`grid w-full ${COL_CLASSES} items-center gap-4 border-b border-stroke-subtle px-4 py-4 text-left last:border-b-0 hover:bg-bg-container`}
>
<p className="truncate text-sm font-medium text-primary">
{guest.first_name} {guest.last_name}
</p>

<p className="text-sm text-text-subtle">—</p>

<div className="flex min-w-0 flex-wrap gap-1.5">
<span className="inline-flex items-center rounded px-2 py-1 text-xs bg-bg-selected text-primary">
Floor {guest.floor}, Suite {guest.room_number}
</span>
</div>

<p className="text-sm text-primary">—</p>
</button>
))}

return (
<button
key={guest.id}
type="button"
onClick={() => onGuestClick(guest.id)}
className="grid w-full grid-cols-[auto_5fr_5fr_2fr_2fr_2fr] items-center gap-[1vw] border-b border-black px-[1vw] py-[1vh] text-left last:border-b-0 hover:bg-neutral-50"
>
{avatarPill()}
<p className="truncate text-[1vw] text-black">
{guest.first_name} {guest.last_name}
</p>
<p className="truncate text-[1vw] text-black">
{guest.preferred_name}
</p>
<p className="text-[1vw] text-black">
{groupSize == null ? "—" : String(groupSize)}
</p>
<p className="text-[1vw] text-black">{guest.floor}</p>
<p className="text-[1vw] text-black">{guest.room_number}</p>
</button>
);
})}
{!isLoading && guests.length === 0 && (
<div className="px-[1vw] py-[2vh] text-[1vw] text-neutral-600">
<div className="px-4 py-6 text-sm text-text-subtle">
No guests match your current filters.
</div>
)}
Expand Down
7 changes: 6 additions & 1 deletion clients/web/src/components/guests/GuestSearchBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ type GuestSearchBarProps = {

export function GuestSearchBar({ value, onChange }: GuestSearchBarProps) {
return (
<SearchBar value={value} onChange={onChange} placeholder="Search Guests" />
<SearchBar
value={value}
onChange={onChange}
placeholder="Search Guests"
className="rounded-lg px-4 py-2.5"
/>
);
}
Loading
Loading