Skip to Content
ExamplesSchedule Management Example (Cals)

Schedule Management Example (Cals)

스케줄 관리 시스템 구현 예제


개요

Cals의 ScheduleCard와 Calendar를 활용한 종합 스케줄 관리 시스템 예제입니다.

주요 특징

  • ✅ 주간/월간 캘린더 뷰
  • ✅ 일정 생성/수정/삭제
  • ✅ 드래그 앤 드롭
  • ✅ 반복 일정 지원
  • ✅ 알림 설정

전체 코드

"use client"; import { useState } from "react"; import { ScheduleCard } 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 { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Badge } from "@vortex/ui-foundation"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; export default function ScheduleManagement() { const [view, setView] = useState("week"); const [selectedDate, setSelectedDate] = useState(new Date()); const schedules = [ { id: 1, title: "Team Standup", start: "09:00", end: "09:30", type: "meeting", color: "blue", recurring: "daily", }, { id: 2, title: "Project Review", start: "14:00", end: "15:30", type: "meeting", color: "purple", participants: ["Alice", "Bob", "Charlie"], }, { id: 3, title: "Client Call", start: "16:00", end: "17:00", type: "call", color: "green", priority: "high", }, { id: 4, title: "Code Review", start: "10:00", end: "11:00", type: "task", color: "orange", }, ]; const weekDays = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; const timeSlots = Array.from( { length: 24 }, (_, i) => `${i.toString().padStart(2, "0")}:00` ); return ( <Container size="xl" className="py-8 space-y-8"> {/* Header */} <div className="flex items-center justify-between"> <div> <h1 className="text-3xl font-bold">Schedule Management</h1> <p className="text-muted-foreground">종합 스케줄 관리 시스템</p> </div> <div className="flex gap-2"> <Select value={view} onValueChange={setView}> <SelectTrigger className="w-[120px]"> <SelectValue /> </SelectTrigger> <SelectContent> <SelectItem value="day">Day</SelectItem> <SelectItem value="week">Week</SelectItem> <SelectItem value="month">Month</SelectItem> </SelectContent> </Select> <Button>Add Schedule</Button> </div> </div> <div className="grid grid-cols-1 lg:grid-cols-4 gap-6"> {/* Calendar View */} <div className="lg:col-span-3"> <Tabs value={view} onValueChange={setView}> <TabsList> <TabsTrigger value="day">Day</TabsTrigger> <TabsTrigger value="week">Week</TabsTrigger> <TabsTrigger value="month">Month</TabsTrigger> </TabsList> <TabsContent value="week" className="space-y-4"> <Card> <CardHeader> <div className="flex items-center justify-between"> <CardTitle>Week View</CardTitle> <div className="flex gap-2"> <Button variant="outline" size="sm"> Previous </Button> <Button variant="outline" size="sm"> Today </Button> <Button variant="outline" size="sm"> Next </Button> </div> </div> </CardHeader> <CardContent> {/* Week Calendar Grid */} <div className="overflow-x-auto"> <div className="min-w-[800px]"> {/* Header */} <div className="grid grid-cols-8 border-b"> <div className="p-2 text-sm font-medium">Time</div> {weekDays.map((day) => ( <div key={day} className="p-2 text-sm font-medium text-center" > {day} </div> ))} </div> {/* Time Slots */} <div className="divide-y"> {timeSlots.slice(8, 19).map((time) => ( <div key={time} className="grid grid-cols-8 min-h-[60px]" > <div className="p-2 text-sm text-muted-foreground border-r"> {time} </div> {weekDays.map((day, dayIndex) => ( <div key={`${day}-${time}`} className="border-r p-1 relative hover:bg-muted/50 cursor-pointer" > {/* Render schedules for this time slot */} {schedules .filter( (s) => s.start === time && dayIndex === 0 ) .map((schedule) => ( <div key={schedule.id} className={`p-2 rounded text-xs bg-${schedule.color}-100 border-l-2 border-${schedule.color}-500`} > <p className="font-medium"> {schedule.title} </p> <p className="text-muted-foreground"> {schedule.start} - {schedule.end} </p> </div> ))} </div> ))} </div> ))} </div> </div> </div> </CardContent> </Card> </TabsContent> <TabsContent value="day"> <Card> <CardHeader> <CardTitle>Day View</CardTitle> </CardHeader> <CardContent> <div className="space-y-2"> {schedules.map((schedule) => ( <ScheduleCard key={schedule.id} title={schedule.title} start={schedule.start} end={schedule.end} type={schedule.type} > <div className="mt-2 flex items-center gap-2"> <Badge variant="secondary">{schedule.type}</Badge> {schedule.priority && ( <Badge variant="destructive"> {schedule.priority} </Badge> )} {schedule.recurring && ( <Badge variant="outline"> {schedule.recurring} </Badge> )} </div> </ScheduleCard> ))} </div> </CardContent> </Card> </TabsContent> <TabsContent value="month"> <Card> <CardHeader> <CardTitle>Month View</CardTitle> </CardHeader> <CardContent> <div className="grid grid-cols-7 gap-2"> {weekDays.map((day) => ( <div key={day} className="text-center text-sm font-medium p-2" > {day} </div> ))} {Array.from({ length: 35 }, (_, i) => ( <div key={i} className="aspect-square border rounded-lg p-2 hover:bg-muted/50 cursor-pointer" > <div className="text-sm font-medium"> {(i % 30) + 1} </div> {i === 10 && ( <div className="mt-1 space-y-1"> <div className="text-xs bg-blue-100 rounded px-1 truncate"> Team Standup </div> <div className="text-xs bg-purple-100 rounded px-1 truncate"> Project Review </div> </div> )} </div> ))} </div> </CardContent> </Card> </TabsContent> </Tabs> </div> {/* Sidebar */} <div className="lg:col-span-1 space-y-6"> {/* Today's Schedule */} <Card> <CardHeader> <CardTitle>Today's Schedule</CardTitle> </CardHeader> <CardContent> <div className="space-y-3"> {schedules.map((schedule) => ( <div key={schedule.id} className="p-3 border rounded-lg hover:bg-muted/50" > <div className="flex items-start justify-between"> <div className="flex-1"> <p className="font-medium text-sm">{schedule.title}</p> <p className="text-xs text-muted-foreground"> {schedule.start} - {schedule.end} </p> </div> <Badge variant="outline" className="text-xs"> {schedule.type} </Badge> </div> </div> ))} </div> </CardContent> </Card> {/* Upcoming */} <Card> <CardHeader> <CardTitle>Upcoming</CardTitle> </CardHeader> <CardContent> <div className="space-y-3"> <div className="p-3 border rounded-lg"> <p className="font-medium text-sm">Quarterly Review</p> <p className="text-xs text-muted-foreground"> Tomorrow, 10:00 </p> <Badge variant="secondary" className="text-xs mt-2"> meeting </Badge> </div> <div className="p-3 border rounded-lg"> <p className="font-medium text-sm">Product Launch</p> <p className="text-xs text-muted-foreground">Next Week</p> <Badge variant="destructive" className="text-xs mt-2"> high priority </Badge> </div> </div> </CardContent> </Card> {/* Calendar Stats */} <Card> <CardHeader> <CardTitle>This Week</CardTitle> </CardHeader> <CardContent className="space-y-3"> <div className="flex items-center justify-between"> <span className="text-sm text-muted-foreground">Meetings</span> <span className="font-medium">12</span> </div> <div className="flex items-center justify-between"> <span className="text-sm text-muted-foreground">Tasks</span> <span className="font-medium">8</span> </div> <div className="flex items-center justify-between"> <span className="text-sm text-muted-foreground">Hours</span> <span className="font-medium">24.5</span> </div> </CardContent> </Card> </div> </div> </Container> ); }

주요 기능

ScheduleCard 사용

<ScheduleCard title="Team Meeting" start="09:00" end="10:00" type="meeting"> {/* 추가 정보 */} </ScheduleCard>

Props:

  • title: 일정 제목
  • start: 시작 시간
  • end: 종료 시간
  • type: 일정 타입 (meeting, task, call, event)
  • children: 커스텀 컨텐츠

일정 생성

const createSchedule = async (data) => { const response = await fetch("/api/schedules", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title: data.title, start: data.start, end: data.end, type: data.type, recurring: data.recurring, reminders: data.reminders, }), }); return response.json(); };

반복 일정

interface RecurringSchedule { frequency: "daily" | "weekly" | "monthly"; interval: number; endDate?: string; daysOfWeek?: number[]; // 0-6 (Sun-Sat) } const recurringSchedule = { title: "Team Standup", start: "09:00", end: "09:30", recurring: { frequency: "daily", interval: 1, daysOfWeek: [1, 2, 3, 4, 5], // Mon-Fri }, };

드래그 앤 드롭

react-dnd 사용

import { useDrag, useDrop } from "react-dnd"; function DraggableSchedule({ schedule }) { const [{ isDragging }, drag] = useDrag({ type: "SCHEDULE", item: { id: schedule.id }, collect: (monitor) => ({ isDragging: monitor.isDragging(), }), }); return ( <div ref={drag} style={{ opacity: isDragging ? 0.5 : 1 }} className="cursor-move" > <ScheduleCard {...schedule} /> </div> ); } function TimeSlot({ time, onDrop }) { const [{ isOver }, drop] = useDrop({ accept: "SCHEDULE", drop: (item) => onDrop(item.id, time), collect: (monitor) => ({ isOver: monitor.isOver(), }), }); return ( <div ref={drop} className={isOver ? "bg-primary/10" : ""}> {time} </div> ); }

일정 이동 처리

const handleScheduleDrop = async (scheduleId, newTime) => { const schedule = schedules.find((s) => s.id === scheduleId); const duration = calculateDuration(schedule.start, schedule.end); const newEnd = addMinutes(newTime, duration); await updateSchedule(scheduleId, { start: newTime, end: newEnd, }); // 상태 업데이트 setSchedules((prev) => prev.map((s) => s.id === scheduleId ? { ...s, start: newTime, end: newEnd } : s ) ); };

알림 시스템

알림 설정

interface Reminder { type: "email" | "push" | "sms"; before: number; // minutes before event } const scheduleWithReminders = { title: "Important Meeting", start: "14:00", reminders: [ { type: "push", before: 15 }, { type: "email", before: 60 }, ], };

Web Push API

const sendPushNotification = async (schedule) => { if ("Notification" in window && Notification.permission === "granted") { const registration = await navigator.serviceWorker.ready; registration.showNotification(schedule.title, { body: `Starting in 15 minutes at ${schedule.start}`, icon: "/notification-icon.png", badge: "/badge-icon.png", tag: `schedule-${schedule.id}`, requireInteraction: true, }); } };

권한 요청

const requestNotificationPermission = async () => { if ("Notification" in window) { const permission = await Notification.requestPermission(); if (permission === "granted") { console.log("Notification permission granted"); } } };

캘린더 통합

Google Calendar API

const syncWithGoogle = async (schedule) => { const event = { summary: schedule.title, start: { dateTime: `${schedule.date}T${schedule.start}:00`, timeZone: "Asia/Seoul", }, end: { dateTime: `${schedule.date}T${schedule.end}:00`, timeZone: "Asia/Seoul", }, }; const response = await gapi.client.calendar.events.insert({ calendarId: "primary", resource: event, }); return response.result; };

iCal Export

const exportToICal = (schedules) => { let ical = "BEGIN:VCALENDAR\nVERSION:2.0\n"; schedules.forEach((schedule) => { ical += `BEGIN:VEVENT\n`; ical += `SUMMARY:${schedule.title}\n`; ical += `DTSTART:${formatICalDate(schedule.start)}\n`; ical += `DTEND:${formatICalDate(schedule.end)}\n`; ical += `END:VEVENT\n`; }); ical += "END:VCALENDAR"; const blob = new Blob([ical], { type: "text/calendar" }); downloadFile(blob, "schedule.ics"); };

충돌 감지

const detectConflicts = (newSchedule, existingSchedules) => { return existingSchedules.filter((existing) => { const newStart = parseTime(newSchedule.start); const newEnd = parseTime(newSchedule.end); const existingStart = parseTime(existing.start); const existingEnd = parseTime(existing.end); return ( (newStart >= existingStart && newStart < existingEnd) || (newEnd > existingStart && newEnd <= existingEnd) || (newStart <= existingStart && newEnd >= existingEnd) ); }); }; const handleScheduleCreate = async (data) => { const conflicts = detectConflicts(data, schedules); if (conflicts.length > 0) { const confirm = await showConflictDialog(conflicts); if (!confirm) return; } await createSchedule(data); };

접근성

키보드 단축키

useEffect(() => { const handleKeyPress = (e) => { if (e.ctrlKey || e.metaKey) { switch (e.key) { case "n": e.preventDefault(); openNewScheduleDialog(); break; case "d": e.preventDefault(); setView("day"); break; case "w": e.preventDefault(); setView("week"); break; case "m": e.preventDefault(); setView("month"); break; } } }; window.addEventListener("keydown", handleKeyPress); return () => window.removeEventListener("keydown", handleKeyPress); }, []);

ARIA 레이블

<div role="grid" aria-label="Weekly calendar view" aria-readonly="false"> <div role="row"> <div role="gridcell" aria-label="Monday 9:00 AM, Team Standup"> <ScheduleCard title="Team Standup" /> </div> </div> </div>

다음 단계

Last updated on