Files
sam-react-prod/src/components/business/CEODashboard/sections/CalendarSection.tsx
유병철 0d4393fc34 feat(WEB): CEO 대시보드 전 섹션 공통 컴포넌트 기반 리팩토링
- EnhancedSections 공통 컴포넌트 추출 (SectionCard, StatItem, StatusBadge 등)
- 전 섹션(매출/매입/생산/출근/미출하/건설/캘린더/일보 등) 공통 패턴 적용
- components.tsx 공통 UI 컴포넌트 강화
- CLAUDE.md Git Workflow 섹션 추가 (develop/stage/main 플로우)

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

598 lines
23 KiB
TypeScript

'use client';
import { useState, useMemo, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Plus, ExternalLink, ChevronLeft, ChevronRight, CalendarDays } from 'lucide-react';
import { ScheduleCalendar } from '@/components/common/ScheduleCalendar';
import type { ScheduleEvent } from '@/components/common/ScheduleCalendar/types';
import { getCalendarEventsForYear, type CalendarEvent } from '@/constants/calendarEvents';
import { useCalendarScheduleStore } from '@/stores/useCalendarScheduleStore';
import { CollapsibleDashboardCard } from '../components';
import type {
CalendarScheduleItem,
CalendarViewType,
CalendarDeptFilterType,
CalendarTaskFilterType,
TodayIssueListItem,
TodayIssueListBadgeType,
} from '../types';
interface CalendarSectionProps {
schedules: CalendarScheduleItem[];
issues?: TodayIssueListItem[];
onScheduleClick?: (schedule: CalendarScheduleItem) => void;
onScheduleEdit?: (schedule: CalendarScheduleItem) => void;
}
// 일정 타입별 색상
const SCHEDULE_TYPE_COLORS: Record<string, string> = {
schedule: 'blue',
order: 'green',
construction: 'purple',
issue: 'red',
other: 'gray',
holiday: 'red',
tax: 'orange',
};
// 이슈 뱃지별 색상
const ISSUE_BADGE_COLORS: Record<TodayIssueListBadgeType, string> = {
'수주등록': 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
'추심이슈': 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300',
'안전재고': 'bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-300',
'지출 승인대기': 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
'세금 신고': 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300',
'결재 요청': 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300',
'신규거래처': 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300',
'입금': 'bg-teal-100 text-teal-700 dark:bg-teal-900/40 dark:text-teal-300',
'출금': 'bg-pink-100 text-pink-700 dark:bg-pink-900/40 dark:text-pink-300',
'기타': 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300',
};
// 부서 필터 옵션
const DEPT_FILTER_OPTIONS: { value: CalendarDeptFilterType; label: string }[] = [
{ value: 'all', label: '전체' },
{ value: 'department', label: '부' },
{ value: 'personal', label: '개인' },
];
// 업무 필터 옵션 (이슈 추가)
type ExtendedTaskFilterType = CalendarTaskFilterType | 'issue';
const TASK_FILTER_OPTIONS: { value: ExtendedTaskFilterType; label: string }[] = [
{ value: 'all', label: '전체' },
{ value: 'schedule', label: '일정' },
{ value: 'order', label: '발주' },
{ value: 'construction', label: '시공' },
{ value: 'issue', label: '이슈' },
];
export function CalendarSection({
schedules,
issues = [],
onScheduleClick,
onScheduleEdit,
}: CalendarSectionProps) {
const router = useRouter();
const [selectedDate, setSelectedDate] = useState<Date | null>(new Date());
const [currentDate, setCurrentDate] = useState(new Date());
const [viewType, setViewType] = useState<CalendarViewType>('month');
const [deptFilter, setDeptFilter] = useState<CalendarDeptFilterType>('all');
const [taskFilter, setTaskFilter] = useState<ExtendedTaskFilterType>('all');
// 스토어에서 공휴일/세무일정 가져오기 (API 연동)
const schedulesByYear = useCalendarScheduleStore((s) => s.schedulesByYear);
// TODO: 백엔드 API(/api/v1/calendar-schedules) 구현 후 주석 해제
// const fetchSchedules = useCalendarScheduleStore((s) => s.fetchSchedules);
// useEffect(() => {
// const year = currentDate.getFullYear();
// fetchSchedules(year);
// }, [currentDate, fetchSchedules]);
// 날짜가 있는 이슈만 필터링
const issuesWithDate = useMemo(() => {
return issues.filter((issue) => issue.date);
}, [issues]);
// 필터링된 스케줄
const filteredSchedules = useMemo(() => {
// 이슈 필터일 경우 스케줄 제외
if (taskFilter === 'issue') {
return [];
}
let result = schedules;
// 업무 필터
if (taskFilter !== 'all') {
result = result.filter((s) => s.type === taskFilter);
}
// 부서 필터 (실제 구현시 부서/개인 로직 추가 필요)
// 현재는 기본 구현만
return result;
}, [schedules, taskFilter, deptFilter]);
// 필터링된 이슈
const filteredIssues = useMemo(() => {
// 이슈 필터가 아니고 all도 아닌 경우 이슈 제외
if (taskFilter !== 'all' && taskFilter !== 'issue') {
return [];
}
return issuesWithDate;
}, [issuesWithDate, taskFilter]);
// 현재 연도의 공휴일/세금일정 (스토어 우선, 폴백 상수)
const staticEvents: ScheduleEvent[] = useMemo(() => {
const year = currentDate.getFullYear();
const events = getCalendarEventsForYear(year);
return events.map((event: CalendarEvent) => ({
id: `${event.type}-${event.date}`,
title: event.type === 'holiday' ? `🔴 ${event.name}` : `🟠 ${event.name}`,
startDate: event.date,
endDate: event.date,
color: SCHEDULE_TYPE_COLORS[event.type] || 'gray',
data: { ...event, _type: event.type as 'holiday' | 'tax' },
}));
// schedulesByYear를 의존성에 추가하여 스토어 갱신 시 리렌더링
}, [currentDate, schedulesByYear]);
// ScheduleCalendar용 이벤트 변환 (스케줄 + 이슈 + 공휴일/세금 통합)
const calendarEvents: ScheduleEvent[] = useMemo(() => {
const scheduleEvents = filteredSchedules.map((schedule) => ({
id: schedule.id,
// 기획서: [부서명] 제목 형식
title: schedule.department ? `[${schedule.department}] ${schedule.title}` : schedule.title,
startDate: schedule.startDate,
endDate: schedule.endDate,
color: SCHEDULE_TYPE_COLORS[schedule.type] || 'gray',
data: { ...schedule, _type: 'schedule' as const },
}));
const issueEvents = filteredIssues.map((issue) => ({
id: issue.id,
title: `[${issue.badge}] ${issue.content}`,
startDate: issue.date!,
endDate: issue.date!,
color: 'red',
data: { ...issue, _type: 'issue' as const },
}));
return [...staticEvents, ...scheduleEvents, ...issueEvents];
}, [staticEvents, filteredSchedules, filteredIssues]);
// 선택된 날짜의 일정 + 이슈 + 공휴일/세금 목록
const selectedDateItems = useMemo(() => {
if (!selectedDate) return { schedules: [], issues: [], staticEvents: [] };
// 로컬 타임존 기준으로 날짜 문자열 생성 (UTC 변환 방지)
const year = selectedDate.getFullYear();
const month = String(selectedDate.getMonth() + 1).padStart(2, '0');
const day = String(selectedDate.getDate()).padStart(2, '0');
const dateStr = `${year}-${month}-${day}`;
const dateSchedules = filteredSchedules.filter((schedule) => {
return schedule.startDate <= dateStr && schedule.endDate >= dateStr;
});
const dateIssues = filteredIssues.filter((issue) => issue.date === dateStr);
// 공휴일/세금일정
const dateStaticEvents = staticEvents.filter((event) => event.startDate === dateStr);
return { schedules: dateSchedules, issues: dateIssues, staticEvents: dateStaticEvents };
}, [selectedDate, filteredSchedules, filteredIssues, staticEvents]);
// 총 건수 계산
const totalItemCount = selectedDateItems.schedules.length + selectedDateItems.issues.length + selectedDateItems.staticEvents.length;
// 날짜 포맷 (기획서: "1월 6일 화요일")
const formatSelectedDate = (date: Date) => {
const month = date.getMonth() + 1;
const day = date.getDate();
const dayNames = ['일요일', '월요일', '화요일', '수요일', '목요일', '금요일', '토요일'];
const dayName = dayNames[date.getDay()];
return `${month}${day}${dayName}`;
};
// 일정 상세 정보 포맷 (기획서: 부서명 | 날짜 | 시간)
const formatScheduleDetail = (schedule: CalendarScheduleItem) => {
const parts: string[] = [];
// 부서명 또는 담당자명
if (schedule.department) {
parts.push(schedule.department);
} else if (schedule.personName) {
parts.push(schedule.personName);
}
// 날짜 (여러 날인 경우)
if (schedule.startDate !== schedule.endDate) {
parts.push(`${schedule.startDate}~${schedule.endDate}`);
}
// 시간
if (schedule.startTime && schedule.endTime) {
parts.push(`${schedule.startTime} ~ ${schedule.endTime}`);
} else if (schedule.startTime) {
parts.push(schedule.startTime);
}
return parts.join(' | ');
};
const handleDateClick = (date: Date) => {
setSelectedDate(date);
};
const handleEventClick = (event: ScheduleEvent) => {
const schedule = event.data as CalendarScheduleItem;
onScheduleClick?.(schedule);
};
const handleMonthChange = (date: Date) => {
setCurrentDate(date);
};
// 모바일 리스트뷰: 현재 월의 모든 날짜와 이벤트
const monthDaysWithEvents = useMemo(() => {
const year = currentDate.getFullYear();
const month = currentDate.getMonth();
const daysInMonth = new Date(year, month + 1, 0).getDate();
const dayNames = ['일', '월', '화', '수', '목', '금', '토'];
const today = new Date();
today.setHours(0, 0, 0, 0);
const days: Array<{
date: Date;
dateStr: string;
label: string;
isToday: boolean;
isWeekend: boolean;
events: ScheduleEvent[];
}> = [];
for (let d = 1; d <= daysInMonth; d++) {
const date = new Date(year, month, d);
const mm = String(month + 1).padStart(2, '0');
const dd = String(d).padStart(2, '0');
const dateStr = `${year}-${mm}-${dd}`;
const dayOfWeek = date.getDay();
const dayEvents = calendarEvents.filter(
(ev) => ev.startDate <= dateStr && ev.endDate >= dateStr
);
days.push({
date,
dateStr,
label: `${d}${dayNames[dayOfWeek]}요일`,
isToday: date.getTime() === today.getTime(),
isWeekend: dayOfWeek === 0 || dayOfWeek === 6,
events: dayEvents,
});
}
return days;
}, [currentDate, calendarEvents]);
const handleMobilePrevMonth = () => {
const prev = new Date(currentDate.getFullYear(), currentDate.getMonth() - 1, 1);
setCurrentDate(prev);
};
const handleMobileNextMonth = () => {
const next = new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 1);
setCurrentDate(next);
};
return (
<CollapsibleDashboardCard
icon={<CalendarDays style={{ color: '#ffffff' }} className="h-5 w-5" />}
title="캘린더"
subtitle="일정 관리"
>
{/* 모바일: 필터+월네비 */}
<div className="lg:hidden mb-3">
<div className="flex items-center gap-2 mb-3">
{/* 부서 필터 */}
<Select
value={deptFilter}
onValueChange={(value) => setDeptFilter(value as CalendarDeptFilterType)}
>
<SelectTrigger className="min-w-[80px] w-auto h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DEPT_FILTER_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 업무 필터 */}
<Select
value={taskFilter}
onValueChange={(value) => setTaskFilter(value as CalendarTaskFilterType)}
>
<SelectTrigger className="min-w-[80px] w-auto h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TASK_FILTER_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 월 네비게이션 */}
<div className="flex items-center justify-center gap-4">
<Button variant="outline" size="sm" className="h-8 px-2" onClick={handleMobilePrevMonth}>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="text-sm font-semibold text-foreground whitespace-nowrap">
{currentDate.getFullYear()} {currentDate.getMonth() + 1}
</span>
<Button variant="outline" size="sm" className="h-8 px-2" onClick={handleMobileNextMonth}>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
{/* 데스크탑: 필터 */}
<div className="hidden lg:flex items-center justify-end mb-4 gap-2">
<Select
value={deptFilter}
onValueChange={(value) => setDeptFilter(value as CalendarDeptFilterType)}
>
<SelectTrigger className="min-w-[80px] w-auto h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DEPT_FILTER_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={taskFilter}
onValueChange={(value) => setTaskFilter(value as CalendarTaskFilterType)}
>
<SelectTrigger className="min-w-[80px] w-auto h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TASK_FILTER_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 모바일: 리스트뷰 */}
<div className="lg:hidden pt-3">
{/* 일별 리스트 */}
<div className="divide-y divide-border">
{monthDaysWithEvents.map((day) => {
const hasEvents = day.events.length > 0;
const isSelected = selectedDate && day.date.getTime() === selectedDate.getTime();
return (
<div
key={day.dateStr}
className={`px-3 py-2.5 cursor-pointer transition-colors ${
isSelected ? 'bg-blue-50 dark:bg-blue-900/30' :
day.isToday ? 'bg-amber-50 dark:bg-amber-900/30' : ''
} ${!hasEvents && !day.isToday && !isSelected ? 'opacity-50' : ''}`}
onClick={() => handleDateClick(day.date)}
>
{/* 날짜 + 일정등록 버튼 */}
<div className="flex items-center justify-between mb-1">
<div className={`text-sm font-medium ${
day.isWeekend ? 'text-red-500' : 'text-foreground'
} ${day.isToday ? 'font-bold' : ''}`}>
{day.label}
{day.isToday && <span className="ml-1 text-amber-600 dark:text-amber-400 text-xs font-semibold">()</span>}
</div>
{isSelected && (
<Button
variant="outline"
size="sm"
className="h-6 text-xs gap-0.5 px-2"
onClick={(e) => {
e.stopPropagation();
onScheduleEdit?.({
id: '',
title: '',
startDate: day.dateStr,
endDate: day.dateStr,
type: 'schedule',
});
}}
>
<Plus className="h-3 w-3" />
</Button>
)}
</div>
{/* 이벤트 목록 (날짜 아래) */}
{hasEvents ? (
<div className="space-y-1 pl-1">
{(isSelected ? day.events : day.events.slice(0, 3)).map((ev) => {
const evData = ev.data as Record<string, unknown>;
const evType = evData?._type as string;
const colorMap: Record<string, string> = {
holiday: 'bg-red-500',
tax: 'bg-orange-500',
schedule: 'bg-blue-500',
order: 'bg-green-500',
construction: 'bg-purple-500',
issue: 'bg-red-400',
};
const dotColor = colorMap[evType] || 'bg-gray-400';
const title = evData?.name as string || evData?.title as string || ev.title;
const cleanTitle = title?.replace(/^[🔴🟠]\s*/, '') || '';
return (
<div key={ev.id} className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span className={`w-2 h-2 rounded-full shrink-0 ${dotColor}`} />
<span className={isSelected ? '' : 'truncate'}>{cleanTitle}</span>
</div>
);
})}
{!isSelected && day.events.length > 3 && (
<div className="text-xs text-muted-foreground pl-3.5">+{day.events.length - 3}</div>
)}
</div>
) : null}
</div>
);
})}
</div>
</div>
{/* 데스크탑: 기존 캘린더 + 상세 */}
<div className="hidden lg:grid lg:grid-cols-2 gap-6">
{/* 캘린더 영역 */}
<div>
<ScheduleCalendar
events={calendarEvents}
currentDate={currentDate}
selectedDate={selectedDate}
onDateClick={handleDateClick}
onEventClick={handleEventClick}
onMonthChange={handleMonthChange}
maxEventsPerDay={4}
weekStartsOn={1}
className="[&_.weekend]:bg-yellow-50 dark:[&_.weekend]:bg-yellow-900/20"
/>
</div>
{/* 선택된 날짜 일정 + 이슈 목록 */}
<div className="border border-border rounded-lg p-4">
<div className="flex items-center justify-between mb-2">
<h4 className="text-lg font-semibold text-foreground">
{selectedDate ? formatSelectedDate(selectedDate) : '날짜를 선택하세요'}
</h4>
<Button
variant="outline"
size="sm"
className="h-7 text-xs gap-1"
onClick={() => {
const year = selectedDate?.getFullYear() || new Date().getFullYear();
const month = String((selectedDate?.getMonth() || new Date().getMonth()) + 1).padStart(2, '0');
const day = String(selectedDate?.getDate() || new Date().getDate()).padStart(2, '0');
const dateStr = `${year}-${month}-${day}`;
onScheduleEdit?.({
id: '',
title: '',
startDate: dateStr,
endDate: dateStr,
type: 'schedule',
});
}}
>
<Plus className="h-3 w-3" />
</Button>
</div>
<div className="text-sm text-muted-foreground mb-4">
{totalItemCount}
</div>
{totalItemCount === 0 ? (
<div className="text-center py-8 text-muted-foreground">
.
</div>
) : (
<div className="space-y-3 max-h-[calc(100vh-400px)] overflow-y-auto pr-1">
{selectedDateItems.staticEvents.map((event) => {
const eventData = event.data as CalendarEvent & { _type: string };
const isHoliday = eventData.type === 'holiday';
return (
<div
key={event.id}
className={`p-3 rounded-lg ${
isHoliday
? 'bg-red-50 border border-red-200 dark:bg-red-900/30 dark:border-red-800'
: 'bg-orange-50 border border-orange-200 dark:bg-orange-900/30 dark:border-orange-800'
}`}
>
<div className="flex items-center gap-2">
<span className="text-lg">{isHoliday ? '🔴' : '🟠'}</span>
<span className="font-medium text-foreground">{eventData.name}</span>
</div>
<div className="text-xs text-muted-foreground mt-1">
{isHoliday ? '공휴일' : '세금 신고 마감일'}
</div>
</div>
);
})}
{selectedDateItems.schedules.map((schedule) => (
<div
key={schedule.id}
className="p-3 border border-border rounded-lg hover:bg-muted/50 transition-colors cursor-pointer"
onClick={() => onScheduleClick?.(schedule)}
>
<div className="font-medium text-base text-foreground mb-1">{schedule.title}</div>
<div className="text-sm text-muted-foreground">{formatScheduleDetail(schedule)}</div>
</div>
))}
{selectedDateItems.issues.map((issue) => (
<div
key={issue.id}
className="p-3 border border-red-200 dark:border-red-800 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors cursor-pointer"
onClick={() => {
if (issue.path) router.push(`/ko${issue.path}`);
}}
>
<div className="flex items-start gap-2 mb-1">
<Badge
variant="secondary"
className={`shrink-0 text-xs ${ISSUE_BADGE_COLORS[issue.badge]}`}
>
{issue.badge}
</Badge>
<span className="font-medium text-sm text-foreground flex-1">{issue.content}</span>
</div>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>{issue.time}</span>
{issue.path && (
<span className="flex items-center gap-1 text-blue-600 dark:text-blue-400 hover:underline">
<ExternalLink className="h-3 w-3" />
</span>
)}
</div>
</div>
))}
</div>
)}
</div>
</div>
</CollapsibleDashboardCard>
);
}