Compare commits

...

3 Commits

Author SHA1 Message Date
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
7 changed files with 184 additions and 44 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

@@ -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

@@ -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

@@ -1876,22 +1876,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

@@ -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) =>