Files
eventspace/frontend/src/components/guests/guests-list.tsx
Felipe Cardoso 25d2f16816
Some checks failed
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) Failing after 51s
Fix max-additional guests in guests-list.tsx
2025-03-16 12:10:54 +01:00

791 lines
26 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,
EventThemeResponse,
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";
// 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>Additional Guests</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>
<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;