diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index 29b13e05..2d8cfdc5 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 @@ -504,41 +507,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}: @@ -626,6 +624,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 ccaa99ec..31cc6b53 100644 --- a/backend/internal/handler/guests.go +++ b/backend/internal/handler/guests.go @@ -168,7 +168,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 e105336e..a79c3b5a 100644 --- a/backend/internal/models/guests.go +++ b/backend/internal/models/guests.go @@ -24,7 +24,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"` @@ -40,13 +40,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 7cdd6598..3588aca3 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 5f4f8a62..b06ffef5 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/GuestNotesCard.tsx b/clients/web/src/components/guests/GuestNotesCard.tsx index 1e4846c2..c71fec2a 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 b1460b32..0f64a897 100644 --- a/clients/web/src/components/guests/GuestProfileCard.tsx +++ b/clients/web/src/components/guests/GuestProfileCard.tsx @@ -1,8 +1,9 @@ +import type { GuestWithStays } from "@shared"; import { UserRound } from "lucide-react"; -import type { GuestProfile } from "./guest-mocks"; +import { formatDate } from "../../utils/dates"; type GuestProfileCardProps = { - guest: GuestProfile; + guest: GuestWithStays; }; function DetailRow({ label, value }: { label: string; value: string }) { @@ -15,6 +16,8 @@ function DetailRow({ label, value }: { label: string; value: string }) { } export function GuestProfileCard({ guest }: GuestProfileCardProps) { + const currentStay = (guest.current_stays ?? [])[0]; + return (
@@ -23,28 +26,27 @@ export function GuestProfileCard({ guest }: GuestProfileCardProps) {

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

-

{guest.pronouns}

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

No active stay.

+ )}
); diff --git a/clients/web/src/components/guests/GuestQuickListTable.tsx b/clients/web/src/components/guests/GuestQuickListTable.tsx index 3157e94b..a3f395ff 100644 --- a/clients/web/src/components/guests/GuestQuickListTable.tsx +++ b/clients/web/src/components/guests/GuestQuickListTable.tsx @@ -1,8 +1,8 @@ +import type { GuestWithBooking } from "@shared"; import { UserRound } from "lucide-react"; -import type { GuestListItem } from "./guest-mocks"; type GuestQuickListTableProps = { - guests: Array; + guests: Array; groupFilter: string; floorFilter: string; onGroupFilterChange: (value: string) => void; @@ -68,14 +68,14 @@ export function GuestQuickListTable({ > {avatarPill()}

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

- {guest.preferredName} + {guest.preferred_name}

-

{guest.groupSize}

+

{guest.group_size ?? "—"}

{guest.floor}

-

{guest.room}

+

{guest.room_number}

))} {guests.length === 0 && ( diff --git a/clients/web/src/components/guests/GuestSpecialNeedsCard.tsx b/clients/web/src/components/guests/GuestSpecialNeedsCard.tsx index d76ff5cf..a1478e60 100644 --- a/clients/web/src/components/guests/GuestSpecialNeedsCard.tsx +++ b/clients/web/src/components/guests/GuestSpecialNeedsCard.tsx @@ -1,7 +1,12 @@ -import type { GuestProfile } from "./guest-mocks"; +type SpecialNeeds = { + dietaryRestrictions: string; + accessibilityNeeds: string; + sensorySensitivities: string; + medicalConditions: string; +}; type GuestSpecialNeedsCardProps = { - specialNeeds: GuestProfile["specialNeeds"]; + specialNeeds: SpecialNeeds; }; function SpecialNeedsRow({ label, value }: { label: string; value: string }) { diff --git a/clients/web/src/components/guests/HousekeepingPreferencesCard.tsx b/clients/web/src/components/guests/HousekeepingPreferencesCard.tsx index 96609874..418f9c02 100644 --- a/clients/web/src/components/guests/HousekeepingPreferencesCard.tsx +++ b/clients/web/src/components/guests/HousekeepingPreferencesCard.tsx @@ -1,7 +1,10 @@ -import type { GuestProfile } from "./guest-mocks"; +type HousekeepingPreferences = { + frequency: string; + doNotDisturb: string; +}; type HousekeepingPreferencesCardProps = { - housekeeping: GuestProfile["housekeeping"]; + housekeeping: HousekeepingPreferences; }; function PreferenceRow({ label, value }: { label: string; value: string }) { diff --git a/clients/web/src/components/guests/PreviousStaysCard.tsx b/clients/web/src/components/guests/PreviousStaysCard.tsx index 2a2c9766..ea2f2792 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 type { Stay } from "@shared"; +import { formatDate } from "../../utils/dates"; type PreviousStaysCardProps = { - stays: Array; + stays: Array; }; export function PreviousStaysCard({ stays }: PreviousStaysCardProps) { @@ -11,19 +12,20 @@ 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..1ed02d93 100644 --- a/clients/web/src/routes/_protected/guests.$guestId.tsx +++ b/clients/web/src/routes/_protected/guests.$guestId.tsx @@ -1,3 +1,4 @@ +import { useGetApiV1GuestsStaysId } from "@shared/api/generated/endpoints/guests/guests"; import { Link, createFileRoute } from "@tanstack/react-router"; import { GuestNotesCard } from "../../components/guests/GuestNotesCard"; import { GuestPageShell } from "../../components/guests/GuestPageShell"; @@ -5,17 +6,38 @@ import { GuestProfileCard } from "../../components/guests/GuestProfileCard"; 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, }); +const emptySpecialNeeds = { + dietaryRestrictions: "", + accessibilityNeeds: "", + sensorySensitivities: "", + medicalConditions: "", +}; + +const emptyHousekeeping = { + frequency: "", + doNotDisturb: "", +}; + function GuestProfilePage() { const { guestId } = Route.useParams(); - const guestProfile = guestProfilesById[guestId]; + const { data: guest, isLoading, isError } = useGetApiV1GuestsStaysId(guestId); + + if (isLoading) { + return ( + +
+ Loading guest profile... +
+
+ ); + } - if (!guestProfile) { + if (isError || !guest) { return (
@@ -35,15 +57,13 @@ function GuestProfilePage() {
- - + +
- - - + + +
diff --git a/clients/web/src/routes/_protected/guests.index.tsx b/clients/web/src/routes/_protected/guests.index.tsx index 53a83aa8..ab7f05d4 100644 --- a/clients/web/src/routes/_protected/guests.index.tsx +++ b/clients/web/src/routes/_protected/guests.index.tsx @@ -1,58 +1,89 @@ +import { usePostApiV1GuestsSearchHook } from "@shared/api/generated/endpoints/guests/guests"; import { createFileRoute, useNavigate } from "@tanstack/react-router"; -import { useMemo, useState } from "react"; +import { useInfiniteQuery } from "@tanstack/react-query"; +import { useState } from "react"; import { GuestPageShell } from "../../components/guests/GuestPageShell"; 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"; export const Route = createFileRoute("/_protected/guests/")({ component: GuestsQuickListPage, }); +function groupSizeFilter(filter: string): number[] | undefined { + if (filter === "1-2") return [1, 2]; + if (filter === "3-4") return [3, 4]; + if (filter === "5+") + return [5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]; + return undefined; +} + function GuestsQuickListPage() { const navigate = useNavigate(); const [searchTerm, setSearchTerm] = useState(""); const [groupFilter, setGroupFilter] = useState("all"); const [floorFilter, setFloorFilter] = useState("all"); - const filteredGuests = useMemo(() => { - const query = searchTerm.trim().toLowerCase(); - - 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 matchesGroup = - groupFilter === "all" || - (groupFilter === "1-2" && guest.groupSize <= 2) || - (groupFilter === "3-4" && - guest.groupSize >= 3 && - guest.groupSize <= 4) || - (groupFilter === "5+" && guest.groupSize >= 5); - - const matchesFloor = - floorFilter === "all" || guest.floor === Number(floorFilter); + const debouncedSearch = useDebounce(searchTerm, 300); + const postGuests = usePostApiV1GuestsSearchHook(); - return matchesSearch && matchesGroup && matchesFloor; + 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: groupSizeFilter(groupFilter), + cursor: pageParam, + limit: 20, + }), + initialPageParam: undefined as string | undefined, + getNextPageParam: (lastPage) => lastPage.next_cursor ?? undefined, }); - }, [floorFilter, groupFilter, searchTerm]); + + const allGuests = data?.pages.flatMap((page) => page.data ?? []) ?? []; return ( - - navigate({ to: "/guests/$guestId", params: { guestId } }) - } - /> + + {isError ? ( +
+ Failed to load guests. Please try again. +
+ ) : ( + <> + + navigate({ to: "/guests/$guestId", params: { guestId } }) + } + /> + + {isLoading && ( +
+ Loading guests... +
+ )} + + {hasNextPage && !isLoading && ( + + )} + + )}
); } diff --git a/clients/web/src/utils/dates.ts b/clients/web/src/utils/dates.ts new file mode 100644 index 00000000..dbe47441 --- /dev/null +++ b/clients/web/src/utils/dates.ts @@ -0,0 +1,7 @@ +export function formatDate(iso: string): string { + return new Date(iso).toLocaleDateString("en-US", { + month: "2-digit", + day: "2-digit", + year: "numeric", + }); +}