diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index bbf2d413c..7f77ec810 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -1,5 +1,20 @@ basePath: /api/v1 definitions: + Assistance: + properties: + accessibility: + items: + type: string + type: array + dietary: + items: + type: string + type: array + medical: + items: + type: string + type: array + type: object CreateGuest: properties: first_name: @@ -98,6 +113,9 @@ definitions: last_name: example: Doe type: string + notes: + example: VIP guest + type: string profile_picture: example: https://example.com/john.jpg type: string @@ -108,25 +126,6 @@ definitions: example: "2024-01-02T00:00:00Z" type: string type: object - GuestBooking: - properties: - arrival_date: - type: string - departure_date: - type: string - guest: - $ref: '#/definitions/Guest' - hotel_id: - example: 521e8400-e458-41d4-a716-446655440000 - type: string - id: - example: f353ca91-4fc5-49f2-9b9e-304f83d11914 - type: string - room: - $ref: '#/definitions/Room' - status: - $ref: '#/definitions/github_com_generate_selfserve_internal_models.BookingStatus' - type: object GuestFilters: properties: cursor: @@ -159,6 +158,31 @@ definitions: next_cursor: type: string type: object + GuestRequest: + properties: + created_at: + type: string + description: + type: string + id: + type: string + name: + type: string + notes: + type: string + priority: + type: string + request_category: + type: string + request_type: + type: string + request_version: + type: string + room_number: + type: integer + status: + type: string + type: object GuestWithBooking: properties: first_name: @@ -178,23 +202,34 @@ definitions: required: - first_name - floor + - group_size - id - last_name - - preferred_name - room_number type: object GuestWithStays: properties: + assistance: + $ref: '#/definitions/Assistance' current_stays: items: $ref: '#/definitions/Stay' type: array + do_not_disturb_end: + example: "07:00:00" + type: string + do_not_disturb_start: + example: "17:00:00" + type: string email: example: jane.doe@example.com type: string first_name: example: Jane type: string + housekeeping_cadence: + example: daily + type: string id: example: 530e8400-e458-41d4-a716-446655440000 type: string @@ -214,6 +249,9 @@ definitions: preferences: example: extra pillows type: string + pronouns: + example: she/her + type: string required: - current_stays - first_name @@ -267,6 +305,12 @@ definitions: example: No special requests type: string priority: + enum: + - low + - medium + - normal + - high + - urgent example: urgent type: string request_category: @@ -285,6 +329,10 @@ definitions: example: "2024-01-01T00:00:00Z" type: string status: + enum: + - pending + - assigned + - completed example: assigned type: string user_id: @@ -324,6 +372,12 @@ definitions: example: No special requests type: string priority: + enum: + - low + - medium + - normal + - high + - urgent example: urgent type: string request_category: @@ -345,23 +399,16 @@ definitions: example: "2024-01-01T00:00:00Z" type: string status: + enum: + - pending + - assigned + - completed example: assigned type: string user_id: example: 521ee400-e458-41d4-a716-446655440000 type: string type: object - Room: - properties: - floor: - type: integer - room_number: - type: integer - room_status: - type: string - suite_type: - type: string - type: object RoomWithOptionalGuestBooking: properties: booking_status: @@ -387,6 +434,8 @@ definitions: departure_date: example: "2024-01-05" type: string + group_size: + type: integer room_number: example: 101 type: integer @@ -406,6 +455,10 @@ definitions: last_name: example: Doe type: string + notes: + example: VIP guest + maxLength: 1000 + type: string profile_picture: example: https://example.com/john.jpg type: string @@ -467,34 +520,6 @@ definitions: type: integer message: {} type: object - github_com_generate_selfserve_internal_models.CreateGuest: - properties: - first_name: - example: Jane - type: string - last_name: - example: Doe - type: string - profile_picture: - example: https://example.com/john.jpg - type: string - timezone: - example: America/New_York - type: string - type: object - github_com_generate_selfserve_internal_models.UpdateGuest: - properties: - first_name: - example: Jane - type: string - last_name: - example: Doe - type: string - profile_picture: - example: https://example.com/john.jpg - type: string - timezone: - example: America/New_York github_com_generate_selfserve_internal_models.BookingStatus: enum: - active @@ -623,7 +648,7 @@ paths: name: request required: true schema: - $ref: '#/definitions/github_com_generate_selfserve_internal_models.UpdateGuest' + $ref: '#/definitions/UpdateGuest' produces: - application/json responses: @@ -654,45 +679,6 @@ paths: summary: Updates a guest 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: @@ -795,14 +781,36 @@ paths: summary: Get developer member tags: - devs - /guest_bookings/floor: + /guest_bookings/group_sizes: get: - description: Retrieves multiple guest bookings whose booked rooms are in the - provided floors array + description: Retrieves all distinct group sizes across guest bookings + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + type: integer + type: array + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + summary: Get available group size options + tags: + - guest-bookings + /guests/stays/{id}: + get: + consumes: + - application/json + description: Retrieves a single guest with previous stays given an id parameters: - - description: Comma-separated floor numbers - in: query - name: floors + - description: Guest ID (UUID) + in: path + name: id required: true type: string produces: @@ -811,24 +819,28 @@ paths: "200": description: OK schema: - items: - $ref: '#/definitions/GuestBooking' - type: array + $ref: '#/definitions/GuestWithStays' "400": - description: Bad Request + 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 + description: Internal server error schema: additionalProperties: type: string type: object - summary: Get guest Bookings By Floor + security: + - BearerAuth: [] + summary: Gets a guest with previous stays tags: - - guest-bookings + - guests /hello: get: consumes: @@ -987,6 +999,41 @@ paths: summary: generates a request tags: - requests + /request/guest/{id}: + get: + description: Retrieves all requests for a given guest + parameters: + - description: Guest ID (UUID) + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/GuestRequest' + type: array + "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 requests by guest + tags: + - requests /rooms: post: consumes: diff --git a/backend/internal/handler/bookings.go b/backend/internal/handler/bookings.go index 236d2d129..c4fe1cc5b 100644 --- a/backend/internal/handler/bookings.go +++ b/backend/internal/handler/bookings.go @@ -2,16 +2,13 @@ package handler import ( "context" - "strconv" - "strings" "github.com/generate/selfserve/internal/errs" - "github.com/generate/selfserve/internal/models" "github.com/gofiber/fiber/v2" ) type GuestBookingsRepository interface { - FindBookingByFloor(ctx context.Context, floors []int) ([]*models.GuestBooking, error) + FindGroupSizeOptions(ctx context.Context, hotelID string) ([]int, error) } type GuestBookingHandler struct { @@ -22,44 +19,24 @@ func NewGuestBookingsHandler(repo GuestBookingsRepository) *GuestBookingHandler return &GuestBookingHandler{repo: repo} } -// GetBookingsByFloor godoc -// @Summary Get guest Bookings By Floor -// @Description Retrieves multiple guest bookings whose booked rooms are in the provided floors array +// GetGroupSizeOptions godoc +// @Summary Get available group size options +// @Description Retrieves all distinct group sizes across guest bookings // @Tags guest-bookings // @Produce json -// @Param floors query string true "Comma-separated floor numbers" -// @Success 200 {object} []models.GuestBooking -// @Failure 400 {object} map[string]string -// @Failure 500 {object} map[string]string -// @Router /guest_bookings/floor [get] -func (h *GuestBookingHandler) GetBookingsByFloor(c *fiber.Ctx) error { - floors, err := getQueryFloors(c.Query("floors")) - if err != nil { - return err +// @Success 200 {object} []int +// @Failure 500 {object} map[string]string +// @Router /guest_bookings/group_sizes [get] +func (h *GuestBookingHandler) GetGroupSizeOptions(c *fiber.Ctx) error { + hotelID := c.Get("X-Hotel-ID") + if hotelID == "" { + return errs.BadRequest("X-Hotel-ID header is required") } - bookings, err := h.repo.FindBookingByFloor(c.Context(), floors) - + sizes, err := h.repo.FindGroupSizeOptions(c.Context(), hotelID) if err != nil { return errs.InternalServerError() } - return c.JSON(bookings) -} - -func getQueryFloors(rawFloors string) ([]int, error) { - if rawFloors == "" { - return nil, errs.BadRequest("Floors must be provided") - } - - parts := strings.Split(rawFloors, ",") - floors := make([]int, 0, len(parts)) - for _, p := range parts { - floor, err := strconv.Atoi(strings.TrimSpace(p)) - if err != nil { - return nil, errs.BadRequest("Floors must be an array of integers") - } - floors = append(floors, floor) - } - return floors, nil + return c.JSON(sizes) } diff --git a/backend/internal/handler/guests.go b/backend/internal/handler/guests.go index eb37364f3..a965acac4 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 /api/v1/guests/stays/{id} [get] +// @Router /guests/stays/{id} [get] func (h *GuestsHandler) GetGuestWithStays(c *fiber.Ctx) error { id := c.Params("id") if !validUUID(id) { diff --git a/backend/internal/models/guests.go b/backend/internal/models/guests.go index 748736357..f72b1a177 100644 --- a/backend/internal/models/guests.go +++ b/backend/internal/models/guests.go @@ -65,13 +65,13 @@ type GuestPage struct { } // @name GuestPage type GuestWithBooking struct { - ID string `json:"id"` - FirstName string `json:"first_name"` - LastName string `json:"last_name"` + 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"` - RoomNumber int `json:"room_number"` - GroupSize *int `json:"group_size"` + Floor int `json:"floor" validate:"required"` + RoomNumber int `json:"room_number" validate:"required"` + GroupSize *int `json:"group_size" validate:"required"` } // @name GuestWithBooking type GuestWithStays struct { diff --git a/backend/internal/repository/guest-bookings.go b/backend/internal/repository/guest-bookings.go index bf596cfe0..8d2151793 100644 --- a/backend/internal/repository/guest-bookings.go +++ b/backend/internal/repository/guest-bookings.go @@ -1,6 +1,8 @@ package repository import ( + "context" + "github.com/jackc/pgx/v5/pgxpool" ) @@ -11,3 +13,28 @@ type GuestBookingsRepository struct { func NewGuestBookingsRepository(db *pgxpool.Pool) *GuestBookingsRepository { return &GuestBookingsRepository{db: db} } + +func (r *GuestBookingsRepository) FindGroupSizeOptions(ctx context.Context, hotelID string) ([]int, error) { + rows, err := r.db.Query(ctx, ` + SELECT DISTINCT group_size + FROM guest_bookings + WHERE hotel_id = $1 + AND group_size IS NOT NULL + ORDER BY group_size ASC + `, hotelID) + if err != nil { + return nil, err + } + defer rows.Close() + + var sizes []int + for rows.Next() { + var size int + if err := rows.Scan(&size); err != nil { + return nil, err + } + sizes = append(sizes, size) + } + + return sizes, rows.Err() +} diff --git a/backend/internal/service/server.go b/backend/internal/service/server.go index 49789dd4b..2cf506829 100644 --- a/backend/internal/service/server.go +++ b/backend/internal/service/server.go @@ -136,6 +136,7 @@ func setupRoutes(app *fiber.App, repo *storage.Repository, genkitInstance *aiflo hotelsHandler := handler.NewHotelsHandler(repository.NewHotelsRepository(repo.DB)) s3Handler := handler.NewS3Handler(s3Store) roomsHandler := handler.NewRoomsHandler(repository.NewRoomsRepository(repo.DB)) + guestBookingsHandler := handler.NewGuestBookingsHandler(repository.NewGuestBookingsRepository(repo.DB)) clerkWhSignatureVerifier, err := handler.NewWebhookVerifier(cfg) if err != nil { @@ -203,6 +204,11 @@ func setupRoutes(app *fiber.App, repo *storage.Repository, genkitInstance *aiflo r.Get("/floors", roomsHandler.GetFloors) }) + // guest booking routes + api.Route("/guest_bookings", func(r fiber.Router) { + r.Get("/group_sizes", guestBookingsHandler.GetGroupSizeOptions) + }) + // s3 routes api.Route("/s3", func(r fiber.Router) { r.Get("/presigned-url/:key", s3Handler.GeneratePresignedURL) diff --git a/clients/mobile/app/(tabs)/guests/[id].tsx b/clients/mobile/app/(tabs)/guests/[id].tsx index 080dbff4e..04b148704 100644 --- a/clients/mobile/app/(tabs)/guests/[id].tsx +++ b/clients/mobile/app/(tabs)/guests/[id].tsx @@ -1,27 +1,81 @@ -import { useLocalSearchParams } from "expo-router"; -import { Text } from "react-native"; -import GuestProfile from "@/components/ui/guest-profile"; -import { useGetApiV1GuestsStaysId } from "@shared/api/generated/endpoints/guests/guests"; +import { useState } from "react"; +import { + View, + Text, + Pressable, + ScrollView, + ActivityIndicator, +} from "react-native"; +import { Info, ChevronRight } from "lucide-react-native"; +import { router, useLocalSearchParams } from "expo-router"; +import { useGetGuestsStaysId } from "@shared/api/generated/endpoints/guests/guests"; +import { GuestHeader, Tab } from "@/components/ui/guest-header"; +import { GuestProfileTab } from "@/components/ui/guest-profile"; +import { GuestActivityTab } from "@/components/ui/guest-activity"; +import { Colors } from "@/constants/theme"; -export default function ProfileScreen() { +export default function GuestProfileScreen() { const { id } = useLocalSearchParams(); + const { data, isLoading } = useGetGuestsStaysId(id as string); + const [activeTab, setActiveTab] = useState("profile"); - const query = useGetApiV1GuestsStaysId(id as string); + if (isLoading) + return ( + + + + ); - if (!query || !query.data) { - return Guest not found; - } + if (!data) + return ( + + Guest not found + + ); return ( - + + + + + {activeTab === "profile" ? ( + + ) : ( + + router.push(`/guests/booking-history?id=${id}`) + } + /> + )} + + + ); +} + +function WaitingRequestsBanner({ name }: { name: string }) { + return ( + + + + + {name} is waiting on requests + + + + ); } diff --git a/clients/mobile/app/(tabs)/guests/booking-history.tsx b/clients/mobile/app/(tabs)/guests/booking-history.tsx new file mode 100644 index 000000000..29e4f78db --- /dev/null +++ b/clients/mobile/app/(tabs)/guests/booking-history.tsx @@ -0,0 +1,103 @@ +import { View, Text, Pressable, SectionList } from "react-native"; +import { ChevronLeft } from "lucide-react-native"; +import { router, useLocalSearchParams } from "expo-router"; +import { Colors } from "@/constants/theme"; +import { useGetGuestsStaysId } from "@shared/api/generated/endpoints/guests/guests"; +import type { Stay } from "@shared/api/generated/models"; + +export default function BookingHistoryScreen() { + const { id } = useLocalSearchParams(); + const { data, isLoading } = useGetGuestsStaysId(id as string); + + const currentStays = data?.current_stays ?? []; + const pastStays = data?.past_stays ?? []; + + const grouped = pastStays.reduce>((acc, stay) => { + const year = new Date(stay.arrival_date).getFullYear().toString(); + if (!acc[year]) acc[year] = []; + acc[year].push(stay); + return acc; + }, {}); + + const pastSections = Object.entries(grouped) + .sort(([a], [b]) => Number(b) - Number(a)) + .map(([year, data]) => ({ title: year, data, active: false })); + + const sections = [ + ...(currentStays.length > 0 + ? [{ title: "Active", data: currentStays, active: true }] + : []), + ...pastSections, + ]; + + return ( + + + router.back()}> + + + + All Bookings + + + + + {isLoading ? ( + + Loading... + + ) : ( + i.toString()} + contentContainerStyle={{ padding: 16, gap: 8 }} + renderSectionHeader={({ section }) => ( + + {section.title} + + )} + renderItem={({ item, section }) => ( + + )} + /> + )} + + ); +} + +function BookingCard({ stay, isActive }: { stay: Stay; isActive: boolean }) { + const fmt = (d: string | Date) => + new Date(d).toLocaleDateString("en-US", { + month: "2-digit", + day: "2-digit", + year: "numeric", + }); + + return ( + + + + Room {stay.room_number} + + + {stay.status} + + + + {fmt(stay.arrival_date)} - {fmt(stay.departure_date)} + + + ); +} diff --git a/clients/mobile/app/(tabs)/guests/index.tsx b/clients/mobile/app/(tabs)/guests/index.tsx index a51aba823..6839ec13e 100644 --- a/clients/mobile/app/(tabs)/guests/index.tsx +++ b/clients/mobile/app/(tabs)/guests/index.tsx @@ -1,33 +1,43 @@ -import React, { useState } from "react"; +import { useState } from "react"; import { View, FlatList, ActivityIndicator } from "react-native"; import { Header } from "@/components/ui/header"; -import { SearchBar } from "@/components/ui/search-bar"; -import { Filter, Filters } from "@/components/ui/filters"; import { GuestCard } from "@/components/ui/guest-card"; import { router } from "expo-router"; import { useAPIClient } from "@shared/api/client"; import { useInfiniteQuery, InfiniteData } from "@tanstack/react-query"; import { GuestPage } from "@shared/api/generated/models/guestPage"; -import { getFloorConfig } from "./utils"; +import { useGetRoomsFloors, useGetGuestBookingsGroupSizes } from "@shared"; +import { GuestListHeader } from "@/components/ui/guest-list-header"; +import { getFloorConfig, getGroupSizeConfig } from "@/utils"; export default function GuestsList() { const [search, setSearch] = useState(""); const [floors, setFloor] = useState(null); + const [groupSizes, setGroupSize] = useState(null); + + const { data: floorOptions } = useGetRoomsFloors({ + query: { staleTime: Infinity }, + }); + const { data: groupSizeOptions } = useGetGuestBookingsGroupSizes({ + query: { staleTime: Infinity }, + }); const onFloorChange = (floor: number) => { if (floors?.includes(floor)) { - setFloor(floors.filter((elem) => elem !== floor)); + setFloor(floors.filter((f) => f !== floor)); } else { setFloor([...(floors ?? []), floor]); } }; - const handleGuestPress = (guestId: string) => { - router.push(`/guests/${guestId}`); + const onGroupSizeChange = (groupSize: number) => { + if (groupSizes?.includes(groupSize)) { + setGroupSize(groupSizes.filter((g) => g !== groupSize)); + } else { + setGroupSize([...(groupSizes ?? []), groupSize]); + } }; - const filterConfig = getFloorConfig(floors ?? [], onFloorChange); - const api = useAPIClient(); const { data: guestData, @@ -41,10 +51,12 @@ export default function GuestsList() { (string | number[] | null)[], string | undefined >({ - queryKey: ["guests", floors], + queryKey: ["guests", floors, groupSizes, search], queryFn: ({ pageParam }) => - api.post("/api/v1/guests/search", { + api.post("/guests/search", { floors: floors ?? undefined, + group_size: groupSizes ?? undefined, + search: search || undefined, cursor: pageParam, limit: 20, }), @@ -53,16 +65,9 @@ export default function GuestsList() { }); const allGuests = guestData?.pages.flatMap((page) => page.data ?? []) ?? []; - const onEndReached = () => { - if (hasNextPage && !isFetchingNextPage) fetchNextPage(); - }; - const listFooter = isFetchingNextPage ? ( - - ) : null; return ( -
g.id} @@ -72,41 +77,41 @@ export default function GuestsList() { lastName={item.last_name} floor={item.floor} room={item.room_number} - onPress={() => handleGuestPress(item.id)} + onPress={() => router.push(`/guests/${item.id}`)} /> )} - onEndReached={onEndReached} + onEndReached={() => { + if (hasNextPage && !isFetchingNextPage) fetchNextPage(); + }} onEndReachedThreshold={0.3} ListHeaderComponent={ } - ListFooterComponent={listFooter} + ListFooterComponent={ + isFetchingNextPage ? : null + } contentContainerStyle={{ gap: 8 }} className="flex-1" /> ); } - -interface GuestListHeaderProps { - search: string; - setSearch: (s: string) => void; - filterConfig: Filter[]; -} - -function GuestListHeader({ - search, - setSearch, - filterConfig, -}: GuestListHeaderProps) { - return ( - - - - - ); -} diff --git a/clients/mobile/app/(tabs)/guests/utils.ts b/clients/mobile/app/(tabs)/guests/utils.ts deleted file mode 100644 index 4a069e414..000000000 --- a/clients/mobile/app/(tabs)/guests/utils.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Filter } from "@/components/ui/filters"; - -// this will get modified once the getFloors endpoint is completed:: PENDING -export const getFloorConfig = ( - floors: number[], - changeFloor: (f: number) => void, -): Filter[] => { - const filterConfig = [ - { - value: floors, - onChange: changeFloor, - placeholder: "Floor", - options: [ - { label: "Floor 1", value: 1 }, - { label: "Floor 2", value: 2 }, - { label: "Floor 3", value: 3 }, - { label: "Floor 4", value: 4 }, - { label: "Floor 5", value: 5 }, - { label: "Floor 6", value: 6 }, - { label: "Floor 7", value: 7 }, - { label: "Floor 8", value: 8 }, - { label: "Floor 9", value: 9 }, - ], - }, - ]; - return filterConfig; -}; diff --git a/clients/mobile/components/ui/filters.tsx b/clients/mobile/components/ui/filters.tsx index fe40f546e..61b44b383 100644 --- a/clients/mobile/components/ui/filters.tsx +++ b/clients/mobile/components/ui/filters.tsx @@ -24,9 +24,11 @@ export function Filters({ className, }: FiltersProps) { return ( - + {filters.map((filter, index) => ( - + + + ))} ); diff --git a/clients/mobile/components/ui/guest-activity.tsx b/clients/mobile/components/ui/guest-activity.tsx new file mode 100644 index 000000000..aa8bb3633 --- /dev/null +++ b/clients/mobile/components/ui/guest-activity.tsx @@ -0,0 +1,131 @@ +import { View, Text, Pressable } from "react-native"; +import { ChevronRight, Calendar, Clock, Users } from "lucide-react-native"; +import { Colors } from "@/constants/theme"; +import type { Stay } from "@shared/api/generated/models"; + +interface GuestActivityTabProps { + currentStays: Stay[]; + pastStays: Stay[]; + onViewAllBookings: () => void; +} + +export function GuestActivityTab({ + currentStays, + pastStays, + onViewAllBookings, +}: GuestActivityTabProps) { + const currentStay = currentStays?.[0] ?? null; + + return ( + + + + + ); +} + +function BookingsSection({ + currentStay, + onViewAll, +}: { + currentStay: Stay | null; + onViewAll: () => void; +}) { + return ( + + + Bookings + + {currentStay ? ( + + ) : ( + + No active bookings + + )} + + View All + + + + ); +} + +function ActiveBookingCard({ stay }: { stay: Stay }) { + const fmt = (d: string | Date) => + new Date(d).toLocaleDateString("en-US", { + month: "2-digit", + day: "2-digit", + year: "numeric", + }); + const fmtTime = (d: string | Date) => + new Date(d).toLocaleTimeString("en-US", { + hour: "2-digit", + minute: "2-digit", + }); + + return ( + + + + Room {stay.room_number} + + + + + {stay.status} + + + + + Arrival: + + + + {fmt(stay.arrival_date)} + + + + + + {fmtTime(stay.arrival_date)} + + + + + Departure: + + + + {fmt(stay.departure_date)} + + + + + + {fmtTime(stay.departure_date)} + + + + + ); +} + +function RequestsSection() { + return ( + + + Requests (0) + + + No requests on record + + + ); +} diff --git a/clients/mobile/components/ui/guest-card.tsx b/clients/mobile/components/ui/guest-card.tsx index 6c98a9feb..9f38e8682 100644 --- a/clients/mobile/components/ui/guest-card.tsx +++ b/clients/mobile/components/ui/guest-card.tsx @@ -5,6 +5,7 @@ interface GuestCardProps { lastName: string; floor: number; room: number; + groupSize?: number; onPress: () => void; } @@ -13,21 +14,37 @@ export function GuestCard({ lastName, floor, room, + groupSize, onPress, }: GuestCardProps) { return ( - - - {firstName + " " + lastName} - - - - Floor: {floor} Room: {room} + + + {firstName} {lastName} + + + + Floor {floor} + + + + + Room {room} + + + {groupSize !== undefined && ( + + + Group {groupSize} + + + )} + ); diff --git a/clients/mobile/components/ui/guest-header.tsx b/clients/mobile/components/ui/guest-header.tsx new file mode 100644 index 000000000..1f30e3f17 --- /dev/null +++ b/clients/mobile/components/ui/guest-header.tsx @@ -0,0 +1,51 @@ +import { View, Text, Pressable } from "react-native"; +import { ChevronLeft } from "lucide-react-native"; +import { router } from "expo-router"; +import { Colors } from "@/constants/theme"; + +export type Tab = "profile" | "activity"; + +interface GuestHeaderProps { + name: string; + activeTab: Tab; + onTabChange: (t: Tab) => void; +} + +export function GuestHeader({ + name, + activeTab, + onTabChange, +}: GuestHeaderProps) { + return ( + + + router.back()}> + + + + {name} + + + + + {(["profile", "activity"] as Tab[]).map((tab) => ( + onTabChange(tab)} + className={`flex-1 py-[1.5vh] items-center ${ + activeTab === tab ? "bg-success-accent" : "bg-white" + }`} + > + + {tab === "profile" ? "Profile" : "Visit Activity"} + + + ))} + + + ); +} diff --git a/clients/mobile/components/ui/guest-list-header.tsx b/clients/mobile/components/ui/guest-list-header.tsx new file mode 100644 index 000000000..8136ceb4e --- /dev/null +++ b/clients/mobile/components/ui/guest-list-header.tsx @@ -0,0 +1,118 @@ +import { useState, useRef } from "react"; +import { + View, + TouchableOpacity, + Modal, + ScrollView, + Text, + Pressable, +} from "react-native"; +import { SlidersHorizontal, X } from "lucide-react-native"; +import { Colors } from "@/constants/theme"; +import { SearchBar } from "@/components/ui/search-bar"; +import { Filters, Filter } from "@/components/ui/filters"; + +interface GuestListHeaderProps { + search: string; + setSearch: (s: string) => void; + filterConfig: Filter[]; + activeFloors: number[]; + activeGroupSizes: number[]; + onFloorChange: (f: number) => void; + onGroupSizeChange: (g: number) => void; +} + +export function GuestListHeader({ + search, + setSearch, + filterConfig, + activeFloors, + activeGroupSizes, + onFloorChange, + onGroupSizeChange, +}: GuestListHeaderProps) { + const [filtersOpen, setFiltersOpen] = useState(false); + const [dropdownTop, setDropdownTop] = useState(0); + const searchRowRef = useRef(null); + + const onSearchRowLayout = () => { + searchRowRef.current?.measureInWindow((_x, y, _w, h) => { + setDropdownTop(y + h + 4); + }); + }; + + const hasActiveTags = activeFloors.length > 0 || activeGroupSizes.length > 0; + + return ( + + + + + + setFiltersOpen(!filtersOpen)} + className="p-2" + > + + + + + {hasActiveTags && ( + + {activeFloors.map((f) => ( + onFloorChange(f)} + className="flex-row items-center gap-1 bg-card border border-primary rounded-md px-3 py-1" + > + Floor {f} + + + ))} + {activeGroupSizes.map((g) => ( + onGroupSizeChange(g)} + className="flex-row items-center gap-1 bg-card border border-primary rounded-md px-3 py-1" + > + Group Size: {g} + + + ))} + + )} + + setFiltersOpen(false)} + > + setFiltersOpen(false)} + className="flex-1" + > + + + + + + + + + ); +} diff --git a/clients/mobile/components/ui/guest-profile.tsx b/clients/mobile/components/ui/guest-profile.tsx index fe6d9c28b..f0ea0bb96 100755 --- a/clients/mobile/components/ui/guest-profile.tsx +++ b/clients/mobile/components/ui/guest-profile.tsx @@ -1,192 +1,104 @@ -import { View, Text, Pressable } from "react-native"; -import { Box } from "./box"; -import { ChevronLeft } from "lucide-react-native"; -import { Collapsible } from "./collapsible"; -import { router } from "expo-router"; -import type { Stay } from "@shared/api/generated/models"; +import { useState } from "react"; +import { View, Text, TextInput } from "react-native"; +import { Colors } from "@/constants/theme"; -interface GuestProfileProps { +interface GuestProfileTabProps { firstName: string; lastName: string; - phone?: string; - email?: string; - notes?: string; - preferences?: string; - currentStays: Stay[]; - previousStays: Stay[]; + phone?: string | null; + email?: string | null; + notes?: string | null; + preferences?: string | null; + specificAssistance?: string[]; } -export default function GuestProfile(props: GuestProfileProps) { +export function GuestProfileTab(props: GuestProfileTabProps) { return ( - - - - - - - + + + + + ); } -function HeaderWithBackArrow() { - return ( - - router.back()}> - - - - Guest Profile - - - - ); -} +function InfoSection({ + firstName, + lastName, + phone, + email, + preferences, +}: GuestProfileTabProps) { + const fields = [ + { label: "Government Name", value: `${firstName} ${lastName}` }, + { label: "Phone", value: phone }, + { label: "Email", value: email }, + { label: "Preferences", value: preferences }, + ].filter((f) => f.value); -function GuestDescription(props: GuestProfileProps) { return ( - - - - - - {props.firstName + " " + props.lastName} + + {fields.map((field) => ( + + + {field.label} + + + {String(field.value)} - - - - {GUEST_PROFILE_CONFIG.infoFields.map((field, index) => ( - - ))} - - - ); -} - -function InfoRow({ - label, - value, - fieldKey, -}: { - label: string; - value: unknown; - fieldKey: string; -}) { - const isPrimaryValue = fieldKey === "phone" || fieldKey === "email"; - - return ( - - {label} - - {String(value)} - + ))} ); } -function StaysCollapsible({ - title, - stays, - emptyMessage, -}: { - title: string; - stays: Stay[]; - emptyMessage: string; -}) { +function SpecificAssistanceSection({ items }: { items: string[] }) { return ( - - {stays.length === 0 ? ( - - ) : ( - - )} - - ); -} - -function EmptyStaysMessage({ message }: { message: string }) { - return {message}; -} - -function StayList({ stays }: { stays: Stay[] }) { - const displayDate = (arr: string, dep: string) => - new Date(arr).toLocaleDateString() + - " - " + - new Date(dep).toLocaleDateString(); - return ( - - {stays.map((stay, index) => ( - - {stay.room_number} - - {displayDate(stay.arrival_date, stay.departure_date)} + + + Specific Assistance + + + {items.length === 0 ? ( + + None on record - - ))} + ) : ( + items.map((item, i) => ( + + + + {item} + + + {i < items.length - 1 && ( + + )} + + )) + )} + ); } -function GuestInfoCollapsibles(props: GuestProfileProps) { +function NotesSection({ notes }: { notes?: string | null }) { + const [value, setValue] = useState(notes ?? ""); + return ( - - {GUEST_PROFILE_CONFIG.collapsibles.map((item, index) => ( - - {item.format(props)} - - ))} - - + + Notes + + - + ); } - -const GUEST_PROFILE_CONFIG = { - infoFields: [ - { - key: "governmentName", - label: "Government Name", - format: (props: GuestProfileProps) => - props.firstName + " " + props.lastName, - }, - { - key: "phone", - label: "Phone", - format: (props: GuestProfileProps) => props.phone, - }, - { - key: "email", - label: "Email", - format: (props: GuestProfileProps) => props.email, - }, - ], - collapsibles: [ - { - key: "notes", - title: "Notes", - format: (props: GuestProfileProps) => props.notes, - }, - { - key: "preferences", - title: "Housekeeping Preferences", - format: (props: GuestProfileProps) => props.preferences, - }, - ], -}; diff --git a/clients/mobile/components/ui/mutli-select-filter.tsx b/clients/mobile/components/ui/mutli-select-filter.tsx index 1397380b3..214413f7c 100644 --- a/clients/mobile/components/ui/mutli-select-filter.tsx +++ b/clients/mobile/components/ui/mutli-select-filter.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import { View, Text, Pressable, ScrollView } from "react-native"; -import { ChevronDown, X, Check } from "lucide-react-native"; +import { ChevronDown, Check } from "lucide-react-native"; import { Filter } from "./filters"; import { Colors } from "@/constants/theme"; @@ -16,38 +16,32 @@ export function MultiSelectFilter({ placeholder, }: Filter) { const [isOpen, setIsOpen] = useState(false); - const selectedOptions = options.filter((opt) => value?.includes(opt.value)); - const onTriggerPress = () => setIsOpen(!isOpen); return ( setIsOpen(!isOpen)} /> {isOpen && ( - - + + {options.map((option, idx) => { - const isSelected = value?.includes(option.value); - const onPress = () => onChange(option.value); + const isSelected = value?.includes(option.value as T); return ( onChange(option.value)} /> ); })} )} - {selectedOptions.length > 0 && ( - - )} ); } @@ -64,12 +58,14 @@ function FilterTrigger({ return ( - {placeholder} - + + {placeholder} + + ); } @@ -87,46 +83,17 @@ function FilterOptionRow({ }) { return ( {option.label} - {isSelected && ( - - )} + {isSelected && } ); } - -function SelectedFilterOptions({ - options, - onChange, -}: { - options: Option[]; - onChange: (value: T) => void; -}) { - return ( - - {options.map((opt) => ( - onChange(opt.value)} - className="flex-row items-center gap-[1vw] bg-card border border-primary-border - rounded-md px-[3vw] py-[1vh]" - > - {opt.label} - - - ))} - - ); -} diff --git a/clients/mobile/components/ui/search-bar.tsx b/clients/mobile/components/ui/search-bar.tsx index bf3555a69..dd5521b95 100644 --- a/clients/mobile/components/ui/search-bar.tsx +++ b/clients/mobile/components/ui/search-bar.tsx @@ -1,6 +1,7 @@ import React from "react"; import { View, TextInput } from "react-native"; import { Search } from "lucide-react-native"; +import { Colors } from "@/constants/theme"; interface SearchBarProps { value: string; @@ -19,11 +20,11 @@ export function SearchBar({ value={value} onChangeText={onChangeText} placeholder={placeholder} - placeholderTextColor="#9CA3AF" - className="w-full h-[6vh] px-[4vw] pr-[12vw] border border-gray-300 rounded-md" + placeholderTextColor={Colors.light.text} + className="w-full h-[8vh] px-[4vw] pr-[12vw] rounded-xl border border-stroke-subtle text-[4.5vw]" /> - + ); diff --git a/clients/mobile/hooks/use-hello.ts b/clients/mobile/hooks/use-hello.ts index 6814df990..9b8f2ec6d 100644 --- a/clients/mobile/hooks/use-hello.ts +++ b/clients/mobile/hooks/use-hello.ts @@ -13,6 +13,6 @@ export const useGetHelloName = (name: string) => { const api = useAPIClient(); return useQuery({ queryKey: ["hello", name], - queryFn: () => api.get(`/api/v1/hello/${name}`), + queryFn: () => api.get(`/hello/${name}`), }); }; diff --git a/clients/mobile/utils.ts b/clients/mobile/utils.ts new file mode 100644 index 000000000..800f39948 --- /dev/null +++ b/clients/mobile/utils.ts @@ -0,0 +1,29 @@ +import { Filter } from "@/components/ui/filters"; + +const toFilterConfig = ( + options: T[], + selected: T[], + onChange: (v: T) => void, + placeholder: string, + toLabel: (v: T) => string, +): Filter[] => [ + { + value: selected, + onChange, + placeholder, + options: options.map((v) => ({ label: toLabel(v), value: v })), + }, +]; + +export const getFloorConfig = ( + floors: number[], + selected: number[], + onChange: (f: number) => void, +) => toFilterConfig(floors, selected, onChange, "Floor", (n) => `Floor ${n}`); + +export const getGroupSizeConfig = ( + sizes: number[], + selected: number[], + onChange: (s: number) => void, +) => + toFilterConfig(sizes, selected, onChange, "Group Size", (n) => `${n} guests`); diff --git a/clients/shared/src/index.ts b/clients/shared/src/index.ts index 5f4f8a62a..ad56f5dcf 100644 --- a/clients/shared/src/index.ts +++ b/clients/shared/src/index.ts @@ -1,3 +1,5 @@ + + // Custom Types (non-generated) export { ApiError } from "./types/api.types"; export type { ApiConfig } from "./types/api.types"; @@ -47,6 +49,11 @@ export { usePutApiV1GuestsId, } from "./api/generated/endpoints/guests/guests"; + +export { + useGetGuestBookingsGroupSizes +} from "./api/generated/endpoints/guest-bookings/guest-bookings"; + export { usePostRooms, useGetRoomsFloors } from "./api/generated/endpoints/rooms/rooms"; export type {