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