Untitled
unknown
plain_text
a month ago
29 kB
3
Indexable
import React, { useState, useMemo } from "react";
// Types
interface Booking {
id: string;
guestName: string;
phone: string;
email: string;
date: string;
time: string;
partySize: number;
tableNumber: number | null;
status: "confirmed" | "pending" | "cancelled" | "completed";
notes: string;
createdAt: string;
}
type BookingStatus = Booking["status"];
// Sample data
const initialBookings: Booking[] = [
{
id: "1",
guestName: "Sarah Johnson",
phone: "(555) 123-4567",
email: "[email protected]",
date: "2026-05-19",
time: "18:30",
partySize: 4,
tableNumber: 5,
status: "confirmed",
notes: "Anniversary dinner, window seat preferred",
createdAt: "2026-05-15T10:30:00Z",
},
{
id: "2",
guestName: "Michael Chen",
phone: "(555) 234-5678",
email: "[email protected]",
date: "2026-05-19",
time: "19:00",
partySize: 2,
tableNumber: 3,
status: "confirmed",
notes: "",
createdAt: "2026-05-16T14:20:00Z",
},
{
id: "3",
guestName: "Emma Williams",
phone: "(555) 345-6789",
email: "[email protected]",
date: "2026-05-19",
time: "20:00",
partySize: 6,
tableNumber: null,
status: "pending",
notes: "Birthday celebration, need high chair",
createdAt: "2026-05-17T09:15:00Z",
},
{
id: "4",
guestName: "David Brown",
phone: "(555) 456-7890",
email: "[email protected]",
date: "2026-05-20",
time: "12:30",
partySize: 3,
tableNumber: 8,
status: "confirmed",
notes: "Gluten-free menu required",
createdAt: "2026-05-17T11:45:00Z",
},
{
id: "5",
guestName: "Lisa Martinez",
phone: "(555) 567-8901",
email: "[email protected]",
date: "2026-05-19",
time: "17:00",
partySize: 2,
tableNumber: 1,
status: "cancelled",
notes: "",
createdAt: "2026-05-14T16:30:00Z",
},
{
id: "6",
guestName: "James Wilson",
phone: "(555) 678-9012",
email: "[email protected]",
date: "2026-05-21",
time: "19:30",
partySize: 8,
tableNumber: null,
status: "pending",
notes: "Business dinner, private area if possible",
createdAt: "2026-05-18T08:00:00Z",
},
{
id: "7",
guestName: "Amanda Taylor",
phone: "(555) 789-0123",
email: "[email protected]",
date: "2026-05-18",
time: "20:30",
partySize: 4,
tableNumber: 6,
status: "completed",
notes: "",
createdAt: "2026-05-12T13:20:00Z",
},
];
// Status badge component
const StatusBadge: React.FC<{ status: BookingStatus }> = ({ status }) => {
const styles: Record<BookingStatus, string> = {
confirmed: "bg-green-100 text-green-800 border-green-200",
pending: "bg-yellow-100 text-yellow-800 border-yellow-200",
cancelled: "bg-red-100 text-red-800 border-red-200",
completed: "bg-gray-100 text-gray-800 border-gray-200",
};
return (
<span
className={`px-2.5 py-1 text-xs font-medium rounded-full border ${styles[status]}`}
>
{status.charAt(0).toUpperCase() + status.slice(1)}
</span>
);
};
// Stat card component
const StatCard: React.FC<{
title: string;
value: number | string;
icon: React.ReactNode;
color: string;
}> = ({ title, value, icon, color }) => (
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-5 hover:shadow-md transition-shadow">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-500">{title}</p>
<p className="text-2xl font-bold text-gray-900 mt-1">{value}</p>
</div>
<div className={`p-3 rounded-lg ${color}`}>{icon}</div>
</div>
</div>
);
// Icons
const CalendarIcon = () => (
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
);
const UsersIcon = () => (
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
);
const ClockIcon = () => (
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
);
const CheckCircleIcon = () => (
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
);
const PlusIcon = () => (
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4v16m8-8H4"
/>
</svg>
);
const SearchIcon = () => (
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
);
const XIcon = () => (
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
);
// Booking form modal
const BookingModal: React.FC<{
booking: Booking | null;
isOpen: boolean;
onClose: () => void;
onSave: (booking: Booking) => void;
}> = ({ booking, isOpen, onClose, onSave }) => {
const [formData, setFormData] = useState<Omit<Booking, "id" | "createdAt">>({
guestName: "",
phone: "",
email: "",
date: "",
time: "",
partySize: 2,
tableNumber: null,
status: "pending",
notes: "",
});
React.useEffect(() => {
if (booking) {
setFormData({
guestName: booking.guestName,
phone: booking.phone,
email: booking.email,
date: booking.date,
time: booking.time,
partySize: booking.partySize,
tableNumber: booking.tableNumber,
status: booking.status,
notes: booking.notes,
});
} else {
setFormData({
guestName: "",
phone: "",
email: "",
date: "",
time: "",
partySize: 2,
tableNumber: null,
status: "pending",
notes: "",
});
}
}, [booking, isOpen]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSave({
id: booking?.id || Date.now().toString(),
...formData,
createdAt: booking?.createdAt || new Date().toISOString(),
});
onClose();
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
<div className="bg-white rounded-2xl shadow-xl w-full max-w-lg max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between p-5 border-b border-gray-100">
<h2 className="text-xl font-semibold text-gray-900">
{booking ? "Edit Booking" : "New Booking"}
</h2>
<button
onClick={onClose}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
>
<XIcon />
</button>
</div>
<form onSubmit={handleSubmit} className="p-5 space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-1">
Guest Name *
</label>
<input
type="text"
required
value={formData.guestName}
onChange={(e) =>
setFormData({ ...formData, guestName: e.target.value })
}
className="w-full px-3 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Phone *
</label>
<input
type="tel"
required
value={formData.phone}
onChange={(e) =>
setFormData({ ...formData, phone: e.target.value })
}
className="w-full px-3 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Email
</label>
<input
type="email"
value={formData.email}
onChange={(e) =>
setFormData({ ...formData, email: e.target.value })
}
className="w-full px-3 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Date *
</label>
<input
type="date"
required
value={formData.date}
onChange={(e) =>
setFormData({ ...formData, date: e.target.value })
}
className="w-full px-3 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Time *
</label>
<input
type="time"
required
value={formData.time}
onChange={(e) =>
setFormData({ ...formData, time: e.target.value })
}
className="w-full px-3 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Party Size *
</label>
<input
type="number"
required
min={1}
max={20}
value={formData.partySize}
onChange={(e) =>
setFormData({
...formData,
partySize: parseInt(e.target.value),
})
}
className="w-full px-3 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Table Number
</label>
<input
type="number"
min={1}
value={formData.tableNumber || ""}
onChange={(e) =>
setFormData({
...formData,
tableNumber: e.target.value
? parseInt(e.target.value)
: null,
})
}
className="w-full px-3 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none"
placeholder="Assign later"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Status
</label>
<select
value={formData.status}
onChange={(e) =>
setFormData({
...formData,
status: e.target.value as BookingStatus,
})
}
className="w-full px-3 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none bg-white"
>
<option value="pending">Pending</option>
<option value="confirmed">Confirmed</option>
<option value="completed">Completed</option>
<option value="cancelled">Cancelled</option>
</select>
</div>
<div className="col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-1">
Notes
</label>
<textarea
value={formData.notes}
onChange={(e) =>
setFormData({ ...formData, notes: e.target.value })
}
rows={3}
className="w-full px-3 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none resize-none"
placeholder="Special requests, dietary requirements, etc."
/>
</div>
</div>
<div className="flex gap-3 pt-4">
<button
type="button"
onClick={onClose}
className="flex-1 px-4 py-2.5 border border-gray-200 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors font-medium"
>
Cancel
</button>
<button
type="submit"
className="flex-1 px-4 py-2.5 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors font-medium"
>
{booking ? "Save Changes" : "Create Booking"}
</button>
</div>
</form>
</div>
</div>
);
};
// Main App
export default function App() {
const [bookings, setBookings] = useState<Booking[]>(initialBookings);
const [searchQuery, setSearchQuery] = useState("");
const [statusFilter, setStatusFilter] = useState<BookingStatus | "all">(
"all"
);
const [dateFilter, setDateFilter] = useState("");
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingBooking, setEditingBooking] = useState<Booking | null>(null);
const today = "2026-05-19";
// Calculate stats
const stats = useMemo(() => {
const todayBookings = bookings.filter((b) => b.date === today);
const confirmed = todayBookings.filter(
(b) => b.status === "confirmed"
).length;
const pending = todayBookings.filter((b) => b.status === "pending").length;
const totalGuests = todayBookings
.filter((b) => b.status === "confirmed" || b.status === "pending")
.reduce((sum, b) => sum + b.partySize, 0);
return { todayTotal: todayBookings.length, confirmed, pending, totalGuests };
}, [bookings]);
// Filter bookings
const filteredBookings = useMemo(() => {
return bookings.filter((booking) => {
const matchesSearch =
booking.guestName.toLowerCase().includes(searchQuery.toLowerCase()) ||
booking.phone.includes(searchQuery) ||
booking.email.toLowerCase().includes(searchQuery.toLowerCase());
const matchesStatus =
statusFilter === "all" || booking.status === statusFilter;
const matchesDate = !dateFilter || booking.date === dateFilter;
return matchesSearch && matchesStatus && matchesDate;
});
}, [bookings, searchQuery, statusFilter, dateFilter]);
const handleSaveBooking = (booking: Booking) => {
setBookings((prev) => {
const exists = prev.find((b) => b.id === booking.id);
if (exists) {
return prev.map((b) => (b.id === booking.id ? booking : b));
}
return [...prev, booking];
});
};
const handleDeleteBooking = (id: string) => {
if (confirm("Are you sure you want to delete this booking?")) {
setBookings((prev) => prev.filter((b) => b.id !== id));
}
};
const handleStatusChange = (id: string, status: BookingStatus) => {
setBookings((prev) =>
prev.map((b) => (b.id === id ? { ...b, status } : b))
);
};
const openEditModal = (booking: Booking) => {
setEditingBooking(booking);
setIsModalOpen(true);
};
const openNewModal = () => {
setEditingBooking(null);
setIsModalOpen(true);
};
const formatTime = (time: string) => {
const [hours, minutes] = time.split(":");
const h = parseInt(hours);
const ampm = h >= 12 ? "PM" : "AM";
const hour12 = h % 12 || 12;
return `${hour12}:${minutes} ${ampm}`;
};
const formatDate = (dateStr: string) => {
const date = new Date(dateStr + "T00:00:00");
return date.toLocaleDateString("en-US", {
weekday: "short",
month: "short",
day: "numeric",
});
};
return (
<div className="min-h-screen bg-gray-50">
{/* Header */}
<header className="bg-white border-b border-gray-100 sticky top-0 z-40">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-indigo-600 rounded-lg flex items-center justify-center">
<span className="text-white text-xl">🍽️</span>
</div>
<div>
<h1 className="text-xl font-bold text-gray-900">
Booking Manager
</h1>
<p className="text-xs text-gray-500">
{formatDate(today)} • Today
</p>
</div>
</div>
<button
onClick={openNewModal}
className="flex items-center gap-2 px-4 py-2.5 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors font-medium shadow-sm"
>
<PlusIcon />
<span className="hidden sm:inline">New Booking</span>
</button>
</div>
</div>
</header>
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
{/* Stats */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<StatCard
title="Today's Bookings"
value={stats.todayTotal}
icon={<CalendarIcon />}
color="bg-indigo-100 text-indigo-600"
/>
<StatCard
title="Confirmed"
value={stats.confirmed}
icon={<CheckCircleIcon />}
color="bg-green-100 text-green-600"
/>
<StatCard
title="Pending"
value={stats.pending}
icon={<ClockIcon />}
color="bg-yellow-100 text-yellow-600"
/>
<StatCard
title="Expected Guests"
value={stats.totalGuests}
icon={<UsersIcon />}
color="bg-purple-100 text-purple-600"
/>
</div>
{/* Filters */}
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-4 mb-6">
<div className="flex flex-col sm:flex-row gap-4">
<div className="flex-1 relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-gray-400">
<SearchIcon />
</div>
<input
type="text"
placeholder="Search by name, phone, or email..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-4 py-2.5 border border-gray-200 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none"
/>
</div>
<div className="flex gap-3">
<select
value={statusFilter}
onChange={(e) =>
setStatusFilter(e.target.value as BookingStatus | "all")
}
className="px-4 py-2.5 border border-gray-200 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none bg-white min-w-[140px]"
>
<option value="all">All Status</option>
<option value="confirmed">Confirmed</option>
<option value="pending">Pending</option>
<option value="completed">Completed</option>
<option value="cancelled">Cancelled</option>
</select>
<input
type="date"
value={dateFilter}
onChange={(e) => setDateFilter(e.target.value)}
className="px-4 py-2.5 border border-gray-200 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none"
/>
{(dateFilter || statusFilter !== "all" || searchQuery) && (
<button
onClick={() => {
setDateFilter("");
setStatusFilter("all");
setSearchQuery("");
}}
className="px-4 py-2.5 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors"
>
Clear
</button>
)}
</div>
</div>
</div>
{/* Bookings List */}
<div className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="bg-gray-50 border-b border-gray-100">
<th className="text-left px-5 py-3.5 text-xs font-semibold text-gray-500 uppercase tracking-wider">
Guest
</th>
<th className="text-left px-5 py-3.5 text-xs font-semibold text-gray-500 uppercase tracking-wider">
Date & Time
</th>
<th className="text-left px-5 py-3.5 text-xs font-semibold text-gray-500 uppercase tracking-wider">
Party
</th>
<th className="text-left px-5 py-3.5 text-xs font-semibold text-gray-500 uppercase tracking-wider">
Table
</th>
<th className="text-left px-5 py-3.5 text-xs font-semibold text-gray-500 uppercase tracking-wider">
Status
</th>
<th className="text-right px-5 py-3.5 text-xs font-semibold text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{filteredBookings.length === 0 ? (
<tr>
<td
colSpan={6}
className="px-5 py-12 text-center text-gray-500"
>
<div className="flex flex-col items-center">
<CalendarIcon />
<p className="mt-2 font-medium">No bookings found</p>
<p className="text-sm">
Try adjusting your filters or create a new booking
</p>
</div>
</td>
</tr>
) : (
filteredBookings.map((booking) => (
<tr
key={booking.id}
className="hover:bg-gray-50 transition-colors"
>
<td className="px-5 py-4">
<div>
<p className="font-medium text-gray-900">
{booking.guestName}
</p>
<p className="text-sm text-gray-500">
{booking.phone}
</p>
{booking.notes && (
<p className="text-xs text-indigo-600 mt-1 truncate max-w-[200px]">
📝 {booking.notes}
</p>
)}
</div>
</td>
<td className="px-5 py-4">
<p className="font-medium text-gray-900">
{formatDate(booking.date)}
</p>
<p className="text-sm text-gray-500">
{formatTime(booking.time)}
</p>
</td>
<td className="px-5 py-4">
<span className="inline-flex items-center gap-1.5 text-gray-900">
<UsersIcon />
{booking.partySize}
</span>
</td>
<td className="px-5 py-4">
{booking.tableNumber ? (
<span className="inline-flex items-center justify-center w-8 h-8 bg-indigo-100 text-indigo-700 rounded-lg font-semibold text-sm">
{booking.tableNumber}
</span>
) : (
<span className="text-gray-400 text-sm">—</span>
)}
</td>
<td className="px-5 py-4">
<select
value={booking.status}
onChange={(e) =>
handleStatusChange(
booking.id,
e.target.value as BookingStatus
)
}
className="appearance-none bg-transparent border-0 p-0 focus:ring-0 cursor-pointer"
>
<option value="pending">🟡 Pending</option>
<option value="confirmed">🟢 Confirmed</option>
<option value="completed">⚫ Completed</option>
<option value="cancelled">🔴 Cancelled</option>
</select>
</td>
<td className="px-5 py-4 text-right">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => openEditModal(booking)}
className="px-3 py-1.5 text-sm text-indigo-600 hover:bg-indigo-50 rounded-lg transition-colors font-medium"
>
Edit
</button>
<button
onClick={() => handleDeleteBooking(booking.id)}
className="px-3 py-1.5 text-sm text-red-600 hover:bg-red-50 rounded-lg transition-colors font-medium"
>
Delete
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{filteredBookings.length > 0 && (
<div className="px-5 py-3 bg-gray-50 border-t border-gray-100 text-sm text-gray-500">
Showing {filteredBookings.length} of {bookings.length} bookings
</div>
)}
</div>
</main>
{/* Modal */}
<BookingModal
booking={editingBooking}
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
onSave={handleSaveBooking}
/>
</div>
);
}
Editor is loading...
Leave a Comment