Booking System Example (Cals)
예약 시스템 구현 예제
개요
Cals의 BookingCard 컴포넌트를 활용한 실시간 예약 시스템 예제입니다.
주요 특징
- ✅ 실시간 예약 가능 여부 확인
- ✅ 다양한 자원 타입 관리
- ✅ 캘린더 통합
- ✅ 예약 확인 플로우
- ✅ 충돌 방지
전체 코드
"use client";
import { useState } from "react";
import { BookingCard } from "@vortex/ui-cals";
import { Container } from "@/components/ui/container";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Button } from "@vortex/ui-foundation";
import { Calendar } from "@/components/ui/calendar";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Badge } from "@vortex/ui-foundation";
export default function BookingSystem() {
const [selectedDate, setSelectedDate] = useState(new Date());
const [selectedTime, setSelectedTime] = useState("");
const [selectedRoom, setSelectedRoom] = useState(null);
const rooms = [
{
id: 1,
title: "Conference Room A",
capacity: 10,
equipment: ["Projector", "Whiteboard", "Video Conference"],
available: true,
floor: "3F",
},
{
id: 2,
title: "Meeting Room B",
capacity: 6,
equipment: ["TV", "Whiteboard"],
available: true,
floor: "2F",
},
{
id: 3,
title: "Executive Suite",
capacity: 4,
equipment: ["Projector", "Video Conference", "Coffee Machine"],
available: false,
floor: "5F",
},
{
id: 4,
title: "Workshop Space",
capacity: 20,
equipment: ["Projector", "Sound System", "Stage"],
available: true,
floor: "1F",
},
];
const timeSlots = [
"09:00",
"10:00",
"11:00",
"12:00",
"13:00",
"14:00",
"15:00",
"16:00",
"17:00",
];
const handleBooking = () => {
if (!selectedRoom || !selectedTime) {
alert("Please select a room and time slot");
return;
}
alert(
`Booking confirmed:\n${
selectedRoom.title
}\nDate: ${selectedDate.toLocaleDateString()}\nTime: ${selectedTime}`
);
};
return (
<Container size="xl" className="py-8 space-y-8">
{/* Header */}
<div>
<h1 className="text-3xl font-bold">Room Booking System</h1>
<p className="text-muted-foreground">실시간 회의실 예약 시스템</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Calendar and Time Selection */}
<div className="lg:col-span-1 space-y-6">
<Card>
<CardHeader>
<CardTitle>Select Date</CardTitle>
</CardHeader>
<CardContent>
<Calendar
mode="single"
selected={selectedDate}
onSelect={setSelectedDate}
className="rounded-md border"
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Select Time</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-3 gap-2">
{timeSlots.map((time) => (
<Button
key={time}
variant={selectedTime === time ? "default" : "outline"}
size="sm"
onClick={() => setSelectedTime(time)}
>
{time}
</Button>
))}
</div>
</CardContent>
</Card>
{/* Booking Summary */}
{selectedRoom && selectedTime && (
<Card className="border-primary">
<CardHeader>
<CardTitle>Booking Summary</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<p className="text-sm text-muted-foreground">Room</p>
<p className="font-medium">{selectedRoom.title}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Date</p>
<p className="font-medium">
{selectedDate.toLocaleDateString()}
</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Time</p>
<p className="font-medium">{selectedTime}</p>
</div>
<Button className="w-full" onClick={handleBooking}>
Confirm Booking
</Button>
</CardContent>
</Card>
)}
</div>
{/* Available Rooms */}
<div className="lg:col-span-2">
<Card>
<CardHeader>
<CardTitle>Available Rooms</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{rooms.map((room) => (
<BookingCard
key={room.id}
title={room.title}
capacity={room.capacity}
available={room.available}
onClick={() => room.available && setSelectedRoom(room)}
className={
selectedRoom?.id === room.id ? "border-primary" : ""
}
>
<div className="space-y-2 mt-4">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
Floor
</span>
<Badge variant="secondary">{room.floor}</Badge>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
Capacity
</span>
<span className="text-sm font-medium">
{room.capacity} people
</span>
</div>
<div>
<p className="text-sm text-muted-foreground mb-2">
Equipment
</p>
<div className="flex flex-wrap gap-1">
{room.equipment.map((item) => (
<Badge
key={item}
variant="outline"
className="text-xs"
>
{item}
</Badge>
))}
</div>
</div>
<div className="pt-2">
{room.available ? (
<Badge className="bg-green-500">Available</Badge>
) : (
<Badge variant="destructive">Booked</Badge>
)}
</div>
</div>
</BookingCard>
))}
</div>
</CardContent>
</Card>
{/* Today's Schedule */}
<Card className="mt-6">
<CardHeader>
<CardTitle>Today's Schedule</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
<div className="flex items-center justify-between p-3 border rounded-lg">
<div>
<p className="font-medium">Conference Room A</p>
<p className="text-sm text-muted-foreground">
Team Meeting
</p>
</div>
<div className="text-right">
<p className="text-sm font-medium">10:00 - 11:00</p>
<Badge variant="secondary">In Progress</Badge>
</div>
</div>
<div className="flex items-center justify-between p-3 border rounded-lg">
<div>
<p className="font-medium">Executive Suite</p>
<p className="text-sm text-muted-foreground">
Board Meeting
</p>
</div>
<div className="text-right">
<p className="text-sm font-medium">14:00 - 16:00</p>
<Badge>Upcoming</Badge>
</div>
</div>
<div className="flex items-center justify-between p-3 border rounded-lg">
<div>
<p className="font-medium">Workshop Space</p>
<p className="text-sm text-muted-foreground">
Training Session
</p>
</div>
<div className="text-right">
<p className="text-sm font-medium">09:00 - 12:00</p>
<Badge variant="outline">Completed</Badge>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
</Container>
);
}주요 기능
BookingCard 컴포넌트
<BookingCard
title="Conference Room A"
capacity={10}
available={true}
onClick={() => handleRoomSelect(room)}
>
{/* 커스텀 컨텐츠 */}
</BookingCard>Props:
title: 자원 이름capacity: 수용 인원available: 예약 가능 여부onClick: 선택 핸들러children: 추가 정보 표시
실시간 가용성 확인
const checkAvailability = async (roomId, date, time) => {
const response = await fetch(`/api/bookings/check`, {
method: "POST",
body: JSON.stringify({ roomId, date, time }),
});
const { available } = await response.json();
return available;
};예약 생성
const createBooking = async (bookingData) => {
const response = await fetch("/api/bookings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
roomId: bookingData.roomId,
date: bookingData.date,
time: bookingData.time,
duration: bookingData.duration,
userId: currentUser.id,
}),
});
if (!response.ok) {
throw new Error("Booking failed");
}
return response.json();
};캘린더 통합
react-calendar 사용
import { Calendar } from "@/components/ui/calendar";
function BookingCalendar() {
const [date, setDate] = useState(new Date());
return (
<Calendar
mode="single"
selected={date}
onSelect={setDate}
disabled={(date) => date < new Date()}
/>
);
}시간대 선택
function TimeSlotSelector({ selectedTime, onSelect, bookedSlots }) {
const timeSlots = generateTimeSlots(9, 18); // 09:00 - 18:00
return (
<div className="grid grid-cols-3 gap-2">
{timeSlots.map((time) => (
<Button
key={time}
variant={selectedTime === time ? "default" : "outline"}
disabled={bookedSlots.includes(time)}
onClick={() => onSelect(time)}
>
{time}
</Button>
))}
</div>
);
}충돌 방지
낙관적 잠금 (Optimistic Locking)
const handleBooking = async () => {
try {
// 1. 최신 상태 확인
const isAvailable = await checkAvailability(roomId, date, time);
if (!isAvailable) {
alert("This slot is no longer available");
return;
}
// 2. 예약 생성
await createBooking({
roomId,
date,
time,
version: currentVersion, // 버전 체크
});
alert("Booking confirmed!");
} catch (error) {
if (error.code === "CONFLICT") {
alert("Someone just booked this slot");
}
}
};실시간 업데이트 (WebSocket)
useEffect(() => {
const ws = new WebSocket("ws://api.example.com/bookings");
ws.onmessage = (event) => {
const { type, data } = JSON.parse(event.data);
if (type === "BOOKING_CREATED") {
// 예약 목록 업데이트
setBookings((prev) => [...prev, data]);
}
};
return () => ws.close();
}, []);데이터 구조
Booking 모델
interface Booking {
id: string;
roomId: string;
userId: string;
date: string;
startTime: string;
endTime: string;
purpose: string;
attendees: number;
status: "pending" | "confirmed" | "cancelled";
createdAt: string;
}Room 모델
interface Room {
id: string;
title: string;
capacity: number;
floor: string;
equipment: string[];
available: boolean;
imageUrl?: string;
}API 라우트 예제
GET /api/bookings
// app/api/bookings/route.ts
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const date = searchParams.get("date");
const roomId = searchParams.get("roomId");
const bookings = await db.bookings.findMany({
where: {
date,
roomId,
status: "confirmed",
},
});
return Response.json(bookings);
}POST /api/bookings
export async function POST(request: Request) {
const body = await request.json();
// 충돌 확인
const conflict = await db.bookings.findFirst({
where: {
roomId: body.roomId,
date: body.date,
startTime: { lte: body.endTime },
endTime: { gte: body.startTime },
},
});
if (conflict) {
return Response.json({ error: "Time slot not available" }, { status: 409 });
}
const booking = await db.bookings.create({
data: body,
});
return Response.json(booking);
}접근성
키보드 네비게이션
<BookingCard
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
handleSelect()
}
}}
>ARIA 속성
<div
role="button"
aria-label={`Book ${room.title}, capacity ${room.capacity}`}
aria-disabled={!room.available}
>다음 단계
Last updated on