달력 위젯 구현

This commit is contained in:
dohyeons 2025-10-14 10:48:17 +09:00
parent 4dbb55f6e1
commit 2311729338
11 changed files with 715 additions and 294 deletions

View File

@ -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] 날짜 계산 로직 검증
- [ ] 성능 최적화 (필요시)
- [ ] 접근성 개선 (필요시)
## 기술 스택

View File

@ -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({
<div className="flex cursor-move items-center justify-between border-b border-gray-200 bg-gray-50 p-3">
<span className="text-sm font-bold text-gray-800">{element.title}</span>
<div className="flex gap-1">
{/* 설정 버튼 (시계 위젯은 자체 설정 UI 사용) */}
{onConfigure && !(element.type === "widget" && element.subtype === "clock") && (
<button
className="hover:bg-accent0 flex h-6 w-6 items-center justify-center rounded text-gray-400 transition-colors duration-200 hover:text-white"
onClick={() => onConfigure(element)}
title="설정"
>
</button>
)}
{/* 설정 버튼 (시계, 달력 위젯은 자체 설정 UI 사용) */}
{onConfigure &&
!(element.type === "widget" && (element.subtype === "clock" || element.subtype === "calendar")) && (
<button
className="hover:bg-accent0 flex h-6 w-6 items-center justify-center rounded text-gray-400 transition-colors duration-200 hover:text-white"
onClick={() => onConfigure(element)}
title="설정"
>
</button>
)}
{/* 삭제 버튼 */}
<button
className="element-close hover:bg-destructive/100 flex h-6 w-6 items-center justify-center rounded text-gray-400 transition-colors duration-200 hover:text-white"
@ -376,6 +385,16 @@ export function CanvasElement({
}}
/>
</div>
) : element.type === "widget" && element.subtype === "calendar" ? (
// 달력 위젯 렌더링
<div className="h-full w-full">
<CalendarWidget
element={element}
onConfigUpdate={(newConfig) => {
onUpdate(element.id, { calendarConfig: newConfig });
}}
/>
</div>
) : (
// 기타 위젯 렌더링
<div

View File

@ -79,8 +79,15 @@ export default function DashboardDesigner() {
// 새로운 요소 생성 (고정 그리드 기반 기본 크기)
const createElement = useCallback(
(type: ElementType, subtype: ElementSubtype, x: number, y: number) => {
// 기본 크기: 차트는 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() {
<div className="relative flex-1 overflow-auto border-r-2 border-gray-300 bg-gray-100">
{/* 편집 중인 대시보드 표시 */}
{dashboardTitle && (
<div className="bg-accent0 absolute left-6 top-6 z-10 rounded-lg px-3 py-1 text-sm font-medium text-white shadow-lg">
<div className="bg-accent0 absolute top-6 left-6 z-10 rounded-lg px-3 py-1 text-sm font-medium text-white shadow-lg">
📝 : {dashboardTitle}
</div>
)}
@ -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 "위젯 내용이 여기에 표시됩니다";
}

View File

@ -111,6 +111,14 @@ export function DashboardSidebar() {
onDragStart={handleDragStart}
className="border-l-4 border-teal-500"
/>
<DraggableItem
icon="📅"
title="달력 위젯"
type="widget"
subtype="calendar"
onDragStart={handleDragStart}
className="border-l-4 border-indigo-500"
/>
</div>
</div>
</div>

View File

@ -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 (
<ClockConfigModal
config={
element.clockConfig || {
style: "digital",
timezone: "Asia/Seoul",
showDate: true,
showSeconds: true,
format24h: true,
theme: "light",
}
}
onSave={handleClockConfigSave}
onClose={onClose}
/>
);
}
return (
<div className="bg-opacity-50 fixed inset-0 z-50 flex items-center justify-center bg-black">
<div className="flex h-[80vh] w-full max-w-4xl flex-col rounded-lg bg-white shadow-xl">

View File

@ -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; // 주차 표시 (선택)
}

View File

@ -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<CalendarConfig>(config);
const handleSave = () => {
onSave(localConfig);
};
return (
<div className="flex max-h-[600px] flex-col">
{/* 헤더 */}
<div className="border-b p-4">
<h3 className="flex items-center gap-2 text-lg font-semibold">
<span>📅</span>
</h3>
</div>
{/* 내용 - 스크롤 가능 */}
<div className="flex-1 space-y-4 overflow-y-auto p-4">
{/* 뷰 타입 선택 (현재는 month만) */}
<div className="space-y-2">
<Label className="text-sm font-semibold"> </Label>
<Select
value={localConfig.view}
onValueChange={(value) => setLocalConfig({ ...localConfig, view: value as any })}
>
<SelectTrigger className="w-full" size="sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="month"> </SelectItem>
{/* <SelectItem value="week"> ( )</SelectItem>
<SelectItem value="day"> ( )</SelectItem> */}
</SelectContent>
</Select>
</div>
<Separator />
{/* 시작 요일 선택 */}
<div className="space-y-2">
<Label className="text-sm font-semibold"> </Label>
<div className="grid grid-cols-2 gap-2">
<Button
type="button"
variant={localConfig.startWeekOn === "sunday" ? "default" : "outline"}
onClick={() => setLocalConfig({ ...localConfig, startWeekOn: "sunday" })}
size="sm"
>
</Button>
<Button
type="button"
variant={localConfig.startWeekOn === "monday" ? "default" : "outline"}
onClick={() => setLocalConfig({ ...localConfig, startWeekOn: "monday" })}
size="sm"
>
</Button>
</div>
</div>
<Separator />
{/* 테마 선택 */}
<div className="space-y-2">
<Label className="text-sm font-semibold"></Label>
<div className="grid grid-cols-3 gap-2">
{[
{
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) => (
<Button
key={theme.value}
type="button"
variant="outline"
onClick={() => setLocalConfig({ ...localConfig, theme: theme.value as any })}
className={`relative h-auto overflow-hidden p-0 ${
localConfig.theme === theme.value ? "ring-primary ring-2 ring-offset-2" : ""
}`}
size="sm"
>
<div className={`${theme.gradient} ${theme.text} w-full rounded px-3 py-2 text-xs font-medium`}>
{theme.label}
</div>
</Button>
))}
</div>
{/* 사용자 지정 색상 */}
{localConfig.theme === "custom" && (
<Card className="mt-2 border p-3">
<Label className="mb-2 block text-xs font-medium"> </Label>
<div className="flex items-center gap-2">
<Input
type="color"
value={localConfig.customColor || "#3b82f6"}
onChange={(e) => setLocalConfig({ ...localConfig, customColor: e.target.value })}
className="h-10 w-16 cursor-pointer"
/>
<Input
type="text"
value={localConfig.customColor || "#3b82f6"}
onChange={(e) => setLocalConfig({ ...localConfig, customColor: e.target.value })}
placeholder="#3b82f6"
className="flex-1 font-mono text-xs"
/>
</div>
</Card>
)}
</div>
<Separator />
{/* 표시 옵션 */}
<div className="space-y-2">
<Label className="text-sm font-semibold"> </Label>
<div className="space-y-2">
{/* 오늘 강조 */}
<div className="flex items-center justify-between rounded-lg border p-3">
<div className="flex items-center gap-2">
<span className="text-lg">📍</span>
<Label className="cursor-pointer text-sm"> </Label>
</div>
<Switch
checked={localConfig.highlightToday}
onCheckedChange={(checked) => setLocalConfig({ ...localConfig, highlightToday: checked })}
/>
</div>
{/* 주말 강조 */}
<div className="flex items-center justify-between rounded-lg border p-3">
<div className="flex items-center gap-2">
<span className="text-lg">🎨</span>
<Label className="cursor-pointer text-sm"> </Label>
</div>
<Switch
checked={localConfig.highlightWeekends}
onCheckedChange={(checked) => setLocalConfig({ ...localConfig, highlightWeekends: checked })}
/>
</div>
{/* 공휴일 표시 */}
<div className="flex items-center justify-between rounded-lg border p-3">
<div className="flex items-center gap-2">
<span className="text-lg">🎉</span>
<Label className="cursor-pointer text-sm"> </Label>
</div>
<Switch
checked={localConfig.showHolidays}
onCheckedChange={(checked) => setLocalConfig({ ...localConfig, showHolidays: checked })}
/>
</div>
</div>
</div>
</div>
{/* 푸터 */}
<div className="flex justify-end gap-2 border-t p-4">
<Button variant="outline" size="sm" onClick={onClose}>
</Button>
<Button size="sm" onClick={handleSave}>
</Button>
</div>
</div>
);
}

View File

@ -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 (
<div className="relative flex h-full w-full flex-col">
{/* 헤더 - 네비게이션 */}
<div className="flex items-center justify-between border-b border-gray-200 p-2">
{/* 이전 월 버튼 */}
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={handlePrevMonth}>
<ChevronLeft className="h-4 w-4" />
</Button>
{/* 현재 년월 표시 */}
<div className="flex items-center gap-2">
<span className="text-sm font-semibold">
{currentYear} {getMonthName(currentMonth)}
</span>
{!isCompact && (
<Button variant="outline" size="sm" className="h-6 px-2 text-xs" onClick={handleToday}>
</Button>
)}
</div>
{/* 다음 월 버튼 */}
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={handleNextMonth}>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
{/* 달력 콘텐츠 */}
<div className="flex-1 overflow-hidden">
{config.view === "month" && <MonthView days={calendarDays} config={config} isCompact={isCompact} />}
{/* 추후 WeekView, DayView 추가 가능 */}
</div>
{/* 설정 버튼 - 우측 하단 */}
<div className="absolute bottom-2 right-2">
<Popover open={settingsOpen} onOpenChange={setSettingsOpen}>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 bg-white/80 hover:bg-white">
<Settings className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[450px] p-0" align="end">
<CalendarSettings config={config} onSave={handleSaveSettings} onClose={() => setSettingsOpen(false)} />
</PopoverContent>
</Popover>
</div>
</div>
);
}

View File

@ -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<ClockConfig>(config);
const handleSave = () => {
onSave(localConfig);
onClose();
};
return (
<Dialog open={true} onOpenChange={onClose}>
<DialogContent className="max-h-[90vh] max-w-2xl overflow-hidden p-0">
<DialogHeader className="border-b p-6">
<DialogTitle className="flex items-center gap-2 text-xl">
<span></span>
</DialogTitle>
</DialogHeader>
{/* 내용 - 스크롤 가능 */}
<div className="flex-1 space-y-6 overflow-y-auto px-6 py-4">
{/* 스타일 선택 */}
<div className="space-y-3">
<Label className="text-sm font-semibold"> </Label>
<div className="grid grid-cols-3 gap-3">
{[
{ value: "digital", label: "디지털", icon: "🔢" },
{ value: "analog", label: "아날로그", icon: "🕐" },
{ value: "both", label: "둘 다", icon: "⏰" },
].map((style) => (
<Button
key={style.value}
type="button"
variant={localConfig.style === style.value ? "default" : "outline"}
onClick={() => setLocalConfig({ ...localConfig, style: style.value as any })}
className="flex h-auto flex-col items-center gap-2 p-4"
>
<span className="text-3xl">{style.icon}</span>
<span className="text-sm font-medium">{style.label}</span>
</Button>
))}
</div>
</div>
{/* 타임존 선택 */}
<div className="space-y-3">
<Label className="text-sm font-semibold"></Label>
<Select
value={localConfig.timezone}
onValueChange={(value) => setLocalConfig({ ...localConfig, timezone: value })}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Asia/Seoul">🇰🇷 (KST)</SelectItem>
<SelectItem value="Asia/Tokyo">🇯🇵 (JST)</SelectItem>
<SelectItem value="Asia/Shanghai">🇨🇳 (CST)</SelectItem>
<SelectItem value="America/New_York">🇺🇸 (EST)</SelectItem>
<SelectItem value="America/Los_Angeles">🇺🇸 LA (PST)</SelectItem>
<SelectItem value="Europe/London">🇬🇧 (GMT)</SelectItem>
<SelectItem value="Europe/Paris">🇫🇷 (CET)</SelectItem>
<SelectItem value="Australia/Sydney">🇦🇺 (AEDT)</SelectItem>
</SelectContent>
</Select>
</div>
{/* 테마 선택 */}
<div className="space-y-3">
<Label className="text-sm font-semibold"></Label>
<div className="grid grid-cols-3 gap-3">
{[
{
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) => (
<Button
key={theme.value}
type="button"
variant="outline"
onClick={() => setLocalConfig({ ...localConfig, theme: theme.value as any })}
className={`relative h-auto overflow-hidden p-0 ${
localConfig.theme === theme.value ? "ring-primary ring-2 ring-offset-2" : ""
}`}
>
<div className={`${theme.gradient} ${theme.text} w-full rounded p-3 text-center text-xs font-medium`}>
{theme.label}
</div>
</Button>
))}
</div>
{/* 사용자 지정 색상 선택 */}
{localConfig.theme === "custom" && (
<Card className="border p-4">
<Label className="mb-2 block text-sm font-medium"> </Label>
<div className="flex items-center gap-3">
<Input
type="color"
value={localConfig.customColor || "#3b82f6"}
onChange={(e) => setLocalConfig({ ...localConfig, customColor: e.target.value })}
className="h-12 w-20 cursor-pointer"
/>
<div className="flex-1">
<Input
type="text"
value={localConfig.customColor || "#3b82f6"}
onChange={(e) => setLocalConfig({ ...localConfig, customColor: e.target.value })}
placeholder="#3b82f6"
className="font-mono"
/>
<p className="text-muted-foreground mt-1 text-xs"> </p>
</div>
</div>
</Card>
)}
</div>
{/* 옵션 토글 */}
<div className="space-y-3">
<Label className="text-sm font-semibold"> </Label>
<div className="grid grid-cols-3 gap-3">
{/* 날짜 표시 */}
<Card className="hover:bg-accent flex cursor-pointer flex-col items-center justify-center border p-4 text-center transition-colors">
<span className="mb-2 text-2xl">📅</span>
<Label className="mb-1 cursor-pointer text-sm font-medium"> </Label>
<Switch
checked={localConfig.showDate}
onCheckedChange={(checked) => setLocalConfig({ ...localConfig, showDate: checked })}
/>
</Card>
{/* 초 표시 */}
<Card className="hover:bg-accent flex cursor-pointer flex-col items-center justify-center border p-4 text-center transition-colors">
<span className="mb-2 text-2xl"></span>
<Label className="mb-1 cursor-pointer text-sm font-medium"> </Label>
<Switch
checked={localConfig.showSeconds}
onCheckedChange={(checked) => setLocalConfig({ ...localConfig, showSeconds: checked })}
/>
</Card>
{/* 24시간 형식 */}
<Card className="hover:bg-accent flex cursor-pointer flex-col items-center justify-center border p-4 text-center transition-colors">
<span className="mb-2 text-2xl">🕐</span>
<Label className="mb-1 cursor-pointer text-sm font-medium">24 </Label>
<Switch
checked={localConfig.format24h}
onCheckedChange={(checked) => setLocalConfig({ ...localConfig, format24h: checked })}
/>
</Card>
</div>
</div>
</div>
<DialogFooter className="border-t p-6">
<Button variant="outline" onClick={onClose}>
</Button>
<Button onClick={handleSave}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -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 (
<div className="flex h-full flex-col p-2">
{/* 요일 헤더 */}
{!isCompact && (
<div className="mb-2 grid grid-cols-7 gap-1">
{weekDayNames.map((name, index) => {
const isWeekend = config.startWeekOn === "sunday" ? index === 0 || index === 6 : index === 5 || index === 6;
return (
<div
key={name}
className={`text-center text-xs font-semibold ${isWeekend && config.highlightWeekends ? "text-red-600" : "text-gray-600"}`}
>
{name}
</div>
);
})}
</div>
)}
{/* 날짜 그리드 */}
<div className="grid flex-1 grid-cols-7 gap-1">
{days.map((day, index) => (
<div
key={index}
className={getDayCellClass(day)}
style={{
backgroundColor:
config.highlightToday && day.isToday ? themeStyles.todayBg : undefined,
color:
config.showHolidays && day.isHoliday && day.isCurrentMonth
? themeStyles.holidayText
: undefined,
}}
title={day.isHoliday ? day.holidayName : undefined}
>
{day.day}
</div>
))}
</div>
</div>
);
}

View File

@ -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 };
}
}