refactor: remove enhanced seating chart component and initialize admin data loading states to false.
This commit is contained in:
@@ -476,7 +476,7 @@ function AdminHeader({ onSettingsOpen, onAddGuest }: { onSettingsOpen: () => voi
|
||||
<Settings className="h-4 w-4 mr-2" />
|
||||
Settings
|
||||
</Button>
|
||||
<Button variant="default" size="sm" onClick={onAddGuest} className="bg-wedding-evergreen hover:bg-wedding-moss">
|
||||
<Button variant="default" size="sm" onClick={onAddGuest} className="bg-wedding-moss hover:bg-wedding-sage">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Guest
|
||||
</Button>
|
||||
@@ -510,7 +510,7 @@ function AdminNavbar() {
|
||||
|
||||
function NavTrigger({ value, icon: Icon, label }: { value: string; icon: React.ComponentType<{ className?: string }>; label: string }) {
|
||||
return (
|
||||
<TabsTrigger value={value} className="rounded-lg data-[state=active]:bg-wedding-evergreen data-[state=active]:text-white hover:bg-wedding-sage/10 hover:text-wedding-evergreen transition-all">
|
||||
<TabsTrigger value={value} className="rounded-lg data-[state=active]:bg-wedding-moss data-[state=active]:text-white hover:bg-wedding-sage/10 hover:text-wedding-moss transition-all">
|
||||
<div className="flex items-center gap-2 px-2 py-1">
|
||||
<Icon className="h-4 w-4 text-inherit" />
|
||||
<span className="hidden sm:inline whitespace-nowrap">{label}</span>
|
||||
|
||||
@@ -89,8 +89,8 @@ export function AdminSidebar({ stats, onLogout }: AdminSidebarProps) {
|
||||
"flex items-center justify-between gap-3 px-3 py-2 rounded-lg transition-all font-body group relative",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-wedding-evergreen/50 focus-visible:ring-offset-2",
|
||||
active
|
||||
? "bg-wedding-sage/20 text-wedding-evergreen shadow-sm"
|
||||
: "text-wedding-ink/70 hover:bg-wedding-sage/10 hover:text-wedding-evergreen"
|
||||
? "bg-wedding-sage/20 text-wedding-moss shadow-sm"
|
||||
: "text-wedding-ink/70 hover:bg-wedding-sage/10 hover:text-wedding-moss"
|
||||
)}
|
||||
>
|
||||
{active && (
|
||||
@@ -134,19 +134,19 @@ export function AdminSidebar({ stats, onLogout }: AdminSidebarProps) {
|
||||
<div className="space-y-1">
|
||||
<Link
|
||||
href="/admin?tab=guest-requests"
|
||||
className="block px-3 py-2 text-xs font-body text-wedding-ink/70 hover:text-wedding-evergreen hover:bg-wedding-sage/10 rounded-lg transition-colors"
|
||||
className="block px-3 py-2 text-xs font-body text-wedding-ink/70 hover:text-wedding-moss hover:bg-wedding-sage/10 rounded-lg transition-colors"
|
||||
>
|
||||
View Guest Requests
|
||||
</Link>
|
||||
<Link
|
||||
href="/admin?tab=rsvp"
|
||||
className="block px-3 py-2 text-xs font-body text-wedding-ink/70 hover:text-wedding-evergreen hover:bg-wedding-sage/10 rounded-lg transition-colors"
|
||||
className="block px-3 py-2 text-xs font-body text-wedding-ink/70 hover:text-wedding-moss hover:bg-wedding-sage/10 rounded-lg transition-colors"
|
||||
>
|
||||
Send RSVP Reminder
|
||||
</Link>
|
||||
<Link
|
||||
href="/admin?tab=export"
|
||||
className="block px-3 py-2 text-xs font-body text-wedding-ink/70 hover:text-wedding-evergreen hover:bg-wedding-sage/10 rounded-lg transition-colors"
|
||||
className="block px-3 py-2 text-xs font-body text-wedding-ink/70 hover:text-wedding-moss hover:bg-wedding-sage/10 rounded-lg transition-colors"
|
||||
>
|
||||
Export Guest List
|
||||
</Link>
|
||||
|
||||
@@ -73,23 +73,23 @@ export function useAdminData(): UseAdminDataReturn {
|
||||
|
||||
// Stats
|
||||
const [stats, setStats] = useState<any>(null);
|
||||
const [statsLoading, setStatsLoading] = useState(true);
|
||||
const [statsLoading, setStatsLoading] = useState(false);
|
||||
|
||||
// Recent Activity
|
||||
const [recentActivity, setRecentActivity] = useState<any[]>([]);
|
||||
const [recentActivityLoading, setRecentActivityLoading] = useState(true);
|
||||
const [recentActivityLoading, setRecentActivityLoading] = useState(false);
|
||||
|
||||
// RSVP data
|
||||
const [rsvpData, setRsvpData] = useState<any>(null);
|
||||
const [rsvpLoading, setRsvpLoading] = useState(true);
|
||||
const [rsvpLoading, setRsvpLoading] = useState(false);
|
||||
|
||||
// Tributes data
|
||||
const [tributesData, setTributesData] = useState<any>(null);
|
||||
const [tributesLoading, setTributesLoading] = useState(true);
|
||||
const [tributesLoading, setTributesLoading] = useState(false);
|
||||
|
||||
// Guestbook data
|
||||
const [guestbookEntries, setGuestbookEntries] = useState<any[]>([]);
|
||||
const [guestbookLoading, setGuestbookLoading] = useState(true);
|
||||
const [guestbookLoading, setGuestbookLoading] = useState(false);
|
||||
|
||||
// Music data
|
||||
const [musicRequests, setMusicRequests] = useState<any[]>([]);
|
||||
@@ -100,7 +100,7 @@ export function useAdminData(): UseAdminDataReturn {
|
||||
|
||||
// Who's Who data
|
||||
const [whosWhoData, setWhosWhoData] = useState<any>(null);
|
||||
const [whosWhoLoading, setWhosWhoLoading] = useState(true);
|
||||
const [whosWhoLoading, setWhosWhoLoading] = useState(false);
|
||||
|
||||
// Fetch Stats
|
||||
const fetchStats = useCallback(async () => {
|
||||
|
||||
@@ -1,885 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import {
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
type DragStartEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { useDroppable } from "@dnd-kit/core";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Users,
|
||||
GripVertical,
|
||||
Search,
|
||||
Plus,
|
||||
Download,
|
||||
Edit3,
|
||||
Trash2,
|
||||
AlertTriangle,
|
||||
Utensils,
|
||||
Printer,
|
||||
LayoutGrid,
|
||||
List,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { useRealtimeSync } from "@/hooks/use-realtime-sync";
|
||||
|
||||
interface Table {
|
||||
id: string;
|
||||
name: string;
|
||||
capacity: number;
|
||||
guests: Guest[];
|
||||
}
|
||||
|
||||
interface Guest {
|
||||
id: string;
|
||||
name: string;
|
||||
surname?: string;
|
||||
maxPax: number;
|
||||
tableId?: string;
|
||||
dietaryRestrictions?: string;
|
||||
relationship?: string;
|
||||
isAttending?: boolean | null;
|
||||
}
|
||||
|
||||
// Draggable Guest Component
|
||||
function DraggableGuest({
|
||||
guest,
|
||||
isOverlay = false,
|
||||
}: {
|
||||
guest: Guest;
|
||||
isOverlay?: boolean;
|
||||
}) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: guest.id });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={isOverlay ? undefined : style}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className={cn(
|
||||
"flex items-center gap-2 p-2 bg-white rounded-lg border border-wedding-sage/20 cursor-grab active:cursor-grabbing hover:border-wedding-sage/40 transition-colors",
|
||||
isDragging && "shadow-lg",
|
||||
isOverlay && "shadow-xl"
|
||||
)}
|
||||
>
|
||||
<GripVertical className="h-4 w-4 text-wedding-moss/40" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">
|
||||
{guest.name} {guest.surname}
|
||||
</p>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs text-wedding-moss/60">
|
||||
{guest.maxPax} {guest.maxPax === 1 ? "guest" : "guests"}
|
||||
</span>
|
||||
{guest.dietaryRestrictions && (
|
||||
<Badge variant="outline" className="text-xs px-1 py-0">
|
||||
<Utensils className="h-3 w-3 mr-1" />
|
||||
Diet
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Droppable Table Component
|
||||
function DroppableTable({
|
||||
table,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
table: Table;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const { setNodeRef, isOver } = useDroppable({ id: table.id });
|
||||
|
||||
const currentOccupancy = table.guests.reduce((sum, g) => sum + g.maxPax, 0);
|
||||
const isOverCapacity = currentOccupancy > table.capacity;
|
||||
const dietaryCount = table.guests.filter((g) => g.dietaryRestrictions).length;
|
||||
|
||||
return (
|
||||
<Card
|
||||
ref={setNodeRef}
|
||||
variant="glass"
|
||||
className={cn(
|
||||
"transition-all",
|
||||
isOver && "ring-2 ring-wedding-sage bg-wedding-sage/5",
|
||||
isOverCapacity && "border-amber-400"
|
||||
)}
|
||||
>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="font-heading text-lg">{table.name}</CardTitle>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onEdit}>
|
||||
<Edit3 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-red-500 hover:text-red-600"
|
||||
onClick={onDelete}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<CardDescription className="font-body flex items-center gap-2">
|
||||
<Users className="h-4 w-4" />
|
||||
<span
|
||||
className={cn(
|
||||
isOverCapacity && "text-amber-600 font-medium"
|
||||
)}
|
||||
>
|
||||
{currentOccupancy}/{table.capacity}
|
||||
</span>
|
||||
{isOverCapacity && (
|
||||
<AlertTriangle className="h-4 w-4 text-amber-500" />
|
||||
)}
|
||||
{dietaryCount > 0 && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
<Utensils className="h-3 w-3 mr-1" />
|
||||
{dietaryCount}
|
||||
</Badge>
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 min-h-[100px]">
|
||||
{table.guests.length === 0 ? (
|
||||
<p className="text-sm text-wedding-moss/50 text-center py-4">
|
||||
Drop guests here
|
||||
</p>
|
||||
) : (
|
||||
table.guests.map((guest) => (
|
||||
<DraggableGuest key={guest.id} guest={guest} />
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function SeatingChartEnhanced() {
|
||||
const { toast } = useToast();
|
||||
const [tables, setTables] = useState<Table[]>([]);
|
||||
const [unseatedGuests, setUnseatedGuests] = useState<Guest[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [showAddTable, setShowAddTable] = useState(false);
|
||||
const [showEditTable, setShowEditTable] = useState<Table | null>(null);
|
||||
const [newTableName, setNewTableName] = useState("");
|
||||
const [newTableCapacity, setNewTableCapacity] = useState(8);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 8,
|
||||
},
|
||||
}),
|
||||
useSensor(KeyboardSensor)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
// Subscribe to real-time updates for seating, guest, and table changes
|
||||
useRealtimeSync({
|
||||
eventTypes: ['seating', 'guest', 'table'],
|
||||
onUpdate: (event) => {
|
||||
if (
|
||||
event.type === 'seating' ||
|
||||
(event.type === 'guest' && event.action === 'update' && (event.data?.tableId !== undefined || event.data?.rsvpStatus)) ||
|
||||
event.type === 'table'
|
||||
) {
|
||||
fetchData();
|
||||
}
|
||||
},
|
||||
debounceMs: 1000,
|
||||
});
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [guestsRes, tablesRes] = await Promise.all([
|
||||
fetch("/api/admin/guests", { credentials: "include" }),
|
||||
fetch("/api/admin/tables", { credentials: "include" }),
|
||||
]);
|
||||
|
||||
const guestsData = await guestsRes.json();
|
||||
const tablesData = await tablesRes.json();
|
||||
|
||||
// Process tables
|
||||
const processedTables: Table[] = (tablesData.tables || []).map(
|
||||
(t: any) => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
capacity: t.capacity || 8,
|
||||
guests: [],
|
||||
})
|
||||
);
|
||||
|
||||
// Process guests
|
||||
const allGuests: Guest[] = (guestsData.guests || [])
|
||||
.filter((g: any) => g.isAttending === true)
|
||||
.map((g: any) => ({
|
||||
id: g.id,
|
||||
name: g.name,
|
||||
surname: g.surname,
|
||||
maxPax: g.maxPax || 1,
|
||||
tableId: g.tableId,
|
||||
dietaryRestrictions: g.dietaryRestrictions,
|
||||
relationship: g.relationship,
|
||||
isAttending: g.isAttending,
|
||||
}));
|
||||
|
||||
// Assign guests to tables
|
||||
allGuests.forEach((guest) => {
|
||||
if (guest.tableId) {
|
||||
const table = processedTables.find((t) => t.id === guest.tableId);
|
||||
if (table) {
|
||||
table.guests.push(guest);
|
||||
} else {
|
||||
setUnseatedGuests((prev) => [...prev, guest]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Unseated guests
|
||||
const unseated = allGuests.filter(
|
||||
(g) => !g.tableId || !processedTables.find((t) => t.id === g.tableId)
|
||||
);
|
||||
|
||||
setTables(processedTables);
|
||||
setUnseatedGuests(unseated);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch seating data:", error);
|
||||
toast({
|
||||
variant: "error",
|
||||
title: "Failed to load seating data",
|
||||
description: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Filter unseated guests by search
|
||||
const filteredUnseated = useMemo(() => {
|
||||
if (!searchQuery) return unseatedGuests;
|
||||
const query = searchQuery.toLowerCase();
|
||||
return unseatedGuests.filter(
|
||||
(g) =>
|
||||
g.name.toLowerCase().includes(query) ||
|
||||
g.surname?.toLowerCase().includes(query)
|
||||
);
|
||||
}, [unseatedGuests, searchQuery]);
|
||||
|
||||
// Dietary summary
|
||||
const dietarySummary = useMemo(() => {
|
||||
const allGuests = [
|
||||
...unseatedGuests,
|
||||
...tables.flatMap((t) => t.guests),
|
||||
];
|
||||
const restrictions: Record<string, number> = {};
|
||||
|
||||
allGuests.forEach((guest) => {
|
||||
if (guest.dietaryRestrictions) {
|
||||
const items = guest.dietaryRestrictions.split(",").map((s) => s.trim());
|
||||
items.forEach((item) => {
|
||||
restrictions[item] = (restrictions[item] || 0) + 1;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return Object.entries(restrictions)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 10);
|
||||
}, [tables, unseatedGuests]);
|
||||
|
||||
// Handle drag events
|
||||
const handleDragStart = (event: DragStartEvent) => {
|
||||
setActiveId(event.active.id as string);
|
||||
};
|
||||
|
||||
const handleDragEnd = async (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
setActiveId(null);
|
||||
|
||||
if (!over) return;
|
||||
|
||||
const guestId = active.id as string;
|
||||
const targetTableId = over.id as string;
|
||||
|
||||
// Find guest
|
||||
let guest: Guest | undefined;
|
||||
let sourceTableId: string | null = null;
|
||||
|
||||
// Check unseated
|
||||
const unseatedIndex = unseatedGuests.findIndex((g) => g.id === guestId);
|
||||
if (unseatedIndex >= 0) {
|
||||
guest = unseatedGuests[unseatedIndex];
|
||||
} else {
|
||||
// Check tables
|
||||
for (const table of tables) {
|
||||
const idx = table.guests.findIndex((g) => g.id === guestId);
|
||||
if (idx >= 0) {
|
||||
guest = table.guests[idx];
|
||||
sourceTableId = table.id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!guest) return;
|
||||
|
||||
// If dropping on "unseated" area
|
||||
if (targetTableId === "unseated") {
|
||||
if (sourceTableId) {
|
||||
// Move from table to unseated
|
||||
setTables((prev) =>
|
||||
prev.map((t) =>
|
||||
t.id === sourceTableId
|
||||
? { ...t, guests: t.guests.filter((g) => g.id !== guestId) }
|
||||
: t
|
||||
)
|
||||
);
|
||||
setUnseatedGuests((prev) => [...prev, { ...guest!, tableId: undefined }]);
|
||||
|
||||
// Update in database
|
||||
await updateGuestTable(guestId, null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Moving to a table
|
||||
const targetTable = tables.find((t) => t.id === targetTableId);
|
||||
if (!targetTable) return;
|
||||
|
||||
// Check capacity
|
||||
const currentOccupancy = targetTable.guests.reduce(
|
||||
(sum, g) => sum + g.maxPax,
|
||||
0
|
||||
);
|
||||
if (currentOccupancy + guest.maxPax > targetTable.capacity) {
|
||||
toast({
|
||||
variant: "error",
|
||||
title: "Table is full",
|
||||
description: `${targetTable.name} doesn't have enough capacity`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Update state
|
||||
if (unseatedIndex >= 0) {
|
||||
setUnseatedGuests((prev) => prev.filter((g) => g.id !== guestId));
|
||||
} else if (sourceTableId) {
|
||||
setTables((prev) =>
|
||||
prev.map((t) =>
|
||||
t.id === sourceTableId
|
||||
? { ...t, guests: t.guests.filter((g) => g.id !== guestId) }
|
||||
: t
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
setTables((prev) =>
|
||||
prev.map((t) =>
|
||||
t.id === targetTableId
|
||||
? { ...t, guests: [...t.guests, { ...guest!, tableId: targetTableId }] }
|
||||
: t
|
||||
)
|
||||
);
|
||||
|
||||
// Update in database
|
||||
await updateGuestTable(guestId, targetTableId);
|
||||
};
|
||||
|
||||
const updateGuestTable = async (guestId: string, tableId: string | null) => {
|
||||
try {
|
||||
const response = await fetch(`/api/admin/guests/${guestId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ tableId }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to update guest table");
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
variant: "error",
|
||||
title: "Failed to save",
|
||||
description: "Could not update guest's table assignment",
|
||||
});
|
||||
// Refresh to restore correct state
|
||||
fetchData();
|
||||
}
|
||||
};
|
||||
|
||||
// Add table
|
||||
const handleAddTable = async () => {
|
||||
if (!newTableName.trim()) return;
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/admin/tables", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
name: newTableName,
|
||||
capacity: newTableCapacity,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error("Failed to create table");
|
||||
|
||||
const result = await response.json();
|
||||
setTables((prev) => [
|
||||
...prev,
|
||||
{ id: result.table.id, name: newTableName, capacity: newTableCapacity, guests: [] },
|
||||
]);
|
||||
|
||||
setShowAddTable(false);
|
||||
setNewTableName("");
|
||||
setNewTableCapacity(8);
|
||||
|
||||
toast({
|
||||
variant: "success",
|
||||
title: "Table created",
|
||||
description: `${newTableName} has been added`,
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
variant: "error",
|
||||
title: "Failed to create table",
|
||||
description: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Delete table
|
||||
const handleDeleteTable = async (tableId: string) => {
|
||||
const table = tables.find((t) => t.id === tableId);
|
||||
if (!table) return;
|
||||
|
||||
if (table.guests.length > 0) {
|
||||
toast({
|
||||
variant: "error",
|
||||
title: "Cannot delete table",
|
||||
description: "Please remove all guests from the table first",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/admin/tables/${tableId}`, {
|
||||
method: "DELETE",
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error("Failed to delete table");
|
||||
|
||||
setTables((prev) => prev.filter((t) => t.id !== tableId));
|
||||
|
||||
toast({
|
||||
variant: "success",
|
||||
title: "Table deleted",
|
||||
description: `${table.name} has been removed`,
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
variant: "error",
|
||||
title: "Failed to delete table",
|
||||
description: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Export seating chart
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/admin/seating/export", {
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error("Export failed");
|
||||
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `seating-chart-${new Date().toISOString().split("T")[0]}.pdf`;
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
toast({
|
||||
variant: "success",
|
||||
title: "Export complete",
|
||||
description: "Seating chart has been downloaded",
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
variant: "error",
|
||||
title: "Export failed",
|
||||
description: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Find active guest for drag overlay
|
||||
const activeGuest = useMemo(() => {
|
||||
if (!activeId) return null;
|
||||
const unseated = unseatedGuests.find((g) => g.id === activeId);
|
||||
if (unseated) return unseated;
|
||||
for (const table of tables) {
|
||||
const guest = table.guests.find((g) => g.id === activeId);
|
||||
if (guest) return guest;
|
||||
}
|
||||
return null;
|
||||
}, [activeId, unseatedGuests, tables]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-48" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-heading text-wedding-evergreen">
|
||||
Seating Chart
|
||||
</h2>
|
||||
<p className="text-wedding-moss/70 font-body">
|
||||
Drag and drop guests to assign tables
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setShowAddTable(true)}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add Table
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleExport}>
|
||||
<Printer className="h-4 w-4 mr-1" />
|
||||
Print
|
||||
</Button>
|
||||
<div className="flex border rounded-lg overflow-hidden">
|
||||
<Button
|
||||
variant={viewMode === "grid" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
className="rounded-none"
|
||||
onClick={() => setViewMode("grid")}
|
||||
>
|
||||
<LayoutGrid className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === "list" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
className="rounded-none"
|
||||
onClick={() => setViewMode("list")}
|
||||
>
|
||||
<List className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dietary Summary */}
|
||||
{dietarySummary.length > 0 && (
|
||||
<Card variant="glass">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-heading flex items-center gap-2">
|
||||
<Utensils className="h-4 w-4" />
|
||||
Dietary Requirements Summary
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{dietarySummary.map(([restriction, count]) => (
|
||||
<Badge key={restriction} variant="secondary">
|
||||
{restriction}: {count}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className="grid gap-6 lg:grid-cols-4">
|
||||
{/* Unseated Guests */}
|
||||
<div className="lg:col-span-1">
|
||||
<Card variant="glass" className="sticky top-4">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="font-heading text-lg flex items-center gap-2">
|
||||
<Users className="h-5 w-5" />
|
||||
Unseated Guests
|
||||
</CardTitle>
|
||||
<CardDescription className="font-body">
|
||||
{filteredUnseated.length} guests to assign
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-wedding-moss/40" />
|
||||
<Input
|
||||
placeholder="Search guests..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="space-y-2 max-h-[60vh] overflow-y-auto pr-2"
|
||||
>
|
||||
{filteredUnseated.length === 0 ? (
|
||||
<p className="text-sm text-wedding-moss/50 text-center py-4">
|
||||
{searchQuery ? "No guests found" : "All guests are seated!"}
|
||||
</p>
|
||||
) : (
|
||||
filteredUnseated.map((guest) => (
|
||||
<DraggableGuest key={guest.id} guest={guest} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Tables */}
|
||||
<div className="lg:col-span-3">
|
||||
<div
|
||||
className={cn(
|
||||
"grid gap-4",
|
||||
viewMode === "grid" ? "md:grid-cols-2 xl:grid-cols-3" : "grid-cols-1"
|
||||
)}
|
||||
>
|
||||
{tables.map((table) => (
|
||||
<DroppableTable
|
||||
key={table.id}
|
||||
table={table}
|
||||
onEdit={() => setShowEditTable(table)}
|
||||
onDelete={() => handleDeleteTable(table.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Drag Overlay */}
|
||||
<DragOverlay>
|
||||
{activeGuest && <DraggableGuest guest={activeGuest} isOverlay />}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
|
||||
{/* Add Table Dialog */}
|
||||
<Dialog open={showAddTable} onOpenChange={setShowAddTable}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="font-heading">Add New Table</DialogTitle>
|
||||
<DialogDescription className="font-body">
|
||||
Create a new table for your seating chart
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tableName">Table Name</Label>
|
||||
<Input
|
||||
id="tableName"
|
||||
value={newTableName}
|
||||
onChange={(e) => setNewTableName(e.target.value)}
|
||||
placeholder="e.g., Table 1, Head Table, Family Table"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="capacity">Capacity</Label>
|
||||
<Input
|
||||
id="capacity"
|
||||
type="number"
|
||||
min={1}
|
||||
max={20}
|
||||
value={newTableCapacity}
|
||||
onChange={(e) => setNewTableCapacity(parseInt(e.target.value) || 8)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowAddTable(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleAddTable}>Create Table</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Edit Table Dialog */}
|
||||
<Dialog open={!!showEditTable} onOpenChange={() => setShowEditTable(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="font-heading">Edit Table</DialogTitle>
|
||||
<DialogDescription className="font-body">
|
||||
Update table details
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{showEditTable && (
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="editTableName">Table Name</Label>
|
||||
<Input
|
||||
id="editTableName"
|
||||
defaultValue={showEditTable.name}
|
||||
onChange={(e) =>
|
||||
setShowEditTable({ ...showEditTable, name: e.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="editCapacity">Capacity</Label>
|
||||
<Input
|
||||
id="editCapacity"
|
||||
type="number"
|
||||
min={1}
|
||||
max={20}
|
||||
defaultValue={showEditTable.capacity}
|
||||
onChange={(e) =>
|
||||
setShowEditTable({
|
||||
...showEditTable,
|
||||
capacity: parseInt(e.target.value) || 8,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{/* Dietary summary for this table */}
|
||||
{showEditTable.guests.some((g) => g.dietaryRestrictions) && (
|
||||
<div className="space-y-2">
|
||||
<Label>Dietary Requirements</Label>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{showEditTable.guests
|
||||
.filter((g) => g.dietaryRestrictions)
|
||||
.map((g) => (
|
||||
<Badge key={g.id} variant="outline">
|
||||
{g.name}: {g.dietaryRestrictions}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowEditTable(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
if (!showEditTable) return;
|
||||
try {
|
||||
await fetch(`/api/admin/tables/${showEditTable.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
name: showEditTable.name,
|
||||
capacity: showEditTable.capacity,
|
||||
}),
|
||||
});
|
||||
setTables((prev) =>
|
||||
prev.map((t) =>
|
||||
t.id === showEditTable.id
|
||||
? { ...t, name: showEditTable.name, capacity: showEditTable.capacity }
|
||||
: t
|
||||
)
|
||||
);
|
||||
setShowEditTable(null);
|
||||
toast({
|
||||
variant: "success",
|
||||
title: "Table updated",
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
variant: "error",
|
||||
title: "Failed to update table",
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -777,21 +777,21 @@ export default function GuestDashboard() {
|
||||
{ACT_2_SECTION_IDS.map((id) => {
|
||||
const { component: Component, lazy } = SECTION_BY_ID[id];
|
||||
return (
|
||||
<StaggeredItem key={id}>
|
||||
<motion.section
|
||||
id={id}
|
||||
variants={itemVariants as any}
|
||||
className="scroll-mt-24"
|
||||
>
|
||||
{lazy ? (
|
||||
<LazySectionWrapper>
|
||||
<StaggeredItem key={id}>
|
||||
<motion.section
|
||||
id={id}
|
||||
variants={itemVariants as any}
|
||||
className="scroll-mt-24"
|
||||
>
|
||||
{lazy ? (
|
||||
<LazySectionWrapper>
|
||||
<Component />
|
||||
</LazySectionWrapper>
|
||||
) : (
|
||||
<Component />
|
||||
</LazySectionWrapper>
|
||||
) : (
|
||||
<Component />
|
||||
)}
|
||||
</motion.section>
|
||||
</StaggeredItem>
|
||||
)}
|
||||
</motion.section>
|
||||
</StaggeredItem>
|
||||
);
|
||||
})}
|
||||
</StaggeredList>
|
||||
@@ -808,21 +808,21 @@ export default function GuestDashboard() {
|
||||
{ACT_3_SECTION_IDS.map((id) => {
|
||||
const { component: Component, lazy } = SECTION_BY_ID[id];
|
||||
return (
|
||||
<StaggeredItem key={id}>
|
||||
<motion.section
|
||||
id={id}
|
||||
variants={itemVariants as any}
|
||||
className="scroll-mt-24"
|
||||
>
|
||||
{lazy ? (
|
||||
<LazySectionWrapper>
|
||||
<StaggeredItem key={id}>
|
||||
<motion.section
|
||||
id={id}
|
||||
variants={itemVariants as any}
|
||||
className="scroll-mt-24"
|
||||
>
|
||||
{lazy ? (
|
||||
<LazySectionWrapper>
|
||||
<Component />
|
||||
</LazySectionWrapper>
|
||||
) : (
|
||||
<Component />
|
||||
</LazySectionWrapper>
|
||||
) : (
|
||||
<Component />
|
||||
)}
|
||||
</motion.section>
|
||||
</StaggeredItem>
|
||||
)}
|
||||
</motion.section>
|
||||
</StaggeredItem>
|
||||
);
|
||||
})}
|
||||
</StaggeredList>
|
||||
@@ -839,21 +839,21 @@ export default function GuestDashboard() {
|
||||
{ACT_4_SECTION_IDS.map((id) => {
|
||||
const { component: Component, lazy } = SECTION_BY_ID[id];
|
||||
return (
|
||||
<StaggeredItem key={id}>
|
||||
<motion.section
|
||||
id={id}
|
||||
variants={itemVariants as any}
|
||||
className="scroll-mt-24"
|
||||
>
|
||||
{lazy ? (
|
||||
<LazySectionWrapper>
|
||||
<StaggeredItem key={id}>
|
||||
<motion.section
|
||||
id={id}
|
||||
variants={itemVariants as any}
|
||||
className="scroll-mt-24"
|
||||
>
|
||||
{lazy ? (
|
||||
<LazySectionWrapper>
|
||||
<Component />
|
||||
</LazySectionWrapper>
|
||||
) : (
|
||||
<Component />
|
||||
</LazySectionWrapper>
|
||||
) : (
|
||||
<Component />
|
||||
)}
|
||||
</motion.section>
|
||||
</StaggeredItem>
|
||||
)}
|
||||
</motion.section>
|
||||
</StaggeredItem>
|
||||
);
|
||||
})}
|
||||
</StaggeredList>
|
||||
@@ -870,21 +870,21 @@ export default function GuestDashboard() {
|
||||
{ACT_5_SECTION_IDS.map((id) => {
|
||||
const { component: Component, lazy } = SECTION_BY_ID[id];
|
||||
return (
|
||||
<StaggeredItem key={id}>
|
||||
<motion.section
|
||||
id={id}
|
||||
variants={itemVariants as any}
|
||||
className="scroll-mt-24"
|
||||
>
|
||||
{lazy ? (
|
||||
<LazySectionWrapper>
|
||||
<StaggeredItem key={id}>
|
||||
<motion.section
|
||||
id={id}
|
||||
variants={itemVariants as any}
|
||||
className="scroll-mt-24"
|
||||
>
|
||||
{lazy ? (
|
||||
<LazySectionWrapper>
|
||||
<Component />
|
||||
</LazySectionWrapper>
|
||||
) : (
|
||||
<Component />
|
||||
</LazySectionWrapper>
|
||||
) : (
|
||||
<Component />
|
||||
)}
|
||||
</motion.section>
|
||||
</StaggeredItem>
|
||||
)}
|
||||
</motion.section>
|
||||
</StaggeredItem>
|
||||
);
|
||||
})}
|
||||
</StaggeredList>
|
||||
@@ -901,21 +901,21 @@ export default function GuestDashboard() {
|
||||
{ACT_6_SECTION_IDS.map((id) => {
|
||||
const { component: Component, lazy } = SECTION_BY_ID[id];
|
||||
return (
|
||||
<StaggeredItem key={id}>
|
||||
<motion.section
|
||||
id={id}
|
||||
variants={itemVariants as any}
|
||||
className="scroll-mt-24"
|
||||
>
|
||||
{lazy ? (
|
||||
<LazySectionWrapper>
|
||||
<StaggeredItem key={id}>
|
||||
<motion.section
|
||||
id={id}
|
||||
variants={itemVariants as any}
|
||||
className="scroll-mt-24"
|
||||
>
|
||||
{lazy ? (
|
||||
<LazySectionWrapper>
|
||||
<Component />
|
||||
</LazySectionWrapper>
|
||||
) : (
|
||||
<Component />
|
||||
</LazySectionWrapper>
|
||||
) : (
|
||||
<Component />
|
||||
)}
|
||||
</motion.section>
|
||||
</StaggeredItem>
|
||||
)}
|
||||
</motion.section>
|
||||
</StaggeredItem>
|
||||
);
|
||||
})}
|
||||
</StaggeredList>
|
||||
|
||||
Reference in New Issue
Block a user