diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index f988f2ba..dc04e747 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -168,16 +168,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: @@ -216,18 +212,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 @@ -644,41 +647,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}: @@ -766,6 +764,86 @@ 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: + - application/json + description: Retrieves a single guest with previous stays given an id + parameters: + - description: Guest ID (UUID) + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/GuestWithStays' + "400": + description: Invalid guest ID format + schema: + additionalProperties: + type: string + type: object + "404": + description: Guest not found + schema: + $ref: '#/definitions/github_com_generate_selfserve_internal_errs.HTTPError' + "500": + description: Internal server error + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Gets a guest with previous stays + tags: + - guests /api/v1/hotels: post: consumes: diff --git a/backend/internal/handler/guests.go b/backend/internal/handler/guests.go index a965acac..01c18ede 100644 --- a/backend/internal/handler/guests.go +++ b/backend/internal/handler/guests.go @@ -103,7 +103,7 @@ func (h *GuestsHandler) GetGuest(c *fiber.Ctx) error { // @Failure 404 {object} errs.HTTPError "Guest not found" // @Failure 500 {object} map[string]string "Internal server error" // @Security BearerAuth -// @Router /guests/stays/{id} [get] +// @Router /api/v1/guests/stays/{id} [get] func (h *GuestsHandler) GetGuestWithStays(c *fiber.Ctx) error { id := c.Params("id") if !validUUID(id) { @@ -116,6 +116,7 @@ func (h *GuestsHandler) GetGuestWithStays(c *fiber.Ctx) error { return errs.NotFound("guest", "id", id) } + slog.Error("failed to get guest with stays", "id", id, "error", err) return errs.InternalServerError() } return c.JSON(guest) @@ -177,7 +178,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 9f749743..591685b6 100644 --- a/backend/internal/models/guests.go +++ b/backend/internal/models/guests.go @@ -49,7 +49,7 @@ type Guest struct { } //@name Guest type GuestFilters struct { - HotelID string `json:"hotel_id" validate:"required,startswith=org_"` + HotelID string `json:"hotel_id" validate:"required,startswith=org_" swaggerignore:"true"` Floors []int `json:"floors"` GroupSize []int `json:"group_size"` Search string `json:"search"` @@ -65,13 +65,13 @@ type GuestPage struct { } // @name GuestPage type GuestWithBooking struct { - ID string `json:"id" validate:"required"` - FirstName string `json:"first_name" validate:"required"` - LastName string `json:"last_name" validate:"required"` - PreferredName string `json:"preferred_name"` - Floor int `json:"floor" validate:"required"` - RoomNumber int `json:"room_number" validate:"required"` - GroupSize *int `json:"group_size" validate:"required"` + 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 { @@ -83,8 +83,8 @@ type GuestWithStays struct { Preferences *string `json:"preferences,omitempty" example:"extra pillows"` Notes *string `json:"notes,omitempty" example:"VIP"` Pronouns *string `json:"pronouns,omitempty" example:"she/her"` - DoNotDisturbStart *time.Time `json:"do_not_disturb_start,omitempty" example:"17:00:00"` - DoNotDisturbEnd *time.Time `json:"do_not_disturb_end,omitempty" example:"07:00:00"` + DoNotDisturbStart *string `json:"do_not_disturb_start,omitempty" example:"17:00:00"` + DoNotDisturbEnd *string `json:"do_not_disturb_end,omitempty" example:"07:00:00"` HousekeepingCadence *string `json:"housekeeping_cadence,omitempty" example:"daily"` Assistance *Assistance `json:"assistance,omitempty"` CurrentStays []Stay `json:"current_stays" validate:"required"` diff --git a/backend/internal/repository/guests.go b/backend/internal/repository/guests.go index ab048ddc..7d425682 100644 --- a/backend/internal/repository/guests.go +++ b/backend/internal/repository/guests.go @@ -2,14 +2,18 @@ package repository import ( "context" + "encoding/json" "errors" + "fmt" "iter" + "sort" "time" "github.com/generate/selfserve/internal/errs" "github.com/generate/selfserve/internal/models" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgxpool" ) @@ -84,7 +88,12 @@ func (r *GuestsRepository) FindGuest(ctx context.Context, id string) (*models.Gu } func (r *GuestsRepository) FindGuestWithStayHistory(ctx context.Context, id string) (*models.GuestWithStays, error) { - guest := &models.GuestWithStays{} + guest := &models.GuestWithStays{ + CurrentStays: []models.Stay{}, + PastStays: []models.Stay{}, + } + var doNotDisturbStart, doNotDisturbEnd pgtype.Time + var assistanceRaw []byte err := r.db.QueryRow(ctx, ` SELECT @@ -95,8 +104,8 @@ func (r *GuestsRepository) FindGuestWithStayHistory(ctx context.Context, id stri WHERE g.id = $1 `, id).Scan( &guest.ID, &guest.FirstName, &guest.LastName, &guest.Phone, &guest.Email, - &guest.Preferences, &guest.Notes, &guest.Pronouns, &guest.DoNotDisturbStart, - &guest.DoNotDisturbEnd, &guest.HousekeepingCadence, &guest.Assistance, + &guest.Preferences, &guest.Notes, &guest.Pronouns, &doNotDisturbStart, + &doNotDisturbEnd, &guest.HousekeepingCadence, &assistanceRaw, ) if err != nil { if errors.Is(err, pgx.ErrNoRows) { @@ -105,6 +114,17 @@ func (r *GuestsRepository) FindGuestWithStayHistory(ctx context.Context, id stri return nil, err } + guest.DoNotDisturbStart = formatPGTime(doNotDisturbStart) + guest.DoNotDisturbEnd = formatPGTime(doNotDisturbEnd) + + if len(assistanceRaw) > 0 { + var assistance *models.Assistance + if err := json.Unmarshal(assistanceRaw, &assistance); err != nil { + return nil, err + } + guest.Assistance = assistance + } + rows, err := r.db.Query(ctx, ` SELECT gb.arrival_date, gb.departure_date, rm.room_number, gb.status, gb.group_size FROM guest_bookings gb @@ -116,35 +136,53 @@ func (r *GuestsRepository) FindGuestWithStayHistory(ctx context.Context, id stri } defer rows.Close() + if err := loadGuestStayHistory(guest, rows); err != nil { + return nil, err + } + + sortGuestStays(guest) + + return guest, rows.Err() +} + +func loadGuestStayHistory(guest *models.GuestWithStays, rows pgx.Rows) error { for rows.Next() { - var arrivalDate, departureDate *time.Time - var roomNumber, groupSize *int - var status *models.BookingStatus + var arrivalDate, departureDate pgtype.Date + var roomNumber, groupSize pgtype.Int4 + var status string if err := rows.Scan(&arrivalDate, &departureDate, &roomNumber, &status, &groupSize); err != nil { - return nil, err + return err } - if arrivalDate == nil { + if !arrivalDate.Valid || !departureDate.Valid || !roomNumber.Valid || status == "" { continue } - stay := buildStay(arrivalDate, departureDate, roomNumber, groupSize, status) - guest = appendStay(guest, stay, *status) + stayStatus := models.BookingStatus(status) + stay := buildStay(arrivalDate, departureDate, roomNumber, groupSize, stayStatus) + appendStay(guest, stay, stayStatus) } - return guest, rows.Err() + return rows.Err() } -func buildStay(arrival, departure *time.Time, roomNumber, groupSize *int, status *models.BookingStatus) models.Stay { +func buildStay( + arrival pgtype.Date, + departure pgtype.Date, + roomNumber pgtype.Int4, + groupSize pgtype.Int4, + status models.BookingStatus, +) models.Stay { stay := models.Stay{ - ArrivalDate: *arrival, - DepartureDate: *departure, - RoomNumber: *roomNumber, - Status: *status, + ArrivalDate: arrival.Time, + DepartureDate: departure.Time, + RoomNumber: int(roomNumber.Int32), + Status: status, } - if groupSize != nil { - stay.GroupSize = groupSize + if groupSize.Valid { + value := int(groupSize.Int32) + stay.GroupSize = &value } return stay } @@ -159,6 +197,36 @@ func appendStay(guest *models.GuestWithStays, stay models.Stay, status models.Bo return guest } +func sortGuestStays(guest *models.GuestWithStays) { + sort.Slice(guest.CurrentStays, func(currentStayIndex, otherCurrentStayIndex int) bool { + return guest.CurrentStays[currentStayIndex].ArrivalDate.After( + guest.CurrentStays[otherCurrentStayIndex].ArrivalDate, + ) + }) + + sort.Slice(guest.PastStays, func(pastStayIndex, otherPastStayIndex int) bool { + return guest.PastStays[pastStayIndex].DepartureDate.After( + guest.PastStays[otherPastStayIndex].DepartureDate, + ) + }) +} + +func formatPGTime(value pgtype.Time) *string { + if !value.Valid { + return nil + } + + duration := time.Duration(value.Microseconds) * time.Microsecond + hours := int(duration / time.Hour) + duration -= time.Duration(hours) * time.Hour + minutes := int(duration / time.Minute) + duration -= time.Duration(minutes) * time.Minute + seconds := int(duration / time.Second) + + formatted := fmt.Sprintf("%02d:%02d:%02d", hours, minutes, seconds) + return &formatted +} + func (r *GuestsRepository) UpdateGuest(ctx context.Context, id string, update *models.UpdateGuest) (*models.Guest, error) { var guest models.Guest diff --git a/backend/internal/repository/guests_test.go b/backend/internal/repository/guests_test.go new file mode 100644 index 00000000..f403ad75 --- /dev/null +++ b/backend/internal/repository/guests_test.go @@ -0,0 +1,52 @@ +package repository + +import ( + "testing" + "time" + + "github.com/generate/selfserve/internal/models" + "github.com/stretchr/testify/assert" +) + +func TestSortGuestStays(t *testing.T) { + t.Parallel() + + base := time.Date(2026, time.April, 4, 12, 0, 0, 0, time.UTC) + guest := &models.GuestWithStays{ + CurrentStays: []models.Stay{ + { + ArrivalDate: base.Add(-48 * time.Hour), + DepartureDate: base.Add(24 * time.Hour), + RoomNumber: 101, + Status: models.BookingStatusActive, + }, + { + ArrivalDate: base.Add(-24 * time.Hour), + DepartureDate: base.Add(48 * time.Hour), + RoomNumber: 202, + Status: models.BookingStatusActive, + }, + }, + PastStays: []models.Stay{ + { + ArrivalDate: base.Add(-240 * time.Hour), + DepartureDate: base.Add(-168 * time.Hour), + RoomNumber: 303, + Status: models.BookingStatusInactive, + }, + { + ArrivalDate: base.Add(-120 * time.Hour), + DepartureDate: base.Add(-72 * time.Hour), + RoomNumber: 404, + Status: models.BookingStatusInactive, + }, + }, + } + + sortGuestStays(guest) + + assert.Equal(t, 202, guest.CurrentStays[0].RoomNumber) + assert.Equal(t, 101, guest.CurrentStays[1].RoomNumber) + assert.Equal(t, 404, guest.PastStays[0].RoomNumber) + assert.Equal(t, 303, guest.PastStays[1].RoomNumber) +} diff --git a/clients/shared/src/index.ts b/clients/shared/src/index.ts index bc33672c..86591f98 100644 --- a/clients/shared/src/index.ts +++ b/clients/shared/src/index.ts @@ -51,14 +51,19 @@ export { usePostApiV1Guests, useGetApiV1GuestsId, usePutApiV1GuestsId, + usePostApiV1GuestsSearchHook, + useGetGuestsStaysId, } from "./api/generated/endpoints/guests/guests"; -export { useGetGuestBookingsGroupSizes } from "./api/generated/endpoints/guest-bookings/guest-bookings"; +export type { + GuestWithBooking, + GuestWithStays, + GuestFilters, + Stay, +} from "./api/generated/models"; -export { - usePostRooms, - useGetRoomsFloors, -} from "./api/generated/endpoints/rooms/rooms"; +export { usePostRooms, useGetRoomsFloors } from "./api/generated/endpoints/rooms/rooms"; +export { useGetGuestBookingsGroupSizes } from "./api/generated/endpoints/guest-bookings/guest-bookings"; export type { RoomWithOptionalGuestBooking, diff --git a/clients/web/src/components/guests/GuestNotesCard.tsx b/clients/web/src/components/guests/GuestNotesCard.tsx index 1e4846c2..7f55074f 100644 --- a/clients/web/src/components/guests/GuestNotesCard.tsx +++ b/clients/web/src/components/guests/GuestNotesCard.tsx @@ -1,13 +1,19 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; type GuestNotesCardProps = { - initialNotes: string; + notes?: string; + onSave: (nextNotes: string) => Promise | void; }; -export function GuestNotesCard({ initialNotes }: GuestNotesCardProps) { +export function GuestNotesCard({ notes = "", onSave }: GuestNotesCardProps) { const [isEditing, setIsEditing] = useState(false); - const [notes, setNotes] = useState(initialNotes); - const [draft, setDraft] = useState(initialNotes); + const [draft, setDraft] = useState(notes); + + useEffect(() => { + if (!isEditing) { + setDraft(notes); + } + }, [isEditing, notes]); const startEditing = () => { setDraft(notes); @@ -19,8 +25,8 @@ export function GuestNotesCard({ initialNotes }: GuestNotesCardProps) { setIsEditing(false); }; - const saveNotes = () => { - setNotes(draft); + const saveNotes = async () => { + await onSave(draft); setIsEditing(false); }; @@ -33,7 +39,7 @@ export function GuestNotesCard({ initialNotes }: GuestNotesCardProps) { @@ -61,7 +67,7 @@ export function GuestNotesCard({ initialNotes }: GuestNotesCardProps) { diff --git a/clients/web/src/components/guests/GuestProfileCard.tsx b/clients/web/src/components/guests/GuestProfileCard.tsx index b1460b32..ecf4f73f 100644 --- a/clients/web/src/components/guests/GuestProfileCard.tsx +++ b/clients/web/src/components/guests/GuestProfileCard.tsx @@ -1,20 +1,27 @@ 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 }) { return (
-

{label}

+

{label}

{value}

); } export function GuestProfileCard({ guest }: GuestProfileCardProps) { + const hasCurrentStay = guest.current_stays.length > 0; + const currentStay = guest.current_stays[0]; + const phone = guest.phone?.trim() || "-"; + const email = guest.email?.trim() || "-"; + const pronouns = guest.pronouns?.trim() || "-"; + return (
@@ -23,28 +30,38 @@ 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/GuestProfilePageSkeleton.tsx b/clients/web/src/components/guests/GuestProfilePageSkeleton.tsx new file mode 100644 index 00000000..a7436c49 --- /dev/null +++ b/clients/web/src/components/guests/GuestProfilePageSkeleton.tsx @@ -0,0 +1,36 @@ +import { Skeleton } from "@/components/ui/skeleton"; + +export function GuestProfilePageSkeleton() { + return ( +
+
+
+ +
+ + +
+
+
+ + + +
+
+ +
+
+ + +
+
+ +
+ + +
+
+
+
+ ); +} diff --git a/clients/web/src/components/guests/GuestQuickListTable.tsx b/clients/web/src/components/guests/GuestQuickListTable.tsx index 3157e94b..cea67f81 100644 --- a/clients/web/src/components/guests/GuestQuickListTable.tsx +++ b/clients/web/src/components/guests/GuestQuickListTable.tsx @@ -1,10 +1,13 @@ import { UserRound } from "lucide-react"; -import type { GuestListItem } from "./guest-mocks"; +import type { GuestWithBooking } from "@shared"; type GuestQuickListTableProps = { - guests: Array; + guests: Array; + floorOptions: Array; + groupSizeOptions: Array; groupFilter: string; floorFilter: string; + isLoading?: boolean; onGroupFilterChange: (value: string) => void; onFloorFilterChange: (value: string) => void; onGuestClick: (guestId: string) => void; @@ -20,8 +23,11 @@ function avatarPill() { export function GuestQuickListTable({ guests, + floorOptions, + groupSizeOptions, groupFilter, floorFilter, + isLoading = false, onGroupFilterChange, onFloorFilterChange, onGuestClick, @@ -38,9 +44,11 @@ export function GuestQuickListTable({ aria-label="Group filter" > - - - + {groupSizeOptions.map((size) => ( + + ))}

Room

- {guests.map((guest) => ( - - ))} - {guests.length === 0 && ( + {guests.map((guest) => { + const groupSize = guest.group_size as number | null | undefined; + + return ( + + ); + })} + {!isLoading && guests.length === 0 && (
No guests match your current filters.
diff --git a/clients/web/src/components/guests/GuestSpecialNeedsCard.tsx b/clients/web/src/components/guests/GuestSpecialNeedsCard.tsx index d76ff5cf..640e085c 100644 --- a/clients/web/src/components/guests/GuestSpecialNeedsCard.tsx +++ b/clients/web/src/components/guests/GuestSpecialNeedsCard.tsx @@ -1,15 +1,19 @@ -import type { GuestProfile } from "./guest-mocks"; +type SpecialNeeds = { + dietaryRestrictions: string; + accessibilityNeeds: string; + medicalConditions: string; +}; type GuestSpecialNeedsCardProps = { - specialNeeds: GuestProfile["specialNeeds"]; + specialNeeds: SpecialNeeds; }; function SpecialNeedsRow({ label, value }: { label: string; value: string }) { const displayValue = value.trim().length > 0 ? value : "-"; return (
-

{label}

-

+

{label}

+

{displayValue}

@@ -32,10 +36,6 @@ export function GuestSpecialNeedsCard({ label="Accessibility Needs" value={specialNeeds.accessibilityNeeds} /> - -

{label}

+

{label}

{value}

); diff --git a/clients/web/src/components/guests/PreviousStaysCard.tsx b/clients/web/src/components/guests/PreviousStaysCard.tsx index 2a2c9766..8c0ff591 100644 --- a/clients/web/src/components/guests/PreviousStaysCard.tsx +++ b/clients/web/src/components/guests/PreviousStaysCard.tsx @@ -1,7 +1,8 @@ -import type { PreviousStay } from "./guest-mocks"; +import { formatDate } from "../../utils/dates"; +import type { Stay } from "@shared"; type PreviousStaysCardProps = { - stays: Array; + stays: Array; }; export function PreviousStaysCard({ stays }: PreviousStaysCardProps) { @@ -11,19 +12,21 @@ export function PreviousStaysCard({ stays }: PreviousStaysCardProps) { Previous Stays
- {stays.map((stay) => ( + {stays.map((stay, index) => (

- {stay.startDate} - {stay.endDate} -

-

- {stay.room} | Group size: {stay.groupSize} + {formatDate(stay.arrival_date)} -{" "} + {formatDate(stay.departure_date)}

+

Room {stay.room_number}

))} + {stays.length === 0 && ( +

No previous stays.

+ )}
); 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 ae928ce4..00000000 --- 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/hooks/use-debounce.ts b/clients/web/src/hooks/use-debounce.ts new file mode 100644 index 00000000..7bb0e18e --- /dev/null +++ b/clients/web/src/hooks/use-debounce.ts @@ -0,0 +1,12 @@ +import { useEffect, useState } from "react"; + +export function useDebounce(value: T, delay: number): T { + const [debounced, setDebounced] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => setDebounced(value), delay); + return () => clearTimeout(timer); + }, [value, delay]); + + return debounced; +} diff --git a/clients/web/src/routes/_protected/guests.$guestId.tsx b/clients/web/src/routes/_protected/guests.$guestId.tsx index 42dd4ae9..fdbf80ce 100644 --- a/clients/web/src/routes/_protected/guests.$guestId.tsx +++ b/clients/web/src/routes/_protected/guests.$guestId.tsx @@ -1,11 +1,12 @@ +import { ApiError, useGetGuestsStaysId, usePutApiV1GuestsId } from "@shared"; import { Link, createFileRoute } from "@tanstack/react-router"; import { GuestNotesCard } from "../../components/guests/GuestNotesCard"; import { GuestPageShell } from "../../components/guests/GuestPageShell"; import { GuestProfileCard } from "../../components/guests/GuestProfileCard"; +import { GuestProfilePageSkeleton } from "../../components/guests/GuestProfilePageSkeleton"; import { GuestSpecialNeedsCard } from "../../components/guests/GuestSpecialNeedsCard"; import { HousekeepingPreferencesCard } from "../../components/guests/HousekeepingPreferencesCard"; import { PreviousStaysCard } from "../../components/guests/PreviousStaysCard"; -import { guestProfilesById } from "../../components/guests/guest-mocks"; export const Route = createFileRoute("/_protected/guests/$guestId")({ component: GuestProfilePage, @@ -13,16 +14,45 @@ export const Route = createFileRoute("/_protected/guests/$guestId")({ function GuestProfilePage() { const { guestId } = Route.useParams(); - const guestProfile = guestProfilesById[guestId]; + const { + data: guest, + isLoading, + isError, + error, + refetch, + } = useGetGuestsStaysId(guestId); + const updateGuest = usePutApiV1GuestsId(); - if (!guestProfile) { + const handleSaveNotes = async (notes: string) => { + await updateGuest.mutateAsync({ + id: guestId, + data: { notes }, + }); + + await refetch(); + }; + + const detailErrorMessage = + error instanceof ApiError && error.status !== 404 + ? "Failed to load guest profile." + : "Guest not found."; + + if (isLoading) { + return ( + + + + ); + } + + if (isError || !guest) { return (
-

Guest not found.

+

{detailErrorMessage}

Return to guest list @@ -31,19 +61,30 @@ function GuestProfilePage() { ); } + const specialNeeds = { + dietaryRestrictions: guest.assistance?.dietary?.join(", ") ?? "", + accessibilityNeeds: guest.assistance?.accessibility?.join(", ") ?? "", + medicalConditions: guest.assistance?.medical?.join(", ") ?? "", + }; + const housekeeping = { + frequency: guest.housekeeping_cadence?.trim() || "-", + doNotDisturb: + guest.do_not_disturb_start && guest.do_not_disturb_end + ? `${guest.do_not_disturb_start} - ${guest.do_not_disturb_end}` + : "-", + }; + return (
- - + +
- - - + + +
diff --git a/clients/web/src/routes/_protected/guests.index.tsx b/clients/web/src/routes/_protected/guests.index.tsx index 9b6f92cf..73732ff4 100644 --- a/clients/web/src/routes/_protected/guests.index.tsx +++ b/clients/web/src/routes/_protected/guests.index.tsx @@ -1,9 +1,15 @@ +import { + MakeRequestPriority, + useGetGuestBookingsGroupSizes, + useGetRoomsFloors, +} from "@shared"; +import { usePostApiV1GuestsSearchHook } from "@shared/api/generated/endpoints/guests/guests"; +import { useInfiniteQuery } from "@tanstack/react-query"; import { createFileRoute, useNavigate } from "@tanstack/react-router"; -import { useMemo, useState } from "react"; -import { MakeRequestPriority } from "@shared"; +import { useState } from "react"; import { GuestQuickListTable } from "../../components/guests/GuestQuickListTable"; import { GuestSearchBar } from "../../components/guests/GuestSearchBar"; -import { guestListItems } from "../../components/guests/guest-mocks"; +import { useDebounce } from "../../hooks/use-debounce"; import type { Request } from "@shared"; import { PageShell } from "@/components/ui/PageShell"; import { GlobalTaskInput } from "@/components/ui/GlobalTaskInput"; @@ -25,30 +31,74 @@ function GuestsQuickListPage() { room_id?: string; } | null>(null); - const filteredGuests = useMemo(() => { - const query = searchTerm.trim().toLowerCase(); + const debouncedSearch = useDebounce(searchTerm, 300); + const postGuests = usePostApiV1GuestsSearchHook(); + const { data: floorsData } = useGetRoomsFloors(); + const { data: groupSizesData } = useGetGuestBookingsGroupSizes(); - return guestListItems.filter((guest) => { - const matchesSearch = - query.length === 0 || - guest.governmentName.toLowerCase().includes(query) || - guest.preferredName.toLowerCase().includes(query) || - guest.room.toLowerCase().includes(query); + const availableFloors = floorsData ?? []; + const availableGroupSizes = groupSizesData ?? []; - const matchesGroup = - groupFilter === "all" || - (groupFilter === "1-2" && guest.groupSize <= 2) || - (groupFilter === "3-4" && - guest.groupSize >= 3 && - guest.groupSize <= 4) || - (groupFilter === "5+" && guest.groupSize >= 5); + const { data, fetchNextPage, hasNextPage, isFetching, isLoading, isError } = + useInfiniteQuery({ + queryKey: ["guests", debouncedSearch, floorFilter, groupFilter], + queryFn: ({ pageParam }: { pageParam: string | undefined }) => + postGuests({ + search: debouncedSearch || undefined, + floors: floorFilter !== "all" ? [Number(floorFilter)] : undefined, + group_size: groupFilter !== "all" ? [Number(groupFilter)] : undefined, + cursor: pageParam, + limit: 20, + }), + initialPageParam: undefined as string | undefined, + getNextPageParam: (lastPage) => lastPage.next_cursor ?? undefined, + }); - const matchesFloor = - floorFilter === "all" || guest.floor === Number(floorFilter); + const allGuests = data?.pages.flatMap((page) => page.data ?? []) ?? []; - return matchesSearch && matchesGroup && matchesFloor; - }); - }, [floorFilter, groupFilter, searchTerm]); + let guestsContent; + if (isError) { + guestsContent = ( +
+ Failed to load guests. Please try again. +
+ ); + } else { + guestsContent = ( + <> + + navigate({ to: "/guests/$guestId", params: { guestId } }) + } + /> + + {isLoading && ( +
+ Loading guests... +
+ )} + + {hasNextPage && !isLoading && ( + + )} + + ); + } return ( - - navigate({ to: "/guests/$guestId", params: { guestId } }) - } - /> + {guestsContent} {generatedData === null && ( { 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 00000000..077472bf --- /dev/null +++ b/clients/web/src/tests/guests-ui.test.tsx @@ -0,0 +1,133 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { GuestProfilePageSkeleton } from "../components/guests/GuestProfilePageSkeleton"; +import { GuestProfileCard } from "../components/guests/GuestProfileCard"; +import { GuestQuickListTable } from "../components/guests/GuestQuickListTable"; +import { formatDate } from "../utils/dates"; +import type { GuestWithBooking } from "@shared"; + +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( + {}} + onFloorFilterChange={() => {}} + onGuestClick={() => {}} + />, + ); + + expect(screen.queryByText("No guests match your current filters.")).toBe( + null, + ); + }); + + it("renders backend-provided filter options", () => { + render( + {}} + onFloorFilterChange={() => {}} + onGuestClick={() => {}} + />, + ); + + expect(screen.getByRole("option", { name: "2" })).not.toBe(null); + expect(screen.getByRole("option", { name: "6" })).not.toBe(null); + }); + + it("renders an em dash for a null group size", () => { + const guest = { + id: "guest-1", + first_name: "Ada", + last_name: "Lovelace", + preferred_name: "Ada", + floor: 4, + group_size: null as unknown as GuestWithBooking["group_size"], + room_number: 401, + }; + + render( + {}} + onFloorFilterChange={() => {}} + onGuestClick={() => {}} + />, + ); + + expect(screen.getByText("—")).not.toBe(null); + }); + }); + + describe("GuestProfileCard", () => { + it("renders current backend guest fields", () => { + render( + , + ); + + expect(screen.getByText("Jane Doe")).not.toBe(null); + expect(screen.getByText("+1 555 111 2222")).not.toBe(null); + expect(screen.getByText("jane@example.com")).not.toBe(null); + expect(screen.getByText("she/her")).not.toBe(null); + expect(screen.getByText("301")).not.toBe(null); + expect(screen.getByText("2")).not.toBe(null); + }); + }); + + describe("GuestProfilePageSkeleton", () => { + it("renders multiple skeleton placeholders for the profile page", () => { + const { container } = render(); + + expect(container.querySelectorAll('[data-slot="skeleton"]').length).toBe( + 11, + ); + }); + }); +}); diff --git a/clients/web/src/utils/dates.ts b/clients/web/src/utils/dates.ts new file mode 100644 index 00000000..dc1c5fbf --- /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}`; +}