diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index bbf2d413c..2d06431d3 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -139,16 +139,12 @@ definitions: items: type: integer type: array - hotel_id: - type: string limit: maximum: 100 minimum: 1 type: integer search: type: string - required: - - hotel_id type: object GuestPage: properties: @@ -162,18 +158,25 @@ definitions: GuestWithBooking: properties: first_name: + example: Jane type: string floor: + example: 3 type: integer group_size: + example: 2 type: integer id: + example: 530e8400-e458-41d4-a716-446655440000 type: string last_name: + example: Doe type: string preferred_name: + example: Jane type: string room_number: + example: 301 type: integer required: - first_name @@ -532,41 +535,36 @@ paths: post: consumes: - application/json - description: Retrieves guests optionally filtered by floor + description: Creates a guest with the given data parameters: - - description: Hotel ID (UUID) - in: header - name: X-Hotel-ID - required: true - type: string - - description: Guest filters + - description: Guest data in: body - name: body + name: request required: true schema: - $ref: '#/definitions/GuestFilters' + $ref: '#/definitions/CreateGuest' produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/GuestPage' + $ref: '#/definitions/Guest' "400": - description: Bad Request + description: Invalid guest body format schema: additionalProperties: type: string type: object "500": - description: Internal Server Error + description: Internal server error schema: additionalProperties: type: string type: object security: - BearerAuth: [] - summary: Get Guests + summary: Creates a guest tags: - guests /api/v1/guests/{id}: @@ -654,6 +652,47 @@ paths: summary: Updates a guest tags: - guests + /api/v1/guests/search: + post: + consumes: + - application/json + description: Retrieves guests optionally filtered by floor + parameters: + - description: Hotel ID (UUID) + in: header + name: X-Hotel-ID + required: true + type: string + - description: Guest filters + in: body + name: body + required: true + schema: + $ref: '#/definitions/GuestFilters' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/GuestPage' + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Get Guests + tags: + - guests /api/v1/guests/stays/{id}: get: consumes: diff --git a/backend/internal/handler/guests.go b/backend/internal/handler/guests.go index eb37364f3..513d4b598 100644 --- a/backend/internal/handler/guests.go +++ b/backend/internal/handler/guests.go @@ -177,7 +177,7 @@ func (h *GuestsHandler) UpdateGuest(c *fiber.Ctx) error { // @Failure 400 {object} map[string]string // @Failure 500 {object} map[string]string // @Security BearerAuth -// @Router /api/v1/guests [post] +// @Router /api/v1/guests/search [post] func (h *GuestsHandler) GetGuests(c *fiber.Ctx) error { hotelID := c.Get("X-Hotel-ID") var filters models.GuestFilters diff --git a/backend/internal/models/guests.go b/backend/internal/models/guests.go index c404701b7..6cc31dcb2 100644 --- a/backend/internal/models/guests.go +++ b/backend/internal/models/guests.go @@ -43,7 +43,7 @@ type Guest struct { } //@name Guest type GuestFilters struct { - HotelID string `json:"hotel_id" validate:"required,uuid"` + HotelID string `json:"hotel_id" validate:"required,uuid" swaggerignore:"true"` Floors []int `json:"floors"` GroupSize []int `json:"group_size"` Search string `json:"search"` @@ -59,13 +59,13 @@ type GuestPage struct { } // @name GuestPage type GuestWithBooking struct { - ID string `json:"id"` - FirstName string `json:"first_name"` - LastName string `json:"last_name"` - PreferredName string `json:"preferred_name"` - Floor int `json:"floor"` - RoomNumber int `json:"room_number"` - GroupSize *int `json:"group_size"` + ID string `json:"id" validate:"required" example:"530e8400-e458-41d4-a716-446655440000"` + FirstName string `json:"first_name" validate:"required" example:"Jane"` + LastName string `json:"last_name" validate:"required" example:"Doe"` + PreferredName string `json:"preferred_name" validate:"required" example:"Jane"` + Floor int `json:"floor" validate:"required" example:"3"` + RoomNumber int `json:"room_number" validate:"required" example:"301"` + GroupSize *int `json:"group_size" example:"2"` } // @name GuestWithBooking type GuestWithStays struct { diff --git a/backend/internal/repository/guests.go b/backend/internal/repository/guests.go index 7cdd65988..3588aca3b 100644 --- a/backend/internal/repository/guests.go +++ b/backend/internal/repository/guests.go @@ -108,7 +108,10 @@ func (r *GuestsRepository) FindGuestWithStayHistory(ctx context.Context, id stri var status *models.BookingStatus if guest == nil { - guest = &models.GuestWithStays{} + guest = &models.GuestWithStays{ + CurrentStays: []models.Stay{}, + PastStays: []models.Stay{}, + } } err := rows.Scan( diff --git a/clients/shared/src/index.ts b/clients/shared/src/index.ts index 5f4f8a62a..b06ffef52 100644 --- a/clients/shared/src/index.ts +++ b/clients/shared/src/index.ts @@ -45,8 +45,18 @@ export { usePostApiV1Guests, useGetApiV1GuestsId, usePutApiV1GuestsId, + usePostApiV1GuestsSearchHook, + useGetApiV1GuestsStaysId, } from "./api/generated/endpoints/guests/guests"; +export type { + GuestWithBooking, + GuestWithStays, + GuestPage, + GuestFilters, + Stay, +} from "./api/generated/models"; + export { usePostRooms, useGetRoomsFloors } from "./api/generated/endpoints/rooms/rooms"; export type { diff --git a/clients/web/src/components/guests/GuestDetailsDrawer.tsx b/clients/web/src/components/guests/GuestDetailsDrawer.tsx new file mode 100644 index 000000000..d2fbfbb1d --- /dev/null +++ b/clients/web/src/components/guests/GuestDetailsDrawer.tsx @@ -0,0 +1,53 @@ +import type { GuestDrawerTab } from "./guest-drawer-state"; +import { DrawerShell } from "@/components/ui/DrawerShell"; + +type GuestDetailsDrawerProps = { + guestName: string; + activeTab: GuestDrawerTab; + onChangeTab: (tab: GuestDrawerTab) => void; + onClose: () => void; + children: React.ReactNode; +}; + +const tabs: Array<{ label: string; value: GuestDrawerTab }> = [ + { label: "Profile", value: "profile" }, + { label: "Visit Activity", value: "activity" }, +]; + +export function GuestDetailsDrawer({ + guestName, + activeTab, + onChangeTab, + onClose, + children, +}: GuestDetailsDrawerProps) { + return ( + +
+
+ {tabs.map((tab) => { + const selected = tab.value === activeTab; + + return ( + + ); + })} +
+
+ + {children} +
+ ); +} diff --git a/clients/web/src/components/guests/GuestFilterPopover.tsx b/clients/web/src/components/guests/GuestFilterPopover.tsx new file mode 100644 index 000000000..d3f58af0b --- /dev/null +++ b/clients/web/src/components/guests/GuestFilterPopover.tsx @@ -0,0 +1,162 @@ +import { useState } from "react"; +import { Filter } from "lucide-react"; +import { Button } from "@/components/ui/Button"; +import { cn } from "@/lib/utils"; + +const FLOOR_OPTIONS = [1, 2, 3, 4, 5]; +const GROUP_SIZE_OPTIONS = ["1-2", "3-4", "5+"]; + +type GuestFilterPopoverProps = { + selectedFloors: Array; + selectedGroupSizes: Array; + onApply: (floors: Array, groupSizes: Array) => void; +}; + +export function selectSingleFilterValue(items: Array, item: T) { + return items.includes(item) ? [] : [item]; +} + +function FilterButton({ + label, + selected, + onClick, +}: { + label: string; + selected: boolean; + onClick: () => void; +}) { + return ( + + ); +} + +function Section({ + title, + children, +}: { + title: string; + children: React.ReactNode; +}) { + return ( +
+

{title}

+
{children}
+
+ ); +} + +export function GuestFilterPopover({ + selectedFloors, + selectedGroupSizes, + onApply, +}: GuestFilterPopoverProps) { + const [open, setOpen] = useState(false); + const [pendingFloors, setPendingFloors] = useState(selectedFloors); + const [pendingGroupSizes, setPendingGroupSizes] = + useState(selectedGroupSizes); + + const handleOpen = () => { + setPendingFloors(selectedFloors); + setPendingGroupSizes(selectedGroupSizes); + setOpen(true); + }; + + const handleCancel = () => { + setPendingFloors(selectedFloors); + setPendingGroupSizes(selectedGroupSizes); + setOpen(false); + }; + + const handleSelect = () => { + onApply(pendingFloors, pendingGroupSizes); + setOpen(false); + }; + + const handleReset = () => { + setPendingFloors([]); + setPendingGroupSizes([]); + }; + + return ( +
+ + + {open ? ( +
+
+ + All Filters + + +
+ +
+
+ {FLOOR_OPTIONS.map((floor) => ( + + setPendingFloors((current) => + selectSingleFilterValue(current, floor), + ) + } + /> + ))} +
+ +
+ {GROUP_SIZE_OPTIONS.map((groupSize) => ( + + setPendingGroupSizes((current) => + selectSingleFilterValue(current, groupSize), + ) + } + /> + ))} +
+
+ +
+
+ + +
+
+ ) : null} +
+ ); +} diff --git a/clients/web/src/components/guests/GuestListHeader.tsx b/clients/web/src/components/guests/GuestListHeader.tsx new file mode 100644 index 000000000..419784e5b --- /dev/null +++ b/clients/web/src/components/guests/GuestListHeader.tsx @@ -0,0 +1,31 @@ +import { GuestFilterPopover } from "./GuestFilterPopover"; +import { GuestSearchBar } from "./GuestSearchBar"; + +type GuestListHeaderProps = { + searchTerm: string; + onSearchChange: (value: string) => void; + selectedFloors: Array; + selectedGroupSizes: Array; + onApplyFilters: (floors: Array, groupSizes: Array) => void; +}; + +export function GuestListHeader({ + searchTerm, + onSearchChange, + selectedFloors, + selectedGroupSizes, + onApplyFilters, +}: GuestListHeaderProps) { + return ( +
+
+ +
+ +
+ ); +} diff --git a/clients/web/src/components/guests/GuestNotesCard.tsx b/clients/web/src/components/guests/GuestNotesCard.tsx index 1e4846c22..c71fec2af 100644 --- a/clients/web/src/components/guests/GuestNotesCard.tsx +++ b/clients/web/src/components/guests/GuestNotesCard.tsx @@ -1,13 +1,13 @@ import { useState } from "react"; type GuestNotesCardProps = { - initialNotes: string; + initialNotes?: string; }; export function GuestNotesCard({ initialNotes }: GuestNotesCardProps) { const [isEditing, setIsEditing] = useState(false); - const [notes, setNotes] = useState(initialNotes); - const [draft, setDraft] = useState(initialNotes); + const [notes, setNotes] = useState(initialNotes ?? ""); + const [draft, setDraft] = useState(initialNotes ?? ""); const startEditing = () => { setDraft(notes); diff --git a/clients/web/src/components/guests/GuestProfileCard.tsx b/clients/web/src/components/guests/GuestProfileCard.tsx index b1460b32a..ba5d4b6aa 100644 --- a/clients/web/src/components/guests/GuestProfileCard.tsx +++ b/clients/web/src/components/guests/GuestProfileCard.tsx @@ -1,8 +1,9 @@ import { UserRound } from "lucide-react"; -import type { GuestProfile } from "./guest-mocks"; +import { formatDate } from "../../utils/dates"; +import type { GuestWithStays } from "@shared"; type GuestProfileCardProps = { - guest: GuestProfile; + guest: GuestWithStays; }; function DetailRow({ label, value }: { label: string; value: string }) { @@ -15,6 +16,9 @@ function DetailRow({ label, value }: { label: string; value: string }) { } export function GuestProfileCard({ guest }: GuestProfileCardProps) { + const hasCurrentStay = guest.current_stays.length > 0; + const currentStay = guest.current_stays[0]; + return (
@@ -23,28 +27,27 @@ export function GuestProfileCard({ guest }: GuestProfileCardProps) {

- {guest.preferredName} + {guest.first_name} {guest.last_name}

-

{guest.pronouns}

-
- - -
-
- - - - + {hasCurrentStay ? ( + <> + + + + + ) : ( +

No active stay.

+ )}
); diff --git a/clients/web/src/components/guests/GuestQuickListTable.tsx b/clients/web/src/components/guests/GuestQuickListTable.tsx index 3157e94b4..fb7fcab88 100644 --- a/clients/web/src/components/guests/GuestQuickListTable.tsx +++ b/clients/web/src/components/guests/GuestQuickListTable.tsx @@ -1,85 +1,53 @@ -import { UserRound } from "lucide-react"; -import type { GuestListItem } from "./guest-mocks"; +import type { GuestWithBooking } from "@shared"; type GuestQuickListTableProps = { - guests: Array; - groupFilter: string; - floorFilter: string; - onGroupFilterChange: (value: string) => void; - onFloorFilterChange: (value: string) => void; + guests: Array; + isLoading?: boolean; onGuestClick: (guestId: string) => void; }; -function avatarPill() { - return ( -
- -
- ); -} - export function GuestQuickListTable({ guests, - groupFilter, - floorFilter, - onGroupFilterChange, - onFloorFilterChange, + isLoading = false, onGuestClick, }: GuestQuickListTableProps) { return (
-
-

Government Name

-

Preferred Name

- - -

Room

+
+

Guest

+

Active Bookings

+

Group Size

+

Specific Assistance

-
+
{guests.map((guest) => ( ))} - {guests.length === 0 && ( -
+ {!isLoading && guests.length === 0 && ( +
No guests match your current filters.
)} diff --git a/clients/web/src/components/guests/GuestSearchBar.tsx b/clients/web/src/components/guests/GuestSearchBar.tsx index edd35a1cf..7187073eb 100644 --- a/clients/web/src/components/guests/GuestSearchBar.tsx +++ b/clients/web/src/components/guests/GuestSearchBar.tsx @@ -7,15 +7,15 @@ type GuestSearchBarProps = { export function GuestSearchBar({ value, onChange }: GuestSearchBarProps) { return ( -
); diff --git a/clients/web/src/components/guests/guest-drawer-state.ts b/clients/web/src/components/guests/guest-drawer-state.ts new file mode 100644 index 000000000..eb76445b8 --- /dev/null +++ b/clients/web/src/components/guests/guest-drawer-state.ts @@ -0,0 +1,59 @@ +export type GuestDrawerTab = "profile" | "activity"; +export type GuestActivityView = "summary" | "history"; + +export type GuestDrawerSearch = { + guestId?: string; + tab?: string; + activityView?: string; +}; + +export function resolveGuestDrawerSearch(search: GuestDrawerSearch): { + guestId?: string; + tab: GuestDrawerTab; + activityView: GuestActivityView; +} { + return { + guestId: typeof search.guestId === "string" ? search.guestId : undefined, + tab: search.tab === "activity" ? "activity" : "profile", + activityView: search.activityView === "history" ? "history" : "summary", + }; +} + +export function clearGuestDrawerSearch( + search: T, +): T & { + guestId: undefined; + tab: undefined; + activityView: undefined; +} { + return { + ...search, + guestId: undefined, + tab: undefined, + activityView: undefined, + }; +} + +export function resolveGuestDrawerTitle({ + guestId, + activeGuestName, + closingGuestName, +}: { + guestId?: string; + activeGuestName?: string; + closingGuestName?: string; +}) { + if (activeGuestName) return activeGuestName; + if (!guestId && closingGuestName) return closingGuestName; + return "Guest"; +} + +export function getGuestDrawerVisibility({ + guestId, + generatedRequestOpen, +}: { + guestId?: string; + generatedRequestOpen: boolean; +}) { + return Boolean(guestId) && !generatedRequestOpen; +} diff --git a/clients/web/src/components/guests/guest-mocks.ts b/clients/web/src/components/guests/guest-mocks.ts deleted file mode 100644 index ae928ce48..000000000 --- a/clients/web/src/components/guests/guest-mocks.ts +++ /dev/null @@ -1,137 +0,0 @@ -export type GuestListItem = { - id: string; - governmentName: string; - preferredName: string; - groupSize: number; - floor: number; - room: string; -}; - -export type PreviousStay = { - id: string; - startDate: string; - endDate: string; - room: string; - groupSize: number; -}; - -export type GuestProfile = { - id: string; - governmentName: string; - preferredName: string; - pronouns: string; - dateOfBirth: string; - room: string; - groupSize: number; - arrivalTime: string; - arrivalDate: string; - departureTime: string; - departureDate: string; - notes: string; - specialNeeds: { - dietaryRestrictions: string; - accessibilityNeeds: string; - sensorySensitivities: string; - medicalConditions: string; - }; - previousStays: Array; - housekeeping: { - frequency: string; - doNotDisturb: string; - }; -}; - -export const guestListItems: Array = [ - { - id: "monkey-d-luffy", - governmentName: "Monkey D. Luffy", - preferredName: "Luffy", - groupSize: 5, - floor: 3, - room: "Suite 300", - }, - { - id: "roronoa-zoro", - governmentName: "Roronoa Zoro", - preferredName: "Zoro", - groupSize: 4, - floor: 4, - room: "Suite 401", - }, - { - id: "nami", - governmentName: "Nami", - preferredName: "Nami", - groupSize: 2, - floor: 2, - room: "Suite 203", - }, - { - id: "nico-robin", - governmentName: "Nico Robin", - preferredName: "Robin", - groupSize: 3, - floor: 5, - room: "Suite 502", - }, - { - id: "usopp", - governmentName: "Usopp", - preferredName: "Usopp", - groupSize: 6, - floor: 3, - room: "Suite 318", - }, - { - id: "sanji", - governmentName: "Vinsmoke Sanji", - preferredName: "Sanji", - groupSize: 1, - floor: 1, - room: "Suite 102", - }, -]; - -export const guestProfilesById: Record = { - "monkey-d-luffy": { - id: "monkey-d-luffy", - governmentName: "Monkey D. Luffy", - preferredName: "Luffy", - pronouns: "he/him", - dateOfBirth: "03/21/2005", - room: "Suite 300", - groupSize: 5, - arrivalTime: "11:00 AM", - arrivalDate: "01/25/2026", - departureTime: "11:00 AM", - departureDate: "02/04/2026", - notes: - '"Wealth, fame, power... Gold Roger, the King of the Pirates, obtained this and everything else the world had to offer and his dying words drove countless souls to the seas.""You want my treasure? You can have it! I left everything I gathered together in one place! Now you just have to find it!""These words lured men to the Grand Line. In pursuit of dreams greather than theyve ever dared to imagine! This is the time known as the Great Pirate Era!"', - specialNeeds: { - dietaryRestrictions: "", - accessibilityNeeds: "", - sensorySensitivities: "", - medicalConditions: "", - }, - previousStays: [ - { - id: "stay-1", - startDate: "05/12/2024", - endDate: "05/20/2024", - room: "Suite 401", - groupSize: 5, - }, - { - id: "stay-2", - startDate: "12/04/2023", - endDate: "12/11/2023", - room: "Suite 318", - groupSize: 4, - }, - ], - housekeeping: { - frequency: "Daily", - doNotDisturb: "6:00 PM - 10:00 AM", - }, - }, -}; diff --git a/clients/web/src/components/ui/PageShell.tsx b/clients/web/src/components/ui/PageShell.tsx index 95e551127..36cce95db 100644 --- a/clients/web/src/components/ui/PageShell.tsx +++ b/clients/web/src/components/ui/PageShell.tsx @@ -5,6 +5,7 @@ type PageShellProps = { header: ReactNode; drawer?: ReactNode; drawerOpen?: boolean; + onDrawerClose?: () => void; children: ReactNode; contentClassName?: string; bodyClassName?: string; @@ -14,11 +15,13 @@ export function PageShell({ header, drawer, drawerOpen = false, + onDrawerClose, children, contentClassName, bodyClassName, }: PageShellProps) { const hasDrawer = drawer !== undefined; + const showOverlay = hasDrawer && drawerOpen && onDrawerClose !== undefined; return (
@@ -42,10 +45,19 @@ export function PageShell({ + {showOverlay && ( + + )} + + )} + {generatedRequest === null && ( - + { + navigate({ + to: "/guests", + search: (prev) => clearGuestDrawerSearch(prev), + replace: true, + }); + setGeneratedRequest(request); + }} + /> )} ); diff --git a/clients/web/src/tests/generated-types.test.ts b/clients/web/src/tests/generated-types.test.ts index bde339ee8..c0c592b0d 100644 --- a/clients/web/src/tests/generated-types.test.ts +++ b/clients/web/src/tests/generated-types.test.ts @@ -38,7 +38,6 @@ describe("Generated Types Integration", () => { status: "pending", priority: "high", created_at: "2024-01-01T00:00:00Z", - request_version: "2024-01-01T00:00:00Z", }; expect(request.id).toBe("456"); diff --git a/clients/web/src/tests/guests-ui.test.tsx b/clients/web/src/tests/guests-ui.test.tsx new file mode 100644 index 000000000..a275d2b43 --- /dev/null +++ b/clients/web/src/tests/guests-ui.test.tsx @@ -0,0 +1,267 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { selectSingleFilterValue } from "../components/guests/GuestFilterPopover"; +import { GuestDetailsDrawer } from "../components/guests/GuestDetailsDrawer"; +import { GuestQuickListTable } from "../components/guests/GuestQuickListTable"; +import { PageShell } from "../components/ui/PageShell"; +import { + clearGuestDrawerSearch, + getGuestDrawerVisibility, + resolveGuestDrawerSearch, + resolveGuestDrawerTitle, +} from "../components/guests/guest-drawer-state"; +import { formatDate } from "../utils/dates"; + +describe("guest UI helpers", () => { + describe("formatDate", () => { + it("formats ISO dates without shifting the calendar day", () => { + expect(formatDate("2024-01-02")).toBe("01/02/2024"); + expect(formatDate("2024-01-02T00:00:00Z")).toBe("01/02/2024"); + }); + }); + + describe("GuestQuickListTable", () => { + it("does not show the empty state while the first page is loading", () => { + render( + {}} />, + ); + + expect(screen.queryByText("No guests match your current filters.")).toBe( + null, + ); + }); + + it("shows the specific assistance placeholder column", () => { + render( + {}} + />, + ); + + expect(screen.getByText("Specific Assistance")).not.toBeNull(); + }); + + it("renders grouped table headings for guest, active bookings, and specific assistance", () => { + render( {}} />); + + expect(screen.getByText("Guest")).not.toBeNull(); + expect(screen.getByText("Active Bookings")).not.toBeNull(); + expect(screen.getByText("Group Size")).not.toBeNull(); + expect(screen.getByText("Specific Assistance")).not.toBeNull(); + }); + + it("renders guest rows as separate government, preferred, suite, and group cells", () => { + render( + {}} + />, + ); + + expect(screen.getByText("Xinning Lucy Liu")).not.toBeNull(); + expect(screen.getByText("(Lucy)")).not.toBeNull(); + expect(screen.getByText("Suite 300")).not.toBeNull(); + expect(screen.getByText("Floor 3")).not.toBeNull(); + expect( + screen.getByText((content) => content.trim() === "5"), + ).not.toBeNull(); + }); + + it("falls back to the first name when preferred name is missing", () => { + render( + {}} + />, + ); + + expect(screen.getByText("(Jane)")).not.toBeNull(); + }); + }); + + describe("guest drawer state", () => { + it("normalizes guest drawer search params with safe defaults", () => { + expect( + resolveGuestDrawerSearch({ + guestId: "guest-123", + tab: "unknown", + activityView: "unknown", + }), + ).toEqual({ + guestId: "guest-123", + tab: "profile", + activityView: "summary", + }); + }); + + it("hides the guest drawer when the generated request drawer is open", () => { + expect( + getGuestDrawerVisibility({ + guestId: "guest-123", + generatedRequestOpen: true, + }), + ).toBe(false); + }); + + it("clears guest drawer query params when a generated request takes over", () => { + expect( + clearGuestDrawerSearch({ + guestId: "guest-123", + tab: "activity", + activityView: "history", + unrelated: "keep-me", + }), + ).toEqual({ + guestId: undefined, + tab: undefined, + activityView: undefined, + unrelated: "keep-me", + }); + }); + + it("keeps the last resolved guest name during drawer close", () => { + expect( + resolveGuestDrawerTitle({ + guestId: undefined, + activeGuestName: undefined, + closingGuestName: "Lucy Liu", + }), + ).toBe("Lucy Liu"); + }); + + it("does not reuse the previous guest name while a new guest is loading", () => { + expect( + resolveGuestDrawerTitle({ + guestId: "guest-456", + activeGuestName: undefined, + closingGuestName: "Lucy Liu", + }), + ).toBe("Guest"); + }); + }); + + describe("GuestFilterPopover", () => { + it("keeps floor and group size selection single-select", () => { + expect(selectSingleFilterValue([], 1)).toEqual([1]); + expect(selectSingleFilterValue([1], 3)).toEqual([3]); + expect(selectSingleFilterValue([3], 3)).toEqual([]); + expect(selectSingleFilterValue(["1-2"], "5+")).toEqual(["5+"]); + }); + }); + + describe("PageShell", () => { + it("renders a transparent dismissible overlay when the drawer is open", () => { + let closeCount = 0; + + render( + Header} + drawerOpen + onDrawerClose={() => { + closeCount += 1; + }} + drawer={
Drawer content
} + > +
Page content
+
, + ); + + const overlay = screen.getByLabelText("Close drawer overlay"); + fireEvent.click(overlay); + + expect(closeCount).toBe(1); + expect(overlay.className).toContain("bg-transparent"); + }); + + it("stacks the dismiss overlay above floating page controls and below the drawer", () => { + const { container } = render( + Header} + drawerOpen + onDrawerClose={() => {}} + drawer={
Drawer content
} + > +
Floating page control
+
, + ); + + const overlay = screen.getByLabelText("Close drawer overlay"); + const drawer = container.querySelector("aside"); + + expect(overlay.className).toContain("z-60"); + expect(drawer?.className).toContain("z-70"); + }); + }); + + describe("GuestDetailsDrawer", () => { + it("switches tabs through the provided callbacks", () => { + let activeTab: "profile" | "activity" = "profile"; + + const { rerender } = render( + { + activeTab = nextTab; + }} + onClose={() => {}} + > +
Drawer body
+
, + ); + + fireEvent.click(screen.getByRole("button", { name: "Visit Activity" })); + + rerender( + { + activeTab = nextTab; + }} + onClose={() => {}} + > +
Drawer body
+
, + ); + + expect( + screen + .getByRole("button", { name: "Visit Activity" }) + .getAttribute("aria-pressed"), + ).toBe("true"); + }); + }); +}); diff --git a/clients/web/src/utils/dates.ts b/clients/web/src/utils/dates.ts new file mode 100644 index 000000000..dc1c5fbfc --- /dev/null +++ b/clients/web/src/utils/dates.ts @@ -0,0 +1,10 @@ +export function formatDate(iso: string): string { + const datePart = iso.includes("T") ? iso.split("T")[0] : iso; + const [year, month, day] = datePart.split("-"); + + if (!year || !month || !day) { + return iso; + } + + return `${month}/${day}/${year}`; +}