Files
eventspace/frontend/src/components/guests/guests-list.tsx
Felipe Cardoso 392dd6f0d2 Add columns for dietary restrictions and notes to guest list
Enhanced the guest list table by adding new columns for dietary restrictions and notes. These columns display content using interactive popovers when data is available, improving data accessibility and user experience. Updated existing table structure to accommodate these enhancements.
2025-03-19 09:07:51 +01:00

847 lines
28 KiB
TypeScript

import React, { useEffect, useState } from "react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
AlertTriangle,
Copy,
Download,
Filter,
MoreHorizontal,
Plus,
Search,
Send,
} from "lucide-react";
import { useGuests } from "@/context/guest-context";
import {
EventResponse,
GuestCreate,
GuestRead,
GuestStatus,
GuestUpdate,
} from "@/client/types.gen";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { toast } from "sonner";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { useAuth } from "@/context/auth-context";
import { generateInviteLink } from "@/lib/utils";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { StickyNote, Utensils } from "lucide-react";
// Helper to generate a random invitation code
const generateInvitationCode = (fullName: string): string => {
const namePart = fullName
.replace(/[^A-Za-z]/g, "")
.substring(0, 6)
.toUpperCase();
const randomPart = Math.floor(Math.random() * 1000)
.toString()
.padStart(5, "0");
return `${namePart}${randomPart}`;
};
type GuestListTableProps = {
event: EventResponse;
};
const GuestListTable = ({ event }: GuestListTableProps) => {
// State
const [addGuestOpen, setAddGuestOpen] = useState(false);
const [editGuestOpen, setEditGuestOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [currentGuest, setCurrentGuest] = useState<GuestRead | null>(null);
// Form state
const initialState = {
full_name: "",
email: "",
phone: "",
max_additional_guests: 0,
dietary_restrictions: "",
notes: "",
can_bring_guests: true,
};
const [formData, setFormData] =
useState<Partial<GuestCreate & GuestUpdate>>(initialState);
// Access guest context
const {
guests,
isLoadingGuests,
error,
createGuest,
updateGuest,
deleteGuest,
refetchGuests,
currentGuestId,
setCurrentGuestId,
} = useGuests();
const { user } = useAuth();
// Filter guests by search query
const filteredGuests =
guests?.filter(
(guest) =>
guest.full_name.toLowerCase().includes(searchQuery.toLowerCase()) ||
(guest.email?.toLowerCase() || "").includes(
searchQuery.toLowerCase(),
) ||
guest.invitation_code.toLowerCase().includes(searchQuery.toLowerCase()),
) || [];
// Handle form input changes
const handleInputChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
) => {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
};
// Handle select changes
const handleSelectChange = (
name: string,
value: string | number | boolean,
) => {
setFormData((prev) => ({ ...prev, [name]: value }));
};
// Handle add guest form submission
const handleAddGuest = async () => {
try {
if (!formData.full_name) {
toast.error("Full name is required");
return;
}
// Get the event ID from the first guest or use a default
// In a real implementation, you would probably pass this as a prop
const eventId = event.id; // This should be replaced with the actual event ID
// Get the current user ID
// In a real implementation, you would get this from an auth context
const invitedBy = user?.id || "admin"; // This should be replaced with the current user ID
const newGuest: GuestCreate = {
event_id: eventId,
invited_by: invitedBy,
full_name: formData.full_name || "",
email: formData.email || null,
phone: formData.phone || null,
max_additional_guests:
typeof formData.max_additional_guests === "number"
? formData.max_additional_guests
: 0,
dietary_restrictions: formData.dietary_restrictions || null,
notes: formData.notes || null,
can_bring_guests: formData.can_bring_guests || true,
invitation_code: generateInvitationCode(formData.full_name || ""),
};
await createGuest(newGuest);
toast.success("Guest added successfully");
setAddGuestOpen(false);
setFormData(initialState);
} catch (error) {
console.error("Error adding guest:", error);
toast.error("Failed to add guest");
}
};
// Handle edit guest
const handleEditGuest = async () => {
try {
if (!currentGuest) return;
const updatedGuest: GuestUpdate = {
full_name: formData.full_name || null,
email: formData.email || null,
phone: formData.phone || null,
max_additional_guests:
typeof formData.max_additional_guests === "number"
? formData.max_additional_guests
: null,
dietary_restrictions: formData.dietary_restrictions || null,
notes: formData.notes || null,
can_bring_guests: formData.can_bring_guests || null,
};
await updateGuest(currentGuest.id, updatedGuest);
toast.success("Guest updated successfully");
setEditGuestOpen(false);
} catch (error) {
console.error("Error updating guest:", error);
toast.error("Failed to update guest");
}
};
// Handle delete guest
const handleDeleteGuest = async () => {
try {
if (!currentGuest) return;
await deleteGuest(currentGuest.id);
setFormData(initialState);
setCurrentGuest(null);
toast.success("Guest deleted successfully");
setDeleteDialogOpen(false);
} catch (error) {
console.error("Error deleting guest:", error);
toast.error("Failed to delete guest");
}
};
// Prepare to edit a guest
const prepareEditGuest = (guest: GuestRead) => {
setCurrentGuest(guest);
setFormData({
full_name: guest.full_name,
email: guest.email || "",
phone: guest.phone || "",
max_additional_guests: guest.max_additional_guests || 0,
dietary_restrictions: guest.dietary_restrictions || "",
notes: guest.notes || "",
can_bring_guests: guest.can_bring_guests || false,
});
setEditGuestOpen(true);
};
// Prepare to delete a guest
const prepareDeleteGuest = (guest: GuestRead) => {
setCurrentGuest(guest);
setDeleteDialogOpen(true);
};
// Helper to get status badge
const getStatusBadge = (status: GuestStatus) => {
const statusStyles: Record<GuestStatus, string> = {
[GuestStatus.INVITED]: "bg-blue-100 text-blue-800",
[GuestStatus.PENDING]: "bg-yellow-100 text-yellow-800",
[GuestStatus.CONFIRMED]: "bg-green-100 text-green-800",
[GuestStatus.DECLINED]: "bg-red-100 text-red-800",
[GuestStatus.WAITLISTED]: "bg-purple-100 text-purple-800",
[GuestStatus.CANCELLED]: "bg-gray-100 text-gray-800",
};
return (
<Badge className={statusStyles[status]}>{status.toUpperCase()}</Badge>
);
};
// Copy invitation code to clipboard
const copyToClipboard = (text: string, message: string) => {
navigator.clipboard.writeText(text);
toast(message);
};
// Export guest list to CSV
const exportToCSV = () => {
if (!guests || guests.length === 0) return;
const headers = [
"Name",
"Email",
"Phone",
"Invitation Code",
"Status",
"Additional Guests",
];
const csvContent = [
headers.join(","),
...guests.map((guest) =>
[
`"${guest.full_name}"`,
`"${guest.email || ""}"`,
`"${guest.phone || ""}"`,
`"${guest.invitation_code}"`,
`"${guest.status}"`,
guest.max_additional_guests || 0,
].join(","),
),
].join("\n");
const blob = new Blob([csvContent], { type: "text/csv" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.setAttribute("href", url);
a.setAttribute("download", "guest-list.csv");
a.click();
};
// Send invitations (stub - in a real app, this would integrate with your email service)
const sendInvitations = () => {
toast("Sending Invitations", {
description:
"This feature would send invitations to all uninvited guests",
action: {
label: "Cancel",
onClick: () => console.log("Cancelled sending invitations"),
},
});
};
useEffect(() => {
// When add dialog opens, always reset to initial state
if (addGuestOpen) {
setFormData(initialState);
setCurrentGuest(null);
}
// When edit dialog opens, form data should reflect the current guest
else if (editGuestOpen && currentGuest) {
setFormData({
full_name: currentGuest.full_name,
email: currentGuest.email || "",
phone: currentGuest.phone || "",
max_additional_guests: currentGuest.max_additional_guests || 0,
dietary_restrictions: currentGuest.dietary_restrictions || "",
notes: currentGuest.notes || "",
can_bring_guests: currentGuest.can_bring_guests || false,
});
}
}, [addGuestOpen, editGuestOpen, currentGuest]);
return (
<div className="space-y-4 w-full">
{error && (
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative flex items-center">
<AlertTriangle className="h-5 w-5 mr-2" />
<span>Error loading guests: {error.message}</span>
</div>
)}
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold">Guest List</h2>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={exportToCSV}>
<Download className="mr-2 h-4 w-4" /> Export
</Button>
<Dialog open={addGuestOpen} onOpenChange={setAddGuestOpen}>
<DialogTrigger asChild>
<Button className="bg-blue-600 hover:bg-blue-700">
<Plus className="mr-2 h-4 w-4" /> Add Guest
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Add New Guest</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="full_name" className="text-right">
Full Name <span className="text-red-500">*</span>
</Label>
<Input
id="full_name"
name="full_name"
value={formData.full_name || ""}
onChange={handleInputChange}
className="col-span-3"
required
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="email" className="text-right">
Email
</Label>
<Input
id="email"
name="email"
type="email"
value={formData.email || ""}
onChange={handleInputChange}
className="col-span-3"
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="phone" className="text-right">
Phone
</Label>
<Input
id="phone"
name="phone"
value={formData.phone || ""}
onChange={handleInputChange}
className="col-span-3"
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="max_additional_guests" className="text-right">
Additional Guests
</Label>
<Input
id="max_additional_guests"
name="max_additional_guests"
type="number"
min="0"
value={formData.max_additional_guests || 0}
onChange={handleInputChange}
className="col-span-3"
/>
</div>
<div className="grid grid-cols-4 items-start gap-4">
<Label
htmlFor="dietary_restrictions"
className="text-right pt-2"
>
Dietary Restrictions
</Label>
<Textarea
id="dietary_restrictions"
name="dietary_restrictions"
value={formData.dietary_restrictions || ""}
onChange={handleInputChange}
className="col-span-3"
rows={2}
/>
</div>
<div className="grid grid-cols-4 items-start gap-4">
<Label htmlFor="notes" className="text-right pt-2">
Notes
</Label>
<Textarea
id="notes"
name="notes"
value={formData.notes || ""}
onChange={handleInputChange}
className="col-span-3"
rows={2}
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="can_bring_guests" className="text-right">
Can Bring Guests
</Label>
<Select
value={
formData.can_bring_guests !== undefined
? String(formData.can_bring_guests)
: "true"
}
onValueChange={(value: string) =>
handleSelectChange("can_bring_guests", value === "true")
}
>
<SelectTrigger className="col-span-3">
<SelectValue placeholder="Select option" />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">Yes</SelectItem>
<SelectItem value="false">No</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setAddGuestOpen(false)}
>
Cancel
</Button>
<Button
className="bg-blue-600 hover:bg-blue-700"
onClick={handleAddGuest}
>
Add Guest
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</div>
<div className="flex justify-between items-center">
<div className="relative w-64">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-gray-500" />
<Input
placeholder="Search guests..."
className="pl-8"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm">
<Filter className="mr-2 h-4 w-4" /> Filter
</Button>
<Button variant="outline" size="sm" onClick={sendInvitations}>
<Send className="mr-2 h-4 w-4" /> Send Invites
</Button>
</div>
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Email</TableHead>
<TableHead>Phone</TableHead>
<TableHead>Invitation Code</TableHead>
<TableHead>Status</TableHead>
<TableHead>Add. Guests</TableHead>
<TableHead>Diet Restr.</TableHead>
<TableHead>Notes</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoadingGuests ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-8">
Loading guests...
</TableCell>
</TableRow>
) : filteredGuests.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-8">
No guests found. Add your first guest!
</TableCell>
</TableRow>
) : (
filteredGuests.map((guest) => (
<TableRow key={guest.id}>
<TableCell className="font-medium">
{guest.full_name}
</TableCell>
<TableCell>{guest.email || "-"}</TableCell>
<TableCell>{guest.phone || "-"}</TableCell>
<TableCell>
<div className="flex items-center gap-1">
{guest.invitation_code}
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() =>
copyToClipboard(
guest.invitation_code,
"Invitation code copied to clipboard",
)
}
>
<Copy className="h-3 w-3" />
</Button>
</div>
</TableCell>
<TableCell>{getStatusBadge(guest.status)}</TableCell>
<TableCell>{guest.actual_additional_guests || 0}</TableCell>
{/* Dietary Restrictions Column */}
<TableCell>
{guest.dietary_restrictions &&
guest.dietary_restrictions.length > 0 ? (
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
>
<Utensils className="h-4 w-4 text-green-600 cursor-pointer" />
</Button>
</PopoverTrigger>
<PopoverContent className="max-w-xs">
<p className="text-sm">
{guest.dietary_restrictions}
</p>
</PopoverContent>
</Popover>
) : (
"-"
)}
</TableCell>
{/* Notes Column */}
<TableCell>
{guest.notes && guest.notes.length > 0 ? (
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
>
<StickyNote className="h-4 w-4 text-blue-600 cursor-pointer" />
</Button>
</PopoverTrigger>
<PopoverContent className="max-w-xs">
<p className="text-sm">{guest.notes}</p>
</PopoverContent>
</Popover>
) : (
"-"
)}
</TableCell>
<TableCell className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => prepareEditGuest(guest)}
>
Edit
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
copyToClipboard(
guest.invitation_code,
"Invitation code copied to clipboard",
)
}
>
Copy Invitation Code
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
copyToClipboard(
generateInviteLink(
event.slug,
guest.invitation_code,
),
"Invitation link copied to clipboard",
)
}
>
Copy Invitation Link
</DropdownMenuItem>
<DropdownMenuItem>Resend Invitation</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-red-600"
onClick={() => prepareDeleteGuest(guest)}
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
<div className="flex justify-between text-sm text-gray-500">
<div>
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}
</div>
</div>
{/* Edit Guest Dialog */}
<Dialog open={editGuestOpen} onOpenChange={setEditGuestOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Edit Guest</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="edit_full_name" className="text-right">
Full Name <span className="text-red-500">*</span>
</Label>
<Input
id="edit_full_name"
name="full_name"
value={formData.full_name || ""}
onChange={handleInputChange}
className="col-span-3"
required
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="edit_email" className="text-right">
Email
</Label>
<Input
id="edit_email"
name="email"
type="email"
value={formData.email || ""}
onChange={handleInputChange}
className="col-span-3"
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="edit_phone" className="text-right">
Phone
</Label>
<Input
id="edit_phone"
name="phone"
value={formData.phone || ""}
onChange={handleInputChange}
className="col-span-3"
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label
htmlFor="edit_max_additional_guests"
className="text-right"
>
Additional Guests
</Label>
<Input
id="edit_max_additional_guests"
name="max_additional_guests"
type="number"
min="0"
value={formData.max_additional_guests || 0}
onChange={handleInputChange}
className="col-span-3"
/>
</div>
<div className="grid grid-cols-4 items-start gap-4">
<Label
htmlFor="edit_dietary_restrictions"
className="text-right pt-2"
>
Dietary Restrictions
</Label>
<Textarea
id="edit_dietary_restrictions"
name="dietary_restrictions"
value={formData.dietary_restrictions || ""}
onChange={handleInputChange}
className="col-span-3"
rows={2}
/>
</div>
<div className="grid grid-cols-4 items-start gap-4">
<Label htmlFor="edit_notes" className="text-right pt-2">
Notes
</Label>
<Textarea
id="edit_notes"
name="notes"
value={formData.notes || ""}
onChange={handleInputChange}
className="col-span-3"
rows={2}
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="edit_can_bring_guests" className="text-right">
Can Bring Guests
</Label>
<Select
value={formData.can_bring_guests ? "true" : "false"}
onValueChange={(value) =>
handleSelectChange("can_bring_guests", value === "true")
}
>
<SelectTrigger className="col-span-3">
<SelectValue placeholder="Select option" />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">Yes</SelectItem>
<SelectItem value="false">No</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setEditGuestOpen(false)}>
Cancel
</Button>
<Button
className="bg-blue-600 hover:bg-blue-700"
onClick={handleEditGuest}
>
Save Changes
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete the guest "{currentGuest?.full_name}
". This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-red-600 hover:bg-red-700 text-white"
onClick={handleDeleteGuest}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
};
export default GuestListTable;