Files
sam-react-prod/src/components/common/ScheduleCalendar/ScheduleCalendar.tsx
유병철 13d27553b9 feat: 모바일 반응형 UI 개선 및 공휴일/일정 시스템 통합
- MobileCard 접기/펼치기(collapsible) 기능 추가 및 반응형 레이아웃 개선
- DatePicker 공휴일/세무일정 색상 코딩 통합, DateTimePicker 신규 추가
- useCalendarScheduleInit 훅으로 전역 공휴일/일정 데이터 캐싱
- 전 도메인 날짜 필드 DatePicker 표준화
- 생산대시보드/작업지시/견적서/주문관리 모바일 호환성 강화
- 회계 모듈 기능 개선 (매입상세 결재연동, 미수금현황 조회조건 등)
- 달력 일정 관리 API 연동 및 대량 등록 다이얼로그 개선

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 21:28:23 +09:00

203 lines
6.3 KiB
TypeScript

'use client';
import { useState, useCallback, useEffect } from 'react';
import { cn } from '@/components/ui/utils';
import { CalendarHeader } from './CalendarHeader';
import { DayTimeView } from './DayTimeView';
import { MonthView } from './MonthView';
import { WeekView } from './WeekView';
import { WeekTimeView } from './WeekTimeView';
import type { ScheduleCalendarProps, CalendarView } from './types';
import { getNextMonth, getPrevMonth, getNextDay, getPrevDay, getNextWeek, getPrevWeek } from './utils';
import { useCalendarScheduleStore } from '@/stores/useCalendarScheduleStore';
/**
* 스케줄 달력 공통 컴포넌트
*
* 주/월 뷰 전환, 일정 바 표시, 날짜별 뱃지 등을 지원하는 재사용 가능한 달력
*
* @example
* ```tsx
* <ScheduleCalendar
* events={[
* { id: '1', title: '김담당 - 현장A / ORD-001', startDate: '2025-12-01', endDate: '2025-12-05', color: 'blue' },
* ]}
* badges={[
* { date: '2025-12-01', count: 3, color: 'red' },
* ]}
* onDateClick={(date) => console.log('날짜 클릭:', date)}
* onEventClick={(event) => console.log('이벤트 클릭:', event)}
* filterSlot={<Select>...</Select>}
* />
* ```
*/
export function ScheduleCalendar({
events = [],
badges = [],
currentDate: controlledDate,
view: controlledView,
selectedDate: controlledSelectedDate,
onDateClick,
onEventClick,
onMonthChange,
onViewChange,
titleSlot,
filterSlot,
maxEventsPerDay = 5,
weekStartsOn = 0,
isLoading = false,
className,
availableViews,
timeRange,
hideNavigation,
}: ScheduleCalendarProps) {
// 내부 상태 (controlled/uncontrolled 지원)
const [internalDate, setInternalDate] = useState(() => new Date());
const [internalView, setInternalView] = useState<CalendarView>('month');
const [internalSelectedDate, setInternalSelectedDate] = useState<Date | null>(null);
// 현재 사용할 값 결정
const currentDate = controlledDate ?? internalDate;
const view = controlledView ?? internalView;
const selectedDate = controlledSelectedDate !== undefined ? controlledSelectedDate : internalSelectedDate;
// Hydration 불일치 방지
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
// 현재 표시 중인 연도의 공휴일/일정 데이터를 스토어에 로드
useEffect(() => {
const year = currentDate.getFullYear();
useCalendarScheduleStore.getState().fetchSchedules(year);
}, [currentDate]);
// 이전 (뷰에 따라 일/주/월 단위)
const handlePrevMonth = useCallback(() => {
let newDate: Date;
if (view === 'day-time') {
newDate = getPrevDay(currentDate);
} else if (view === 'week-time' || view === 'week') {
newDate = getPrevWeek(currentDate);
} else {
newDate = getPrevMonth(currentDate);
}
if (controlledDate === undefined) {
setInternalDate(newDate);
}
onMonthChange?.(newDate);
}, [currentDate, view, controlledDate, onMonthChange]);
// 다음 (뷰에 따라 일/주/월 단위)
const handleNextMonth = useCallback(() => {
let newDate: Date;
if (view === 'day-time') {
newDate = getNextDay(currentDate);
} else if (view === 'week-time' || view === 'week') {
newDate = getNextWeek(currentDate);
} else {
newDate = getNextMonth(currentDate);
}
if (controlledDate === undefined) {
setInternalDate(newDate);
}
onMonthChange?.(newDate);
}, [currentDate, view, controlledDate, onMonthChange]);
// 뷰 변경
const handleViewChange = useCallback((newView: CalendarView) => {
if (controlledView === undefined) {
setInternalView(newView);
}
onViewChange?.(newView);
}, [controlledView, onViewChange]);
// 날짜 클릭
const handleDateClick = useCallback((date: Date) => {
if (controlledSelectedDate === undefined) {
setInternalSelectedDate(date);
}
onDateClick?.(date);
}, [controlledSelectedDate, onDateClick]);
// 이벤트 클릭
const handleEventClick = useCallback((event: import('./types').ScheduleEvent) => {
onEventClick?.(event);
}, [onEventClick]);
// SSR에서는 빈 컨테이너 렌더링
if (!mounted) {
return (
<div className={cn('min-h-[400px] rounded-md border p-4', className)} />
);
}
return (
<div className={cn('rounded-md border p-4 bg-background', className)}>
{/* 헤더 */}
<CalendarHeader
currentDate={currentDate}
view={view}
onPrevMonth={handlePrevMonth}
onNextMonth={handleNextMonth}
onViewChange={handleViewChange}
titleSlot={titleSlot}
filterSlot={filterSlot}
availableViews={availableViews}
hideNavigation={hideNavigation}
/>
{/* 본문 */}
<div className="mt-4">
{isLoading ? (
<div className="flex items-center justify-center min-h-[300px]">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
</div>
) : view === 'day-time' ? (
<DayTimeView
currentDate={currentDate}
events={events}
selectedDate={selectedDate}
timeRange={timeRange}
onDateClick={handleDateClick}
onEventClick={handleEventClick}
/>
) : view === 'week-time' ? (
<WeekTimeView
currentDate={currentDate}
events={events}
selectedDate={selectedDate}
weekStartsOn={weekStartsOn}
timeRange={timeRange}
onDateClick={handleDateClick}
onEventClick={handleEventClick}
/>
) : view === 'month' ? (
<MonthView
currentDate={currentDate}
events={events}
badges={badges}
selectedDate={selectedDate}
maxEventsPerDay={maxEventsPerDay}
weekStartsOn={weekStartsOn}
onDateClick={handleDateClick}
onEventClick={handleEventClick}
/>
) : (
<WeekView
currentDate={currentDate}
events={events}
badges={badges}
selectedDate={selectedDate}
maxEventsPerDay={maxEventsPerDay}
weekStartsOn={weekStartsOn}
onDateClick={handleDateClick}
onEventClick={handleEventClick}
/>
)}
</div>
</div>
);
}