diff --git a/frontend/components/admin/dashboard/CALENDAR_WIDGET_PLAN.md b/frontend/components/admin/dashboard/CALENDAR_WIDGET_PLAN.md
index 84f2a4dc..e127be43 100644
--- a/frontend/components/admin/dashboard/CALENDAR_WIDGET_PLAN.md
+++ b/frontend/components/admin/dashboard/CALENDAR_WIDGET_PLAN.md
@@ -34,57 +34,57 @@
### ✅ Step 1: 타입 정의
-- [ ] `CalendarConfig` 인터페이스 정의
-- [ ] `types.ts`에 달력 설정 타입 추가
-- [ ] 요소 타입에 'calendar' subtype 추가
+- [x] `CalendarConfig` 인터페이스 정의
+- [x] `types.ts`에 달력 설정 타입 추가
+- [x] 요소 타입에 'calendar' subtype 추가
### ✅ Step 2: 기본 달력 컴포넌트
-- [ ] `CalendarWidget.tsx` - 메인 위젯 컴포넌트
-- [ ] `MonthView.tsx` - 월간 달력 뷰
-- [ ] `WeekView.tsx` - 주간 달력 뷰 (선택)
-- [ ] 날짜 계산 유틸리티 함수
+- [x] `CalendarWidget.tsx` - 메인 위젯 컴포넌트
+- [x] `MonthView.tsx` - 월간 달력 뷰
+- [x] 날짜 계산 유틸리티 함수 (`calendarUtils.ts`)
+- [ ] `WeekView.tsx` - 주간 달력 뷰 (향후 추가)
### ✅ Step 3: 달력 네비게이션
-- [ ] 이전/다음 월 이동 버튼
-- [ ] 오늘로 돌아가기 버튼
-- [ ] 월/연도 선택 드롭다운
+- [x] 이전/다음 월 이동 버튼
+- [x] 오늘로 돌아가기 버튼
+- [ ] 월/연도 선택 드롭다운 (향후 추가)
### ✅ Step 4: 설정 UI
-- [ ] `CalendarSettings.tsx` - Popover 내장 설정 컴포넌트
-- [ ] 뷰 타입 선택 (월간/주간/일간)
-- [ ] 시작 요일 설정
-- [ ] 테마 선택
-- [ ] 표시 옵션 (주말 강조, 공휴일 등)
+- [x] `CalendarSettings.tsx` - Popover 내장 설정 컴포넌트
+- [x] 뷰 타입 선택 (월간 - 현재 구현)
+- [x] 시작 요일 설정
+- [x] 테마 선택 (light/dark/custom)
+- [x] 표시 옵션 (주말 강조, 공휴일, 오늘 강조)
### ✅ Step 5: 스타일링
-- [ ] 달력 그리드 레이아웃
-- [ ] 날짜 셀 디자인
-- [ ] 오늘 날짜 하이라이트
-- [ ] 주말/평일 구분
-- [ ] 반응형 디자인 (크기별 최적화)
+- [x] 달력 그리드 레이아웃
+- [x] 날짜 셀 디자인
+- [x] 오늘 날짜 하이라이트
+- [x] 주말/평일 구분
+- [x] 반응형 디자인 (크기별 최적화)
### ✅ Step 6: 통합
-- [ ] `DashboardSidebar`에 달력 위젯 추가
-- [ ] `CanvasElement`에서 달력 위젯 렌더링
-- [ ] `DashboardDesigner`에 기본값 설정
+- [x] `DashboardSidebar`에 달력 위젯 추가
+- [x] `CanvasElement`에서 달력 위젯 렌더링
+- [x] `DashboardDesigner`에 기본값 설정
### ✅ Step 7: 공휴일 데이터
-- [ ] 한국 공휴일 데이터 정의
-- [ ] 공휴일 표시 기능
-- [ ] 공휴일 이름 툴팁
+- [x] 한국 공휴일 데이터 정의
+- [x] 공휴일 표시 기능
+- [x] 공휴일 이름 툴팁
### ✅ Step 8: 테스트 및 최적화
-- [ ] 다양한 크기에서 테스트
-- [ ] 날짜 계산 로직 검증
-- [ ] 성능 최적화
-- [ ] 접근성 개선
+- [ ] 다양한 크기에서 테스트 (사용자 테스트 필요)
+- [x] 날짜 계산 로직 검증
+- [ ] 성능 최적화 (필요시)
+- [ ] 접근성 개선 (필요시)
## 기술 스택
diff --git a/frontend/components/admin/dashboard/CanvasElement.tsx b/frontend/components/admin/dashboard/CanvasElement.tsx
index d830263d..aced2eb9 100644
--- a/frontend/components/admin/dashboard/CanvasElement.tsx
+++ b/frontend/components/admin/dashboard/CanvasElement.tsx
@@ -19,6 +19,8 @@ const ExchangeWidget = dynamic(() => import("@/components/dashboard/widgets/Exch
// 시계 위젯 임포트
import { ClockWidget } from "./widgets/ClockWidget";
+// 달력 위젯 임포트
+import { CalendarWidget } from "./widgets/CalendarWidget";
interface CanvasElementProps {
element: DashboardElement;
@@ -130,26 +132,30 @@ export function CanvasElement({
let newX = resizeStart.elementX;
let newY = resizeStart.elementY;
- const minSize = GRID_CONFIG.CELL_SIZE * 2; // 최소 2셀
+ // 최소 크기 설정: 달력은 2x3, 나머지는 2x2
+ const minWidthCells = 2;
+ const minHeightCells = element.type === "widget" && element.subtype === "calendar" ? 3 : 2;
+ const minWidth = GRID_CONFIG.CELL_SIZE * minWidthCells;
+ const minHeight = GRID_CONFIG.CELL_SIZE * minHeightCells;
switch (resizeStart.handle) {
case "se": // 오른쪽 아래
- newWidth = Math.max(minSize, resizeStart.width + deltaX);
- newHeight = Math.max(minSize, resizeStart.height + deltaY);
+ newWidth = Math.max(minWidth, resizeStart.width + deltaX);
+ newHeight = Math.max(minHeight, resizeStart.height + deltaY);
break;
case "sw": // 왼쪽 아래
- newWidth = Math.max(minSize, resizeStart.width - deltaX);
- newHeight = Math.max(minSize, resizeStart.height + deltaY);
+ newWidth = Math.max(minWidth, resizeStart.width - deltaX);
+ newHeight = Math.max(minHeight, resizeStart.height + deltaY);
newX = resizeStart.elementX + deltaX;
break;
case "ne": // 오른쪽 위
- newWidth = Math.max(minSize, resizeStart.width + deltaX);
- newHeight = Math.max(minSize, resizeStart.height - deltaY);
+ newWidth = Math.max(minWidth, resizeStart.width + deltaX);
+ newHeight = Math.max(minHeight, resizeStart.height - deltaY);
newY = resizeStart.elementY + deltaY;
break;
case "nw": // 왼쪽 위
- newWidth = Math.max(minSize, resizeStart.width - deltaX);
- newHeight = Math.max(minSize, resizeStart.height - deltaY);
+ newWidth = Math.max(minWidth, resizeStart.width - deltaX);
+ newHeight = Math.max(minHeight, resizeStart.height - deltaY);
newX = resizeStart.elementX + deltaX;
newY = resizeStart.elementY + deltaY;
break;
@@ -281,6 +287,8 @@ export function CanvasElement({
return "bg-gradient-to-br from-cyan-400 to-indigo-800";
case "clock":
return "bg-gradient-to-br from-teal-400 to-cyan-600";
+ case "calendar":
+ return "bg-gradient-to-br from-indigo-400 to-purple-600";
default:
return "bg-gray-200";
}
@@ -310,16 +318,17 @@ export function CanvasElement({
{element.title}
- {/* 설정 버튼 (시계 위젯은 자체 설정 UI 사용) */}
- {onConfigure && !(element.type === "widget" && element.subtype === "clock") && (
-
- )}
+ {/* 설정 버튼 (시계, 달력 위젯은 자체 설정 UI 사용) */}
+ {onConfigure &&
+ !(element.type === "widget" && (element.subtype === "clock" || element.subtype === "calendar")) && (
+
+ )}
{/* 삭제 버튼 */}
+ ) : element.type === "widget" && element.subtype === "calendar" ? (
+ // 달력 위젯 렌더링
+
+ {
+ onUpdate(element.id, { calendarConfig: newConfig });
+ }}
+ />
+
) : (
// 기타 위젯 렌더링
{
- // 기본 크기: 차트는 4x3 셀, 위젯은 2x2 셀
- const defaultCells = type === "chart" ? { width: 4, height: 3 } : { width: 2, height: 2 };
+ // 기본 크기 설정
+ let defaultCells = { width: 2, height: 2 }; // 기본 위젯 크기
+
+ if (type === "chart") {
+ defaultCells = { width: 4, height: 3 }; // 차트
+ } else if (type === "widget" && subtype === "calendar") {
+ defaultCells = { width: 2, height: 3 }; // 달력 최소 크기
+ }
+
const cellWithGap = GRID_CONFIG.CELL_SIZE + GRID_CONFIG.GAP;
const defaultWidth = defaultCells.width * cellWithGap - GRID_CONFIG.GAP;
@@ -232,7 +239,7 @@ export default function DashboardDesigner() {
{/* 편집 중인 대시보드 표시 */}
{dashboardTitle && (
-
+
📝 편집 중: {dashboardTitle}
)}
@@ -291,6 +298,8 @@ function getElementTitle(type: ElementType, subtype: ElementSubtype): string {
return "☁️ 날씨 위젯";
case "clock":
return "⏰ 시계 위젯";
+ case "calendar":
+ return "📅 달력 위젯";
default:
return "🔧 위젯";
}
@@ -319,6 +328,8 @@ function getElementContent(type: ElementType, subtype: ElementSubtype): string {
return "서울\n23°C\n구름 많음";
case "clock":
return "clock";
+ case "calendar":
+ return "calendar";
default:
return "위젯 내용이 여기에 표시됩니다";
}
diff --git a/frontend/components/admin/dashboard/DashboardSidebar.tsx b/frontend/components/admin/dashboard/DashboardSidebar.tsx
index 41888172..82ce27c3 100644
--- a/frontend/components/admin/dashboard/DashboardSidebar.tsx
+++ b/frontend/components/admin/dashboard/DashboardSidebar.tsx
@@ -111,6 +111,14 @@ export function DashboardSidebar() {
onDragStart={handleDragStart}
className="border-l-4 border-teal-500"
/>
+
diff --git a/frontend/components/admin/dashboard/ElementConfigModal.tsx b/frontend/components/admin/dashboard/ElementConfigModal.tsx
index dc9d3f32..31fdee8b 100644
--- a/frontend/components/admin/dashboard/ElementConfigModal.tsx
+++ b/frontend/components/admin/dashboard/ElementConfigModal.tsx
@@ -1,10 +1,9 @@
"use client";
import React, { useState, useCallback } from "react";
-import { DashboardElement, ChartDataSource, ChartConfig, QueryResult, ClockConfig } from "./types";
+import { DashboardElement, ChartDataSource, ChartConfig, QueryResult } from "./types";
import { QueryEditor } from "./QueryEditor";
import { ChartConfigPanel } from "./ChartConfigPanel";
-import { ClockConfigModal } from "./widgets/ClockConfigModal";
interface ElementConfigModalProps {
element: DashboardElement;
@@ -57,46 +56,14 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
onClose();
}, [element, dataSource, chartConfig, onSave, onClose]);
- // 시계 위젯 설정 저장
- const handleClockConfigSave = useCallback(
- (clockConfig: ClockConfig) => {
- const updatedElement: DashboardElement = {
- ...element,
- clockConfig,
- };
- onSave(updatedElement);
- },
- [element, onSave],
- );
-
// 모달이 열려있지 않으면 렌더링하지 않음
if (!isOpen) return null;
- // 시계 위젯은 자체 설정 UI를 가지고 있으므로 모달 표시하지 않음
- if (element.type === "widget" && element.subtype === "clock") {
+ // 시계, 달력 위젯은 자체 설정 UI를 가지고 있으므로 모달 표시하지 않음
+ if (element.type === "widget" && (element.subtype === "clock" || element.subtype === "calendar")) {
return null;
}
- // 이전 코드 호환성 유지 (아래 주석 처리된 코드는 제거 예정)
- if (false && element.type === "widget" && element.subtype === "clock") {
- return (
-
- );
- }
-
return (
diff --git a/frontend/components/admin/dashboard/types.ts b/frontend/components/admin/dashboard/types.ts
index d304c9f3..2ac0bb6d 100644
--- a/frontend/components/admin/dashboard/types.ts
+++ b/frontend/components/admin/dashboard/types.ts
@@ -14,7 +14,8 @@ export type ElementSubtype =
| "combo" // 차트 타입
| "exchange"
| "weather"
- | "clock"; // 위젯 타입
+ | "clock"
+ | "calendar"; // 위젯 타입
export interface Position {
x: number;
@@ -37,6 +38,7 @@ export interface DashboardElement {
dataSource?: ChartDataSource; // 데이터 소스 설정
chartConfig?: ChartConfig; // 차트 설정
clockConfig?: ClockConfig; // 시계 설정
+ calendarConfig?: CalendarConfig; // 달력 설정
}
export interface DragData {
@@ -86,3 +88,15 @@ export interface ClockConfig {
theme: "light" | "dark" | "custom"; // 테마
customColor?: string; // 사용자 지정 색상 (custom 테마일 때)
}
+
+// 달력 위젯 설정
+export interface CalendarConfig {
+ view: "month" | "week" | "day"; // 뷰 타입
+ startWeekOn: "monday" | "sunday"; // 주 시작 요일
+ highlightWeekends: boolean; // 주말 강조
+ highlightToday: boolean; // 오늘 강조
+ showHolidays: boolean; // 공휴일 표시
+ theme: "light" | "dark" | "custom"; // 테마
+ customColor?: string; // 사용자 지정 색상
+ showWeekNumbers?: boolean; // 주차 표시 (선택)
+}
diff --git a/frontend/components/admin/dashboard/widgets/CalendarSettings.tsx b/frontend/components/admin/dashboard/widgets/CalendarSettings.tsx
new file mode 100644
index 00000000..89633cc8
--- /dev/null
+++ b/frontend/components/admin/dashboard/widgets/CalendarSettings.tsx
@@ -0,0 +1,207 @@
+"use client";
+
+import { useState } from "react";
+import { CalendarConfig } from "../types";
+import { Button } from "@/components/ui/button";
+import { Label } from "@/components/ui/label";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { Switch } from "@/components/ui/switch";
+import { Input } from "@/components/ui/input";
+import { Card } from "@/components/ui/card";
+import { Separator } from "@/components/ui/separator";
+
+interface CalendarSettingsProps {
+ config: CalendarConfig;
+ onSave: (config: CalendarConfig) => void;
+ onClose: () => void;
+}
+
+/**
+ * 달력 위젯 설정 UI (Popover 내부용)
+ */
+export function CalendarSettings({ config, onSave, onClose }: CalendarSettingsProps) {
+ const [localConfig, setLocalConfig] = useState
(config);
+
+ const handleSave = () => {
+ onSave(localConfig);
+ };
+
+ return (
+
+ {/* 헤더 */}
+
+
+ 📅
+ 달력 설정
+
+
+
+ {/* 내용 - 스크롤 가능 */}
+
+ {/* 뷰 타입 선택 (현재는 month만) */}
+
+
+
+
+
+
+
+ {/* 시작 요일 선택 */}
+
+
+
+
+
+
+
+
+
+
+ {/* 테마 선택 */}
+
+
+
+ {[
+ {
+ value: "light",
+ label: "Light",
+ gradient: "bg-gradient-to-br from-white to-gray-100",
+ text: "text-gray-900",
+ },
+ {
+ value: "dark",
+ label: "Dark",
+ gradient: "bg-gradient-to-br from-gray-800 to-gray-900",
+ text: "text-white",
+ },
+ {
+ value: "custom",
+ label: "사용자",
+ gradient: "bg-gradient-to-br from-blue-400 to-purple-600",
+ text: "text-white",
+ },
+ ].map((theme) => (
+
+ ))}
+
+
+ {/* 사용자 지정 색상 */}
+ {localConfig.theme === "custom" && (
+
+
+
+ setLocalConfig({ ...localConfig, customColor: e.target.value })}
+ className="h-10 w-16 cursor-pointer"
+ />
+ setLocalConfig({ ...localConfig, customColor: e.target.value })}
+ placeholder="#3b82f6"
+ className="flex-1 font-mono text-xs"
+ />
+
+
+ )}
+
+
+
+
+ {/* 표시 옵션 */}
+
+
+
+ {/* 오늘 강조 */}
+
+
+ 📍
+
+
+
setLocalConfig({ ...localConfig, highlightToday: checked })}
+ />
+
+
+ {/* 주말 강조 */}
+
+
+ 🎨
+
+
+
setLocalConfig({ ...localConfig, highlightWeekends: checked })}
+ />
+
+
+ {/* 공휴일 표시 */}
+
+
+ 🎉
+
+
+
setLocalConfig({ ...localConfig, showHolidays: checked })}
+ />
+
+
+
+
+
+ {/* 푸터 */}
+
+
+
+
+
+ );
+}
+
diff --git a/frontend/components/admin/dashboard/widgets/CalendarWidget.tsx b/frontend/components/admin/dashboard/widgets/CalendarWidget.tsx
new file mode 100644
index 00000000..4f54ac65
--- /dev/null
+++ b/frontend/components/admin/dashboard/widgets/CalendarWidget.tsx
@@ -0,0 +1,121 @@
+"use client";
+
+import { useState } from "react";
+import { DashboardElement, CalendarConfig } from "../types";
+import { MonthView } from "./MonthView";
+import { CalendarSettings } from "./CalendarSettings";
+import { generateCalendarDays, getMonthName, navigateMonth } from "./calendarUtils";
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
+import { Button } from "@/components/ui/button";
+import { Settings, ChevronLeft, ChevronRight, Calendar } from "lucide-react";
+
+interface CalendarWidgetProps {
+ element: DashboardElement;
+ onConfigUpdate?: (config: CalendarConfig) => void;
+}
+
+/**
+ * 달력 위젯 메인 컴포넌트
+ * - 월간/주간/일간 뷰 지원
+ * - 네비게이션 (이전/다음 월, 오늘)
+ * - 내장 설정 UI
+ */
+export function CalendarWidget({ element, onConfigUpdate }: CalendarWidgetProps) {
+ // 현재 표시 중인 년/월
+ const today = new Date();
+ const [currentYear, setCurrentYear] = useState(today.getFullYear());
+ const [currentMonth, setCurrentMonth] = useState(today.getMonth());
+ const [settingsOpen, setSettingsOpen] = useState(false);
+
+ // 기본 설정값
+ const config = element.calendarConfig || {
+ view: "month",
+ startWeekOn: "sunday",
+ highlightWeekends: true,
+ highlightToday: true,
+ showHolidays: true,
+ theme: "light",
+ };
+
+ // 설정 저장 핸들러
+ const handleSaveSettings = (newConfig: CalendarConfig) => {
+ onConfigUpdate?.(newConfig);
+ setSettingsOpen(false);
+ };
+
+ // 이전 월로 이동
+ const handlePrevMonth = () => {
+ const { year, month } = navigateMonth(currentYear, currentMonth, "prev");
+ setCurrentYear(year);
+ setCurrentMonth(month);
+ };
+
+ // 다음 월로 이동
+ const handleNextMonth = () => {
+ const { year, month } = navigateMonth(currentYear, currentMonth, "next");
+ setCurrentYear(year);
+ setCurrentMonth(month);
+ };
+
+ // 오늘로 돌아가기
+ const handleToday = () => {
+ setCurrentYear(today.getFullYear());
+ setCurrentMonth(today.getMonth());
+ };
+
+ // 달력 날짜 생성
+ const calendarDays = generateCalendarDays(currentYear, currentMonth, config.startWeekOn);
+
+ // 크기에 따른 컴팩트 모드 판단
+ const isCompact = element.size.width < 400 || element.size.height < 400;
+
+ return (
+
+ {/* 헤더 - 네비게이션 */}
+
+ {/* 이전 월 버튼 */}
+
+
+ {/* 현재 년월 표시 */}
+
+
+ {currentYear}년 {getMonthName(currentMonth)}
+
+ {!isCompact && (
+
+ )}
+
+
+ {/* 다음 월 버튼 */}
+
+
+
+ {/* 달력 콘텐츠 */}
+
+ {config.view === "month" && }
+ {/* 추후 WeekView, DayView 추가 가능 */}
+
+
+ {/* 설정 버튼 - 우측 하단 */}
+
+
+
+
+
+
+ setSettingsOpen(false)} />
+
+
+
+
+ );
+}
+
diff --git a/frontend/components/admin/dashboard/widgets/ClockConfigModal.tsx b/frontend/components/admin/dashboard/widgets/ClockConfigModal.tsx
deleted file mode 100644
index 26067b48..00000000
--- a/frontend/components/admin/dashboard/widgets/ClockConfigModal.tsx
+++ /dev/null
@@ -1,205 +0,0 @@
-"use client";
-
-import { useState } from "react";
-import { ClockConfig } from "../types";
-import { Button } from "@/components/ui/button";
-import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
-import { Label } from "@/components/ui/label";
-import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
-import { Switch } from "@/components/ui/switch";
-import { Input } from "@/components/ui/input";
-import { Card } from "@/components/ui/card";
-import { X } from "lucide-react";
-
-interface ClockConfigModalProps {
- config: ClockConfig;
- onSave: (config: ClockConfig) => void;
- onClose: () => void;
-}
-
-/**
- * 시계 위젯 설정 모달
- * - 스타일 선택 (아날로그/디지털/둘다)
- * - 타임존 선택
- * - 테마 선택
- * - 옵션 토글 (날짜, 초, 24시간)
- */
-export function ClockConfigModal({ config, onSave, onClose }: ClockConfigModalProps) {
- const [localConfig, setLocalConfig] = useState(config);
-
- const handleSave = () => {
- onSave(localConfig);
- onClose();
- };
-
- return (
-
- );
-}
diff --git a/frontend/components/admin/dashboard/widgets/MonthView.tsx b/frontend/components/admin/dashboard/widgets/MonthView.tsx
new file mode 100644
index 00000000..c0fd3871
--- /dev/null
+++ b/frontend/components/admin/dashboard/widgets/MonthView.tsx
@@ -0,0 +1,117 @@
+"use client";
+
+import { CalendarConfig } from "../types";
+import { CalendarDay, getWeekDayNames } from "./calendarUtils";
+
+interface MonthViewProps {
+ days: CalendarDay[];
+ config: CalendarConfig;
+ isCompact?: boolean; // 작은 크기 (2x2, 3x3)
+}
+
+/**
+ * 월간 달력 뷰 컴포넌트
+ */
+export function MonthView({ days, config, isCompact = false }: MonthViewProps) {
+ const weekDayNames = getWeekDayNames(config.startWeekOn);
+
+ // 테마별 스타일
+ const getThemeStyles = () => {
+ if (config.theme === "custom" && config.customColor) {
+ return {
+ todayBg: config.customColor,
+ holidayText: config.customColor,
+ weekendText: "#dc2626",
+ };
+ }
+
+ if (config.theme === "dark") {
+ return {
+ todayBg: "#3b82f6",
+ holidayText: "#f87171",
+ weekendText: "#f87171",
+ };
+ }
+
+ // light 테마
+ return {
+ todayBg: "#3b82f6",
+ holidayText: "#dc2626",
+ weekendText: "#dc2626",
+ };
+ };
+
+ const themeStyles = getThemeStyles();
+
+ // 날짜 셀 스타일 클래스
+ const getDayCellClass = (day: CalendarDay) => {
+ const baseClass = "flex aspect-square items-center justify-center rounded-lg transition-colors";
+ const sizeClass = isCompact ? "text-xs" : "text-sm";
+
+ let colorClass = "text-gray-700";
+
+ // 현재 월이 아닌 날짜
+ if (!day.isCurrentMonth) {
+ colorClass = "text-gray-300";
+ }
+ // 오늘
+ else if (config.highlightToday && day.isToday) {
+ colorClass = "text-white font-bold";
+ }
+ // 공휴일
+ else if (config.showHolidays && day.isHoliday) {
+ colorClass = "font-semibold";
+ }
+ // 주말
+ else if (config.highlightWeekends && day.isWeekend) {
+ colorClass = "text-red-600";
+ }
+
+ const bgClass = config.highlightToday && day.isToday ? "" : "hover:bg-gray-100";
+
+ return `${baseClass} ${sizeClass} ${colorClass} ${bgClass}`;
+ };
+
+ return (
+
+ {/* 요일 헤더 */}
+ {!isCompact && (
+
+ {weekDayNames.map((name, index) => {
+ const isWeekend = config.startWeekOn === "sunday" ? index === 0 || index === 6 : index === 5 || index === 6;
+ return (
+
+ {name}
+
+ );
+ })}
+
+ )}
+
+ {/* 날짜 그리드 */}
+
+ {days.map((day, index) => (
+
+ {day.day}
+
+ ))}
+
+
+ );
+}
+
diff --git a/frontend/components/admin/dashboard/widgets/calendarUtils.ts b/frontend/components/admin/dashboard/widgets/calendarUtils.ts
new file mode 100644
index 00000000..4bdb8deb
--- /dev/null
+++ b/frontend/components/admin/dashboard/widgets/calendarUtils.ts
@@ -0,0 +1,162 @@
+/**
+ * 달력 유틸리티 함수
+ */
+
+// 한국 공휴일 데이터 (2025년 기준)
+export interface Holiday {
+ date: string; // 'MM-DD' 형식
+ name: string;
+ isRecurring: boolean;
+}
+
+export const KOREAN_HOLIDAYS: Holiday[] = [
+ { date: "01-01", name: "신정", isRecurring: true },
+ { date: "01-28", name: "설날 연휴", isRecurring: false },
+ { date: "01-29", name: "설날", isRecurring: false },
+ { date: "01-30", name: "설날 연휴", isRecurring: false },
+ { date: "03-01", name: "삼일절", isRecurring: true },
+ { date: "05-05", name: "어린이날", isRecurring: true },
+ { date: "06-06", name: "현충일", isRecurring: true },
+ { date: "08-15", name: "광복절", isRecurring: true },
+ { date: "10-03", name: "개천절", isRecurring: true },
+ { date: "10-09", name: "한글날", isRecurring: true },
+ { date: "12-25", name: "크리스마스", isRecurring: true },
+];
+
+/**
+ * 특정 월의 첫 날 Date 객체 반환
+ */
+export function getFirstDayOfMonth(year: number, month: number): Date {
+ return new Date(year, month, 1);
+}
+
+/**
+ * 특정 월의 마지막 날짜 반환
+ */
+export function getLastDateOfMonth(year: number, month: number): number {
+ return new Date(year, month + 1, 0).getDate();
+}
+
+/**
+ * 특정 월의 첫 날의 요일 반환 (0=일요일, 1=월요일, ...)
+ */
+export function getFirstDayOfWeek(year: number, month: number): number {
+ return new Date(year, month, 1).getDay();
+}
+
+/**
+ * 달력 그리드에 표시할 날짜 배열 생성
+ * @param year 년도
+ * @param month 월 (0-11)
+ * @param startWeekOn 주 시작 요일 ('monday' | 'sunday')
+ * @returns 6주 * 7일 = 42개의 날짜 정보 배열
+ */
+export interface CalendarDay {
+ date: Date;
+ day: number;
+ isCurrentMonth: boolean;
+ isToday: boolean;
+ isWeekend: boolean;
+ isHoliday: boolean;
+ holidayName?: string;
+}
+
+export function generateCalendarDays(
+ year: number,
+ month: number,
+ startWeekOn: "monday" | "sunday" = "sunday",
+): CalendarDay[] {
+ const days: CalendarDay[] = [];
+ const firstDay = getFirstDayOfWeek(year, month);
+ const lastDate = getLastDateOfMonth(year, month);
+ const today = new Date();
+ today.setHours(0, 0, 0, 0);
+
+ // 시작 오프셋 계산
+ let startOffset = firstDay;
+ if (startWeekOn === "monday") {
+ startOffset = firstDay === 0 ? 6 : firstDay - 1;
+ }
+
+ // 이전 달 날짜들
+ const prevMonthLastDate = getLastDateOfMonth(year, month - 1);
+ for (let i = startOffset - 1; i >= 0; i--) {
+ const date = new Date(year, month - 1, prevMonthLastDate - i);
+ days.push(createCalendarDay(date, false, today));
+ }
+
+ // 현재 달 날짜들
+ for (let day = 1; day <= lastDate; day++) {
+ const date = new Date(year, month, day);
+ days.push(createCalendarDay(date, true, today));
+ }
+
+ // 다음 달 날짜들 (42개 채우기)
+ const remainingDays = 42 - days.length;
+ for (let day = 1; day <= remainingDays; day++) {
+ const date = new Date(year, month + 1, day);
+ days.push(createCalendarDay(date, false, today));
+ }
+
+ return days;
+}
+
+/**
+ * CalendarDay 객체 생성
+ */
+function createCalendarDay(date: Date, isCurrentMonth: boolean, today: Date): CalendarDay {
+ const dayOfWeek = date.getDay();
+ const isToday = date.getTime() === today.getTime();
+ const isWeekend = dayOfWeek === 0 || dayOfWeek === 6;
+
+ // 공휴일 체크
+ const monthStr = String(date.getMonth() + 1).padStart(2, "0");
+ const dayStr = String(date.getDate()).padStart(2, "0");
+ const dateKey = `${monthStr}-${dayStr}`;
+ const holiday = KOREAN_HOLIDAYS.find((h) => h.date === dateKey);
+
+ return {
+ date,
+ day: date.getDate(),
+ isCurrentMonth,
+ isToday,
+ isWeekend,
+ isHoliday: !!holiday,
+ holidayName: holiday?.name,
+ };
+}
+
+/**
+ * 요일 이름 배열 반환
+ */
+export function getWeekDayNames(startWeekOn: "monday" | "sunday" = "sunday"): string[] {
+ const sundayFirst = ["일", "월", "화", "수", "목", "금", "토"];
+ const mondayFirst = ["월", "화", "수", "목", "금", "토", "일"];
+ return startWeekOn === "monday" ? mondayFirst : sundayFirst;
+}
+
+/**
+ * 월 이름 반환
+ */
+export function getMonthName(month: number): string {
+ const months = ["1월", "2월", "3월", "4월", "5월", "6월", "7월", "8월", "9월", "10월", "11월", "12월"];
+ return months[month];
+}
+
+/**
+ * 이전/다음 월로 이동
+ */
+export function navigateMonth(year: number, month: number, direction: "prev" | "next"): { year: number; month: number } {
+ if (direction === "prev") {
+ if (month === 0) {
+ return { year: year - 1, month: 11 };
+ }
+ return { year, month: month - 1 };
+ } else {
+ if (month === 11) {
+ return { year: year + 1, month: 0 };
+ }
+ return { year, month: month + 1 };
+ }
+}
+