Compare commits

..

7 Commits

Author SHA1 Message Date
Felipe Cardoso
678e1db0a3 Update max additional guests logic and refine guest counts display
All checks were successful
Build and Push Docker Images / changes (push) Successful in 5s
Build and Push Docker Images / build-backend (push) Has been skipped
Build and Push Docker Images / build-frontend (push) Successful in 1m15s
Set default max additional guests to 10 and renamed related labels for clarity. Improved guest count calculation by adding total confirmed guests and refining additional guest computations. Updated the UI to reflect these changes concisely and consistently.
2025-03-20 18:33:02 +01:00
Felipe Cardoso
a2c3f16dc7 Update guests-list to use RSVP data for dietary and notes
All checks were successful
Build and Push Docker Images / changes (push) Successful in 11s
Build and Push Docker Images / build-backend (push) Has been skipped
Build and Push Docker Images / build-frontend (push) Successful in 1m49s
Replaced `guest.dietary_restrictions` and `guest.notes` with `guest.rsvp.dietary_requirements` and `guest.rsvp.response_message`. This ensures the data reflects RSVP-specific fields and improves code consistency with the RSVP model.
2025-03-19 20:49:29 +01:00
Felipe Cardoso
44e6b2a6dc Refactor guest schema and add RSVP field to GuestBase.
Reorganized imports for better readability and compliance with standards. Added an optional `rsvp` field to the `GuestBase` model to include RSVP details. This enhances the schema's flexibility and supports additional guest-related data.
2025-03-19 20:47:12 +01:00
Felipe Cardoso
42508af610 Add optional RSVP field to guest schema
This commit introduces an optional `rsvp` field to the guest-related types and schemas. The field is nullable and references the `RSVPSchema`, allowing better handling of RSVP data in the application.
2025-03-19 20:47:07 +01:00
Felipe Cardoso
2a1f13a5f0 Add functionality to display gift reservations per guest
All checks were successful
Build and Push Docker Images / changes (push) Successful in 4s
Build and Push Docker Images / build-backend (push) Has been skipped
Build and Push Docker Images / build-frontend (push) Successful in 1m10s
Implemented fetching and grouping of guests' gift reservations for events. Added a popover UI to display reservation details, including guest names and quantities, with a loading state for pending data. This enhances the gift dashboard's interactivity and usability.
2025-03-19 19:56:23 +01:00
Felipe Cardoso
c81e27c602 Add event-specific guest gift reservations endpoint
Introduced a function `get_event_guest_gift_reservations` in the CRUD layer to fetch gift reservations filtered by event ID. Updated the API endpoint to optionally accept an `event_id` query parameter for retrieving reservations specific to an event.
2025-03-19 19:56:10 +01:00
Felipe Cardoso
9fe5e60907 Rename gift purchases API to reservations and update types
Refactor types and API references to replace "gift purchases" with "gift reservations" for improved clarity. Updates include type definitions, query keys, options, and endpoint URLs, with the addition of an optional event ID filter. This ensures better alignment with the intended functionality.
2025-03-19 19:55:56 +01:00
10 changed files with 224 additions and 67 deletions

View File

@@ -1,7 +1,7 @@
from typing import List, Optional, Dict, Any
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Path
from fastapi import APIRouter, Depends, HTTPException, Path, Query
from sqlalchemy.orm import Session
from app.api.dependencies.auth import get_current_active_user, get_current_user, get_optional_current_user
@@ -1050,19 +1050,25 @@ def read_gift_purchases_by_guest(
return gift_purchase_crud.get_gift_reservations_by_guest(db, guest_id=guest_id)
@router.get(
"/purchases/guest/all",
"/reservations/guests",
response_model=List[GiftPurchase],
operation_id="read_guests_gift_purchases"
operation_id="read_guests_gift_reservations",
)
def read_all_guest_gift_reservations(
*,
db: Session = Depends(get_db),
event_id: Optional[str] = Query(None, description="Optional event ID to filter reservations by event"),
) -> Any:
"""
Retrieve all guest gift reservations.
Retrieve all guest gift reservations, optionally filtered by event ID.
"""
reservations = gift_purchase_crud.get_all_guest_gift_reservations(db=db)
if event_id:
event_id = UUID(event_id)
reservations = gift_purchase_crud.get_event_guest_gift_reservations(db=db, event_id=event_id)
else:
reservations = gift_purchase_crud.get_all_guest_gift_reservations(db=db)
if not reservations:
reservations = []
return reservations

View File

@@ -387,6 +387,41 @@ class CRUDGiftPurchase(CRUDBase[GiftPurchase, GiftPurchaseCreate, GiftPurchaseUp
return result
def get_event_guest_gift_reservations(self, db: Session, event_id: UUID | str) -> List[GiftPurchase]:
"""Retrieve all gift reservations for guests belonging to a specific event."""
event_id = event_id if isinstance(event_id, UUID) else UUID(event_id)
stmt = (
select(Guest)
.where(Guest.event_id == event_id)
.options(joinedload(Guest.gifts))
)
# Correct: Call unique() on the RESULT, not on the stmt
guests = db.execute(stmt).unique().scalars().all()
results = []
for guest in guests:
for gift in guest.gifts:
reservation_stmt = select(GuestGifts).where(
(GuestGifts.c.guest_id == guest.id) &
(GuestGifts.c.gift_id == gift.id)
)
reservation = db.execute(reservation_stmt).first()
if reservation:
purchase = GiftPurchase(
id=UUID('00000000-0000-0000-0000-000000000000'),
gift_id=gift.id,
guest_id=guest.id,
quantity=1,
purchased_at=reservation.reserved_at,
notes=reservation.notes
)
results.append(purchase)
return results
# Create CRUD instances
gift_item_crud = CRUDGiftItem(GiftItem)

View File

@@ -1,11 +1,10 @@
import uuid
from pydantic import BaseModel, EmailStr, ConfigDict
from datetime import datetime
from typing import Optional, Any, Dict
from app.models.guest import GuestStatus
from uuid import UUID
from pydantic import BaseModel, EmailStr, ConfigDict
from app.models.guest import GuestStatus
from app.schemas.rsvp import RSVPSchema, RSVPStatus
@@ -50,6 +49,8 @@ class GuestRead(GuestBase):
is_blocked: bool
model_config = ConfigDict(from_attributes=True)
invitation_code: str
rsvp: Optional[RSVPSchema] = None
class GuestWithRSVPResponse(BaseModel):
"""
@@ -62,6 +63,7 @@ class GuestWithRSVPResponse(BaseModel):
class Config:
from_attributes = True
def map_rsvp_status_to_guest_status(rsvp_status: RSVPStatus) -> GuestStatus:
if rsvp_status == RSVPStatus.ATTENDING:
return GuestStatus.CONFIRMED
@@ -70,4 +72,4 @@ def map_rsvp_status_to_guest_status(rsvp_status: RSVPStatus) -> GuestStatus:
elif rsvp_status == RSVPStatus.MAYBE:
return GuestStatus.PENDING
else:
return GuestStatus.INVITED
return GuestStatus.INVITED

View File

@@ -44,7 +44,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { GiftPriority, GiftStatus } from "@/client/types.gen";
import { GiftPriority, GiftPurchase, GiftStatus } from "@/client/types.gen";
import { CategoryModal } from "@/components/gifts/category-modal";
import { GiftModal } from "@/components/gifts/gift-modal";
import {
@@ -52,6 +52,7 @@ import {
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { useGuests } from "@/context/guest-context";
export default function GiftRegistryPage() {
const { slug } = useParams<{ slug: string }>();
@@ -68,8 +69,10 @@ export default function GiftRegistryPage() {
currentEventId,
setCurrentEventId,
deleteItem,
fetchGuestsGiftPurchases,
} = useGifts();
const { guests } = useGuests();
// State for modals
const [isAddGiftModalOpen, setIsAddGiftModalOpen] = useState(false);
const [isEditGiftModalOpen, setIsEditGiftModalOpen] = useState(false);
@@ -84,6 +87,39 @@ export default function GiftRegistryPage() {
const [searchQuery, setSearchQuery] = useState("");
const [categoryFilter, setCategoryFilter] = useState<string>("all");
const [statusFilter, setStatusFilter] = useState<string>("all");
const [reservations, setReservations] = useState<
Record<string, GiftPurchase[]>
>({});
const [loadingReservations, setLoadingReservations] = useState<boolean>(true);
useEffect(() => {
const loadReservations = async () => {
if (currentEventId) {
setLoadingReservations(true);
try {
const data = await fetchGuestsGiftPurchases(currentEventId);
if (data) {
const groupedReservations = data.reduce(
(acc, purchase) => {
const giftId = purchase.gift_id;
if (!acc[giftId]) acc[giftId] = [];
acc[giftId].push(purchase);
return acc;
},
{} as Record<string, GiftPurchase[]>,
);
setReservations(groupedReservations);
}
} catch (err) {
console.error("Unable to fetch reservations:", err);
} finally {
setLoadingReservations(false);
}
}
};
loadReservations();
}, [currentEventId, fetchGuestsGiftPurchases]);
// Filter items based on search query and filters
const filteredItems = items
@@ -436,20 +472,44 @@ export default function GiftRegistryPage() {
{getStatusBadge(item.status)}
</div>
</PopoverTrigger>
{/*<PopoverContent className="text-sm">*/}
{/* {item.reservations &&*/}
{/* item.reservations.length > 0 ? (*/}
{/* <ul className="list-disc">*/}
{/* {item.reservations.map((res) => (*/}
{/* <li key={res.guest_id}>*/}
{/* {res.guest_name}: {res.quantity}*/}
{/* </li>*/}
{/* ))}*/}
{/* </ul>*/}
{/* ) : (*/}
{/* <p>No reservations available.</p>*/}
{/* )}*/}
{/*</PopoverContent>*/}
<PopoverContent className="text-sm w-[220px]">
{loadingReservations ? (
<div className="flex items-center justify-center p-2">
<Loader2
className="animate-spin"
size={16}
/>
<span className="ml-2">
Loading reservations...
</span>
</div>
) : reservations[item.id] &&
reservations[item.id].length > 0 ? (
<ul className="list-disc pl-4 py-2">
{reservations[item.id].map(
(purchase, index) => {
const guest = guests?.find(
(g) => g.id === purchase.guest_id,
);
return (
<li key={`${purchase.id}_${index}`}>
<strong>
{guest?.full_name ||
purchase.guest_id}
</strong>
: {purchase.quantity}
</li>
);
},
)}
</ul>
) : (
<p className="p-2 text-gray-500">
No reservations available.
</p>
)}
</PopoverContent>
</Popover>
) : (
getStatusBadge(item.status)

View File

@@ -48,7 +48,7 @@ import {
readGiftPurchase,
readGiftPurchasesByGift,
readGiftPurchasesByGuest,
readGuestsGiftPurchases,
readGuestsGiftReservations,
createEvent,
getUserEvents,
getUpcomingEvents,
@@ -166,7 +166,7 @@ import type {
ReadGiftPurchaseData,
ReadGiftPurchasesByGiftData,
ReadGiftPurchasesByGuestData,
ReadGuestsGiftPurchasesData,
ReadGuestsGiftReservationsData,
CreateEventData,
CreateEventError,
CreateEventResponse,
@@ -1423,16 +1423,16 @@ export const readGiftPurchasesByGuestOptions = (
});
};
export const readGuestsGiftPurchasesQueryKey = (
options?: Options<ReadGuestsGiftPurchasesData>,
) => createQueryKey("readGuestsGiftPurchases", options);
export const readGuestsGiftReservationsQueryKey = (
options?: Options<ReadGuestsGiftReservationsData>,
) => createQueryKey("readGuestsGiftReservations", options);
export const readGuestsGiftPurchasesOptions = (
options?: Options<ReadGuestsGiftPurchasesData>,
export const readGuestsGiftReservationsOptions = (
options?: Options<ReadGuestsGiftReservationsData>,
) => {
return queryOptions({
queryFn: async ({ queryKey, signal }) => {
const { data } = await readGuestsGiftPurchases({
const { data } = await readGuestsGiftReservations({
...options,
...queryKey[0],
signal,
@@ -1440,7 +1440,7 @@ export const readGuestsGiftPurchasesOptions = (
});
return data;
},
queryKey: readGuestsGiftPurchasesQueryKey(options),
queryKey: readGuestsGiftReservationsQueryKey(options),
});
};

View File

@@ -2427,6 +2427,16 @@ export const GuestReadSchema = {
type: "string",
title: "Invitation Code",
},
rsvp: {
anyOf: [
{
$ref: "#/components/schemas/RSVPSchema",
},
{
type: "null",
},
],
},
},
type: "object",
required: [

View File

@@ -142,8 +142,9 @@ import type {
ReadGiftPurchasesByGuestData,
ReadGiftPurchasesByGuestResponse,
ReadGiftPurchasesByGuestError,
ReadGuestsGiftPurchasesData,
ReadGuestsGiftPurchasesResponse,
ReadGuestsGiftReservationsData,
ReadGuestsGiftReservationsResponse,
ReadGuestsGiftReservationsError,
CreateEventData,
CreateEventResponse,
CreateEventError,
@@ -1157,17 +1158,19 @@ export const readGiftPurchasesByGuest = <ThrowOnError extends boolean = false>(
/**
* Read All Guest Gift Reservations
* Retrieve all guest gift reservations.
* Retrieve all guest gift reservations, optionally filtered by event ID.
*/
export const readGuestsGiftPurchases = <ThrowOnError extends boolean = false>(
options?: Options<ReadGuestsGiftPurchasesData, ThrowOnError>,
export const readGuestsGiftReservations = <
ThrowOnError extends boolean = false,
>(
options?: Options<ReadGuestsGiftReservationsData, ThrowOnError>,
) => {
return (options?.client ?? _heyApiClient).get<
ReadGuestsGiftPurchasesResponse,
unknown,
ReadGuestsGiftReservationsResponse,
ReadGuestsGiftReservationsError,
ThrowOnError
>({
url: "/api/v1/events/gifts/purchases/guest/all",
url: "/api/v1/events/gifts/reservations/guests",
...options,
});
};

View File

@@ -370,6 +370,7 @@ export type GuestRead = {
actual_additional_guests: number;
is_blocked: boolean;
invitation_code: string;
rsvp?: RsvpSchema | null;
};
export type GuestStatus =
@@ -1876,22 +1877,37 @@ export type ReadGiftPurchasesByGuestResponses = {
export type ReadGiftPurchasesByGuestResponse =
ReadGiftPurchasesByGuestResponses[keyof ReadGiftPurchasesByGuestResponses];
export type ReadGuestsGiftPurchasesData = {
export type ReadGuestsGiftReservationsData = {
body?: never;
path?: never;
query?: never;
url: "/api/v1/events/gifts/purchases/guest/all";
query?: {
/**
* Optional event ID to filter reservations by event
*/
event_id?: string | null;
};
url: "/api/v1/events/gifts/reservations/guests";
};
export type ReadGuestsGiftPurchasesResponses = {
export type ReadGuestsGiftReservationsErrors = {
/**
* Validation Error
*/
422: HttpValidationError;
};
export type ReadGuestsGiftReservationsError =
ReadGuestsGiftReservationsErrors[keyof ReadGuestsGiftReservationsErrors];
export type ReadGuestsGiftReservationsResponses = {
/**
* Successful Response
*/
200: Array<GiftPurchase>;
};
export type ReadGuestsGiftPurchasesResponse =
ReadGuestsGiftPurchasesResponses[keyof ReadGuestsGiftPurchasesResponses];
export type ReadGuestsGiftReservationsResponse =
ReadGuestsGiftReservationsResponses[keyof ReadGuestsGiftReservationsResponses];
export type CreateEventData = {
body: EventCreate;

View File

@@ -101,7 +101,7 @@ const GuestListTable = ({ event }: GuestListTableProps) => {
full_name: "",
email: "",
phone: "",
max_additional_guests: 0,
max_additional_guests: 10,
dietary_restrictions: "",
notes: "",
can_bring_guests: true,
@@ -293,7 +293,7 @@ const GuestListTable = ({ event }: GuestListTableProps) => {
"Phone",
"Invitation Code",
"Status",
"Additional Guests",
"Max Additional Guests",
];
const csvContent = [
headers.join(","),
@@ -348,6 +348,12 @@ const GuestListTable = ({ event }: GuestListTableProps) => {
}
}, [addGuestOpen, editGuestOpen, currentGuest]);
const confirmedGuestCount =
guests?.filter((g) => g.status === GuestStatus.CONFIRMED).length || 0;
const confirmedAdditionalGuestsCount =
guests?.reduce((acc, g) => acc + (g.actual_additional_guests || 0), 0) || 0;
const totalConfirmedGuestsCount =
confirmedGuestCount + confirmedAdditionalGuestsCount;
return (
<div className="space-y-4 w-full">
{error && (
@@ -414,14 +420,14 @@ const GuestListTable = ({ event }: GuestListTableProps) => {
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="max_additional_guests" className="text-right">
Additional Guests
Max Add. Guests
</Label>
<Input
id="max_additional_guests"
name="max_additional_guests"
type="number"
min="0"
value={formData.max_additional_guests || 0}
value={formData.max_additional_guests || 10}
onChange={handleInputChange}
className="col-span-3"
/>
@@ -578,8 +584,8 @@ const GuestListTable = ({ event }: GuestListTableProps) => {
{/* Dietary Restrictions Column */}
<TableCell>
{guest.dietary_restrictions &&
guest.dietary_restrictions.length > 0 ? (
{guest.rsvp?.dietary_requirements &&
guest.rsvp.dietary_requirements.length > 0 ? (
<Popover>
<PopoverTrigger asChild>
<Button
@@ -592,7 +598,7 @@ const GuestListTable = ({ event }: GuestListTableProps) => {
</PopoverTrigger>
<PopoverContent className="max-w-xs">
<p className="text-sm">
{guest.dietary_restrictions}
{guest.rsvp.dietary_requirements}
</p>
</PopoverContent>
</Popover>
@@ -603,7 +609,8 @@ const GuestListTable = ({ event }: GuestListTableProps) => {
{/* Notes Column */}
<TableCell>
{guest.notes && guest.notes.length > 0 ? (
{guest.rsvp?.response_message &&
guest.rsvp.response_message.length > 0 ? (
<Popover>
<PopoverTrigger asChild>
<Button
@@ -615,7 +622,9 @@ const GuestListTable = ({ event }: GuestListTableProps) => {
</Button>
</PopoverTrigger>
<PopoverContent className="max-w-xs">
<p className="text-sm">{guest.notes}</p>
<p className="text-sm">
{guest.rsvp.response_message}
</p>
</PopoverContent>
</Popover>
) : (
@@ -682,14 +691,9 @@ const GuestListTable = ({ event }: GuestListTableProps) => {
Showing {filteredGuests.length} of {guests?.length || 0} guests
</div>
<div>
Total Confirmed:{" "}
{guests?.filter((g) => g.status === GuestStatus.CONFIRMED).length ||
0}{" "}
| Total Additional Guests:{" "}
{guests?.reduce(
(acc, g) => acc + (g.max_additional_guests || 0),
0,
) || 0}
Guests Confirmed: {confirmedGuestCount} | Additional Guests:{" "}
{confirmedAdditionalGuestsCount} | Total Guests:{" "}
{totalConfirmedGuestsCount}
</div>
</div>
@@ -743,7 +747,7 @@ const GuestListTable = ({ event }: GuestListTableProps) => {
htmlFor="edit_max_additional_guests"
className="text-right"
>
Additional Guests
Max Add. Guests
</Label>
<Input
id="edit_max_additional_guests"

View File

@@ -24,6 +24,7 @@ import {
readGiftPurchase,
readGiftPurchasesByGift,
readGiftPurchasesByGuest,
readGuestsGiftReservations,
} from "@/client/sdk.gen";
import {
GiftCategory,
@@ -48,6 +49,10 @@ interface GiftContextState {
refetchCategories: (eventId: string) => Promise<any>;
fetchCategoryById: (id: string, eventId?: string) => void;
fetchGuestsGiftPurchases: (
eventId: string,
) => Promise<GiftPurchase[] | undefined>;
createCategory: (
data: GiftCategoryCreate,
) => Promise<GiftCategory | undefined>;
@@ -147,6 +152,8 @@ const defaultGiftContextState: GiftContextState = {
},
fetchCategoryById: () => {},
fetchGuestsGiftPurchases: async () => undefined,
createCategory: async () => {
throw new Error("GiftContext not initialized");
},
@@ -310,6 +317,20 @@ export const GiftProvider: React.FC<GiftProviderProps> = ({ children }) => {
}
};
const fetchGuestsGiftPurchases = async (
eventId: string,
): Promise<GiftPurchase[] | undefined> => {
try {
const result = await readGuestsGiftReservations({
query: { event_id: eventId },
});
return result.data;
} catch (error) {
console.error("Error fetching guests' gift purchases:", error);
throw error;
}
};
// Create Category Mutation
const createCategoryMutation = useMutation({
mutationFn: (data: GiftCategoryCreate) =>
@@ -771,7 +792,7 @@ export const GiftProvider: React.FC<GiftProviderProps> = ({ children }) => {
isLoadingCategories,
isLoadingCategory,
refetchCategories,
fetchGuestsGiftPurchases,
fetchCategoryById,
createCategory: createCategoryMutation.mutateAsync,
updateCategory: (id, data, eventId) =>