feat(WEB): 입찰/계약/주문관리 기능 추가 및 견적 상세 리팩토링

- 입찰관리: 목록/상세/수정 페이지 및 목업 데이터
- 계약관리: 목록/상세/수정 페이지 구현
- 주문관리: 수주/발주 목록 및 상세 페이지 구현
- 견적 상세 폼: 섹션별 분리 및 hooks/utils 리팩토링
- 품목관리, 카테고리관리, 단가관리 기능 추가
- 현장설명회/협력업체 폼 개선
- 프린트 유틸리티 공통화 (print-utils.ts)
- 문서 모달 공통 컴포넌트 정리
- IntegratedListTemplateV2, StatCards 개선

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
byeongcheolryu
2026-01-05 18:59:04 +09:00
parent 4b1a3abf05
commit 386cd30bc0
145 changed files with 25782 additions and 254 deletions

View File

@@ -0,0 +1,81 @@
'use client';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/components/ui/utils';
import type { CalendarHeaderProps, CalendarView } from './types';
import { formatYearMonth } from './utils';
/**
* 달력 헤더 컴포넌트
* - 년월 표시 및 네비게이션 (◀ ▶)
* - 주/월 뷰 전환 탭
* - 필터 slot (children으로 외부 주입)
*/
export function CalendarHeader({
currentDate,
view,
onPrevMonth,
onNextMonth,
onViewChange,
filterSlot,
}: CalendarHeaderProps) {
const views: { value: CalendarView; label: string }[] = [
{ value: 'week', label: '주' },
{ value: 'month', label: '월' },
];
return (
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between pb-3 border-b">
{/* 좌측: 년월 네비게이션 */}
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
className="h-8 w-8 shrink-0 hover:bg-primary/10"
onClick={onPrevMonth}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="text-lg font-bold min-w-[120px] text-center">
{formatYearMonth(currentDate)}
</span>
<Button
variant="outline"
size="icon"
className="h-8 w-8 shrink-0 hover:bg-primary/10"
onClick={onNextMonth}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
{/* 우측: 뷰 전환 + 필터 */}
<div className="flex items-center gap-3">
{/* 뷰 전환 탭 */}
<div className="flex rounded-md border">
{views.map((v) => (
<button
key={v.value}
onClick={() => onViewChange(v.value)}
className={cn(
'px-4 py-1.5 text-sm font-medium transition-colors',
'first:rounded-l-md last:rounded-r-md',
view === v.value
? 'bg-primary text-primary-foreground'
: 'hover:bg-primary/10 text-foreground'
)}
>
{v.label}
</button>
))}
</div>
{/* 필터 슬롯 */}
{filterSlot && <div className="flex items-center gap-2">{filterSlot}</div>}
</div>
</div>
);
}

View File

@@ -0,0 +1,73 @@
'use client';
import { cn } from '@/components/ui/utils';
import type { DayBadge } from './types';
import { BADGE_COLORS } from './types';
import { format } from 'date-fns';
interface DayCellProps {
date: Date;
isCurrentMonth: boolean;
isToday: boolean;
isSelected: boolean;
isWeekend: boolean;
badge?: DayBadge;
onClick: (date: Date) => void;
}
/**
* 일자 셀 컴포넌트
* - 날짜 숫자 표시
* - 뱃지 숫자 표시 (빨간 원)
* - 클릭 이벤트 처리
* - 선택/오늘 상태 스타일
*/
export function DayCell({
date,
isCurrentMonth,
isToday,
isSelected,
isWeekend,
badge,
onClick,
}: DayCellProps) {
const dayNumber = format(date, 'd');
const badgeColor = badge?.color || 'red';
return (
<button
type="button"
onClick={() => onClick(date)}
className={cn(
'relative w-full h-8 flex items-center justify-center',
'text-sm font-medium transition-colors rounded-md',
'hover:bg-primary/10',
// 현재 월 여부
isCurrentMonth ? 'text-foreground' : 'text-muted-foreground/40',
// 주말 색상
isWeekend && isCurrentMonth && 'text-red-500',
// 오늘
isToday && 'bg-accent text-accent-foreground font-bold',
// 선택됨
isSelected && 'bg-primary text-primary-foreground hover:bg-primary'
)}
>
{/* 날짜 숫자 */}
<span>{dayNumber}</span>
{/* 뱃지 */}
{badge && badge.count > 0 && (
<span
className={cn(
'absolute -top-1 -right-1 min-w-[18px] h-[18px] px-1',
'flex items-center justify-center',
'text-[10px] font-bold rounded-full',
BADGE_COLORS[badgeColor] || BADGE_COLORS.red
)}
>
{badge.count > 99 ? '99+' : badge.count}
</span>
)}
</button>
);
}

View File

@@ -0,0 +1,236 @@
'use client';
import { useMemo } from 'react';
import { cn } from '@/components/ui/utils';
import { DayCell } from './DayCell';
import { ScheduleBar } from './ScheduleBar';
import { MorePopover } from './MorePopover';
import type { MonthViewProps } from './types';
import {
getMonthDays,
getWeekdayHeaders,
isCurrentMonth,
checkIsToday,
isSameDate,
splitIntoWeeks,
getEventSegmentsForWeek,
assignEventRows,
getEventsForDate,
getBadgeForDate,
} from './utils';
import { getDay } from 'date-fns';
/**
* 월간 뷰 컴포넌트
* - 월간 그리드 레이아웃 (7xN)
* - 요일 헤더 (일~토)
* - 날짜 셀 렌더링
* - 일정 바 렌더링
* - +N 더보기 표시
*/
export function MonthView({
currentDate,
events,
badges,
selectedDate,
maxEventsPerDay,
weekStartsOn,
onDateClick,
onEventClick,
}: MonthViewProps) {
// 요일 헤더
const weekdayHeaders = useMemo(
() => getWeekdayHeaders(weekStartsOn),
[weekStartsOn]
);
// 월간 날짜 배열
const monthDays = useMemo(
() => getMonthDays(currentDate, weekStartsOn),
[currentDate, weekStartsOn]
);
// 주 단위로 분할
const weeks = useMemo(() => splitIntoWeeks(monthDays), [monthDays]);
return (
<div className="flex flex-col">
{/* 요일 헤더 */}
<div className="grid grid-cols-7 mb-1">
{weekdayHeaders.map((day, index) => {
const isWeekend =
(weekStartsOn === 0 && (index === 0 || index === 6)) ||
(weekStartsOn === 1 && (index === 5 || index === 6));
return (
<div
key={day}
className={cn(
'text-center text-xs font-semibold py-2',
'text-muted-foreground',
isWeekend && 'text-red-400'
)}
>
{day}
</div>
);
})}
</div>
{/* 주 단위 렌더링 */}
<div className="flex flex-col border rounded-md overflow-hidden">
{weeks.map((weekDays, weekIndex) => (
<WeekRow
key={weekIndex}
weekDays={weekDays}
events={events}
badges={badges}
currentDate={currentDate}
selectedDate={selectedDate}
maxEventsPerDay={maxEventsPerDay}
weekStartsOn={weekStartsOn}
onDateClick={onDateClick}
onEventClick={onEventClick}
/>
))}
</div>
</div>
);
}
interface WeekRowProps {
weekDays: Date[];
events: import('./types').ScheduleEvent[];
badges: import('./types').DayBadge[];
currentDate: Date;
selectedDate: Date | null;
maxEventsPerDay: number;
weekStartsOn: 0 | 1;
onDateClick: (date: Date) => void;
onEventClick: (event: import('./types').ScheduleEvent) => void;
}
function WeekRow({
weekDays,
events,
badges,
currentDate,
selectedDate,
maxEventsPerDay,
weekStartsOn,
onDateClick,
onEventClick,
}: WeekRowProps) {
// 이 주에 해당하는 이벤트 세그먼트 계산
const eventSegments = useMemo(
() => getEventSegmentsForWeek(events, weekDays, weekStartsOn),
[events, weekDays, weekStartsOn]
);
// 이벤트 행 배치
const rowAssignments = useMemo(
() => assignEventRows(eventSegments),
[eventSegments]
);
// 표시할 이벤트 수 계산 (maxEventsPerDay 초과 시 +N 표시)
const visibleRows = maxEventsPerDay;
// 각 날짜별 숨겨진 이벤트 수 계산
const hiddenEventCounts = useMemo(() => {
const counts: Map<number, number> = new Map();
weekDays.forEach((date, colIndex) => {
const dayEvents = getEventsForDate(events, date);
const hidden = Math.max(0, dayEvents.length - visibleRows);
if (hidden > 0) {
counts.set(colIndex, hidden);
}
});
return counts;
}, [weekDays, events, visibleRows]);
// 셀 최소 높이 계산 (이벤트 행 수에 따라) - 더 넉넉하게 확보
const maxRowIndex = Math.max(0, ...Array.from(rowAssignments.values()));
const rowHeight = Math.max(120, 40 + Math.min(maxRowIndex + 1, visibleRows) * 28 + 24);
return (
<div
className="relative grid grid-cols-7 border-b last:border-b-0"
style={{ minHeight: `${rowHeight}px` }}
>
{/* 날짜 셀들 */}
{weekDays.map((date, colIndex) => {
const dayOfWeek = getDay(date);
const isWeekend = dayOfWeek === 0 || dayOfWeek === 6;
const badge = getBadgeForDate(badges, date);
const hiddenCount = hiddenEventCounts.get(colIndex) || 0;
const dayEvents = getEventsForDate(events, date);
const isSelected = isSameDate(selectedDate, date);
return (
<div
key={date.toISOString()}
className={cn(
'relative p-1 border-r last:border-r-0',
'flex flex-col cursor-pointer transition-colors',
// 기본 배경
!isCurrentMonth(date, currentDate) && 'bg-muted/30',
// 선택된 날짜 - 셀 전체 배경색 변경 (테두리 없이)
isSelected && 'bg-primary/15'
)}
onClick={() => onDateClick(date)}
>
{/* 날짜 셀 */}
<DayCell
date={date}
isCurrentMonth={isCurrentMonth(date, currentDate)}
isToday={checkIsToday(date)}
isSelected={isSelected}
isWeekend={isWeekend}
badge={badge}
onClick={onDateClick}
/>
{/* 더보기 버튼 (하단에 배치) */}
{hiddenCount > 0 && (
<div className="absolute bottom-1 left-1">
<MorePopover
date={date}
events={dayEvents}
hiddenCount={hiddenCount}
onEventClick={onEventClick}
onDateClick={onDateClick}
/>
</div>
)}
</div>
);
})}
{/* 이벤트 바들 (절대 위치) */}
{eventSegments
.filter((segment) => {
const rowIndex = rowAssignments.get(segment.event.id) || 0;
return rowIndex < visibleRows;
})
.map((segment) => {
const rowIndex = rowAssignments.get(segment.event.id) || 0;
return (
<ScheduleBar
key={`${segment.event.id}-${weekDays[0].toISOString()}`}
event={segment.event}
isStart={segment.isStart}
isEnd={segment.isEnd}
colSpan={segment.colSpan}
startCol={segment.startCol}
rowIndex={rowIndex}
onClick={onEventClick}
/>
);
})}
</div>
);
}

View File

@@ -0,0 +1,45 @@
'use client';
import { cn } from '@/components/ui/utils';
import type { ScheduleEvent } from './types';
interface MorePopoverProps {
date: Date;
events: ScheduleEvent[];
hiddenCount: number;
onEventClick: (event: ScheduleEvent) => void;
onDateClick?: (date: Date) => void;
}
/**
* 더보기 버튼 컴포넌트
* - +N 버튼 렌더링
* - 클릭 시 해당 날짜 선택 (테이블 필터링)
*/
export function MorePopover({
date,
hiddenCount,
onDateClick,
}: MorePopoverProps) {
if (hiddenCount <= 0) return null;
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation();
// 날짜 선택 → 테이블 필터링
onDateClick?.(date);
};
return (
<button
type="button"
className={cn(
'text-xs font-medium text-muted-foreground',
'hover:text-primary hover:underline',
'transition-colors cursor-pointer'
)}
onClick={handleClick}
>
+{hiddenCount}
</button>
);
}

View File

@@ -0,0 +1,73 @@
'use client';
import { cn } from '@/components/ui/utils';
import type { ScheduleEvent } from './types';
import { EVENT_COLORS } from './types';
interface ScheduleBarProps {
event: ScheduleEvent;
/** 현재 주에서 시작하는지 */
isStart: boolean;
/** 현재 주에서 끝나는지 */
isEnd: boolean;
/** 차지하는 컬럼 수 (1-7) */
colSpan: number;
/** 시작 위치 (0-6) */
startCol: number;
/** 행 인덱스 (겹치는 이벤트 처리) */
rowIndex: number;
onClick: (event: ScheduleEvent) => void;
}
/**
* 일정 바 컴포넌트
* - 일정 바 렌더링 (시작~종료 날짜)
* - 여러 날에 걸치는 바 표시
* - 색상 구분 (상태별)
* - 호버/클릭 이벤트
*/
export function ScheduleBar({
event,
isStart,
isEnd,
colSpan,
startCol,
rowIndex,
onClick,
}: ScheduleBarProps) {
const color = event.color || 'blue';
const colorClass = EVENT_COLORS[color] || EVENT_COLORS.blue;
// 컬럼 너비 계산 (각 셀은 1/7)
const widthPercent = (colSpan / 7) * 100;
const leftPercent = (startCol / 7) * 100;
return (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onClick(event);
}}
className={cn(
'absolute h-5 px-2 text-xs font-medium truncate',
'transition-all hover:opacity-80 hover:shadow-sm',
'flex items-center cursor-pointer',
colorClass,
// 라운드 처리
isStart && isEnd && 'rounded-md',
isStart && !isEnd && 'rounded-l-md',
!isStart && isEnd && 'rounded-r-md',
!isStart && !isEnd && 'rounded-none'
)}
style={{
width: `calc(${widthPercent}% - 4px)`,
left: `calc(${leftPercent}% + 2px)`,
top: `${rowIndex * 24 + 32}px`, // 날짜 영역(32px) 아래부터 시작
}}
title={event.title}
>
{isStart && <span className="truncate">{event.title}</span>}
</button>
);
}

View File

@@ -0,0 +1,153 @@
'use client';
import { useState, useCallback, useEffect } from 'react';
import { cn } from '@/components/ui/utils';
import { CalendarHeader } from './CalendarHeader';
import { MonthView } from './MonthView';
import { WeekView } from './WeekView';
import type { ScheduleCalendarProps, CalendarView } from './types';
import { getNextMonth, getPrevMonth } from './utils';
/**
* 스케줄 달력 공통 컴포넌트
*
* 주/월 뷰 전환, 일정 바 표시, 날짜별 뱃지 등을 지원하는 재사용 가능한 달력
*
* @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,
filterSlot,
maxEventsPerDay = 3,
weekStartsOn = 0,
isLoading = false,
className,
}: 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);
}, []);
// 이전 달
const handlePrevMonth = useCallback(() => {
const newDate = getPrevMonth(currentDate);
if (controlledDate === undefined) {
setInternalDate(newDate);
}
onMonthChange?.(newDate);
}, [currentDate, controlledDate, onMonthChange]);
// 다음 달
const handleNextMonth = useCallback(() => {
const newDate = getNextMonth(currentDate);
if (controlledDate === undefined) {
setInternalDate(newDate);
}
onMonthChange?.(newDate);
}, [currentDate, 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}
filterSlot={filterSlot}
/>
{/* 본문 */}
<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 === '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>
);
}

View File

@@ -0,0 +1,200 @@
'use client';
import { useMemo } from 'react';
import { cn } from '@/components/ui/utils';
import { DayCell } from './DayCell';
import { ScheduleBar } from './ScheduleBar';
import { MorePopover } from './MorePopover';
import type { WeekViewProps } from './types';
import {
getWeekDays,
checkIsToday,
isSameDate,
getEventSegmentsForWeek,
assignEventRows,
getEventsForDate,
getBadgeForDate,
} from './utils';
import { format, getDay } from 'date-fns';
import { ko } from 'date-fns/locale';
/**
* 주간 뷰 컴포넌트
* - 주간 그리드 레이아웃 (7 컬럼)
* - 요일 헤더 (날짜 + 요일)
* - 날짜 셀 렌더링
* - 일정 바 렌더링
*/
export function WeekView({
currentDate,
events,
badges,
selectedDate,
maxEventsPerDay,
weekStartsOn,
onDateClick,
onEventClick,
}: WeekViewProps) {
// 주간 날짜 배열
const weekDays = useMemo(
() => getWeekDays(currentDate, weekStartsOn),
[currentDate, weekStartsOn]
);
// 이 주에 해당하는 이벤트 세그먼트 계산
const eventSegments = useMemo(
() => getEventSegmentsForWeek(events, weekDays, weekStartsOn),
[events, weekDays, weekStartsOn]
);
// 이벤트 행 배치
const rowAssignments = useMemo(
() => assignEventRows(eventSegments),
[eventSegments]
);
// 표시할 이벤트 수 계산
const visibleRows = maxEventsPerDay;
// 각 날짜별 숨겨진 이벤트 수 계산
const hiddenEventCounts = useMemo(() => {
const counts: Map<number, number> = new Map();
weekDays.forEach((date, colIndex) => {
const dayEvents = getEventsForDate(events, date);
const hidden = Math.max(0, dayEvents.length - visibleRows);
if (hidden > 0) {
counts.set(colIndex, hidden);
}
});
return counts;
}, [weekDays, events, visibleRows]);
// 셀 최소 높이 계산
const maxRowIndex = Math.max(0, ...Array.from(rowAssignments.values()));
const rowHeight = Math.max(120, 48 + Math.min(maxRowIndex + 1, visibleRows) * 24 + 24);
return (
<div className="flex flex-col">
{/* 요일 헤더 (날짜 + 요일) */}
<div className="grid grid-cols-7 mb-1">
{weekDays.map((date) => {
const dayOfWeek = getDay(date);
const isWeekend = dayOfWeek === 0 || dayOfWeek === 6;
const isToday = checkIsToday(date);
return (
<div
key={date.toISOString()}
className={cn(
'text-center py-2',
'flex flex-col items-center'
)}
>
<span
className={cn(
'text-xs font-semibold text-muted-foreground',
isWeekend && 'text-red-400'
)}
>
{format(date, 'E', { locale: ko })}
</span>
<span
className={cn(
'text-lg font-bold mt-0.5',
isWeekend && 'text-red-500',
isToday && 'bg-primary text-primary-foreground rounded-full w-8 h-8 flex items-center justify-center'
)}
>
{format(date, 'd')}
</span>
</div>
);
})}
</div>
{/* 주간 본문 */}
<div
className="relative grid grid-cols-7 border rounded-md overflow-hidden"
style={{ minHeight: `${rowHeight}px` }}
>
{/* 날짜 셀들 */}
{weekDays.map((date, colIndex) => {
const dayOfWeek = getDay(date);
const isWeekend = dayOfWeek === 0 || dayOfWeek === 6;
const badge = getBadgeForDate(badges, date);
const hiddenCount = hiddenEventCounts.get(colIndex) || 0;
const dayEvents = getEventsForDate(events, date);
return (
<div
key={date.toISOString()}
className={cn(
'relative p-2 border-r last:border-r-0',
'flex flex-col cursor-pointer transition-colors',
// 선택된 날짜 - 배경색 + 호버 시 더 진하게
isSameDate(selectedDate, date)
? 'bg-primary/15 hover:bg-primary/25'
: 'hover:bg-muted/30'
)}
onClick={() => onDateClick(date)}
>
{/* 뱃지 */}
{badge && badge.count > 0 && (
<div className="absolute top-1 right-1">
<span
className={cn(
'min-w-[18px] h-[18px] px-1',
'flex items-center justify-center',
'text-[10px] font-bold rounded-full',
'bg-red-500 text-white'
)}
>
{badge.count > 99 ? '99+' : badge.count}
</span>
</div>
)}
{/* 더보기 버튼 (하단에 배치) */}
{hiddenCount > 0 && (
<div className="absolute bottom-1 left-2">
<MorePopover
date={date}
events={dayEvents}
hiddenCount={hiddenCount}
onEventClick={onEventClick}
onDateClick={onDateClick}
/>
</div>
)}
</div>
);
})}
{/* 이벤트 바들 (절대 위치) */}
{eventSegments
.filter((segment) => {
const rowIndex = rowAssignments.get(segment.event.id) || 0;
return rowIndex < visibleRows;
})
.map((segment) => {
const rowIndex = rowAssignments.get(segment.event.id) || 0;
return (
<ScheduleBar
key={`${segment.event.id}-week`}
event={segment.event}
isStart={segment.isStart}
isEnd={segment.isEnd}
colSpan={segment.colSpan}
startCol={segment.startCol}
rowIndex={rowIndex}
onClick={onEventClick}
/>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,30 @@
/**
* ScheduleCalendar 공통 컴포넌트
*
* 주/월 뷰 전환, 일정 바 표시, 날짜별 뱃지 등을 지원하는 재사용 가능한 스케줄 달력
*/
export { ScheduleCalendar } from './ScheduleCalendar';
export { CalendarHeader } from './CalendarHeader';
export { MonthView } from './MonthView';
export { WeekView } from './WeekView';
export { DayCell } from './DayCell';
export { ScheduleBar } from './ScheduleBar';
export { MorePopover } from './MorePopover';
export type {
ScheduleCalendarProps,
ScheduleEvent,
DayBadge,
CalendarView,
CalendarHeaderProps,
DayCellProps,
ScheduleBarProps,
MorePopoverProps,
MonthViewProps,
WeekViewProps,
} from './types';
export { EVENT_COLORS, BADGE_COLORS } from './types';
export * from './utils';

View File

@@ -0,0 +1,179 @@
/**
* ScheduleCalendar 공통 컴포넌트 타입 정의
*/
/**
* 달력 뷰 모드
*/
export type CalendarView = 'week' | 'month';
/**
* 일정 이벤트
*/
export interface ScheduleEvent {
/** 이벤트 고유 ID */
id: string;
/** 이벤트 제목 (예: "담당자 - 현장명 / 발주번호") */
title: string;
/** 시작 날짜 (yyyy-MM-dd 형식) */
startDate: string;
/** 종료 날짜 (yyyy-MM-dd 형식) */
endDate: string;
/** 이벤트 색상 (기본 색상명 또는 커스텀) */
color?: string;
/** 이벤트 상태 */
status?: string;
/** 추가 데이터 */
data?: unknown;
}
/**
* 일자별 뱃지 정보
*/
export interface DayBadge {
/** 날짜 (yyyy-MM-dd 형식) */
date: string;
/** 뱃지에 표시할 숫자 */
count: number;
/** 뱃지 색상 */
color?: 'red' | 'blue' | 'yellow' | 'green' | 'gray';
}
/**
* 달력 Props
*/
export interface ScheduleCalendarProps {
/** 일정 이벤트 목록 */
events: ScheduleEvent[];
/** 일자별 뱃지 목록 */
badges?: DayBadge[];
/** 현재 년월 (Date 객체) */
currentDate?: Date;
/** 현재 뷰 모드 */
view?: CalendarView;
/** 선택된 날짜 */
selectedDate?: Date | null;
/** 날짜 클릭 핸들러 */
onDateClick?: (date: Date) => void;
/** 이벤트 클릭 핸들러 */
onEventClick?: (event: ScheduleEvent) => void;
/** 월 변경 핸들러 */
onMonthChange?: (date: Date) => void;
/** 뷰 모드 변경 핸들러 */
onViewChange?: (view: CalendarView) => void;
/** 필터 영역 (slot) */
filterSlot?: React.ReactNode;
/** 최대 표시 이벤트 수 (초과 시 +N 표시) */
maxEventsPerDay?: number;
/** 주 시작 요일 (0: 일요일, 1: 월요일) */
weekStartsOn?: 0 | 1;
/** 로딩 상태 */
isLoading?: boolean;
/** 추가 클래스 */
className?: string;
}
/**
* 캘린더 헤더 Props
*/
export interface CalendarHeaderProps {
currentDate: Date;
view: CalendarView;
onPrevMonth: () => void;
onNextMonth: () => void;
onViewChange: (view: CalendarView) => void;
filterSlot?: React.ReactNode;
}
/**
* 일자 셀 Props
*/
export interface DayCellProps {
date: Date;
isCurrentMonth: boolean;
isToday: boolean;
isSelected: boolean;
badge?: DayBadge;
events: ScheduleEvent[];
maxEvents: number;
onClick: (date: Date) => void;
onEventClick: (event: ScheduleEvent) => void;
}
/**
* 일정 바 Props
*/
export interface ScheduleBarProps {
event: ScheduleEvent;
/** 현재 주에서 시작하는지 */
isStart: boolean;
/** 현재 주에서 끝나는지 */
isEnd: boolean;
/** 차지하는 컬럼 수 (1-7) */
colSpan: number;
/** 시작 위치 (0-6) */
startCol: number;
/** 행 인덱스 (겹치는 이벤트 처리) */
rowIndex: number;
onClick: (event: ScheduleEvent) => void;
}
/**
* 더보기 팝오버 Props
*/
export interface MorePopoverProps {
date: Date;
events: ScheduleEvent[];
count: number;
onEventClick: (event: ScheduleEvent) => void;
}
/**
* 월간 뷰 Props
*/
export interface MonthViewProps {
currentDate: Date;
events: ScheduleEvent[];
badges: DayBadge[];
selectedDate: Date | null;
maxEventsPerDay: number;
weekStartsOn: 0 | 1;
onDateClick: (date: Date) => void;
onEventClick: (event: ScheduleEvent) => void;
}
/**
* 주간 뷰 Props
*/
export interface WeekViewProps {
currentDate: Date;
events: ScheduleEvent[];
badges: DayBadge[];
selectedDate: Date | null;
maxEventsPerDay: number;
weekStartsOn: 0 | 1;
onDateClick: (date: Date) => void;
onEventClick: (event: ScheduleEvent) => void;
}
/**
* 이벤트 색상 매핑
*/
export const EVENT_COLORS: Record<string, string> = {
gray: 'bg-gray-400 text-white',
blue: 'bg-blue-500 text-white',
yellow: 'bg-yellow-500 text-white',
green: 'bg-green-500 text-white',
red: 'bg-red-500 text-white',
};
/**
* 뱃지 색상 매핑
*/
export const BADGE_COLORS: Record<string, string> = {
red: 'bg-red-500 text-white',
blue: 'bg-blue-500 text-white',
yellow: 'bg-yellow-500 text-white',
green: 'bg-green-500 text-white',
gray: 'bg-gray-500 text-white',
};

View File

@@ -0,0 +1,292 @@
/**
* ScheduleCalendar 유틸리티 함수
*/
import {
startOfMonth,
endOfMonth,
startOfWeek,
endOfWeek,
eachDayOfInterval,
isSameMonth,
isSameDay,
isToday,
format,
addMonths,
subMonths,
parseISO,
isWithinInterval,
differenceInDays,
addDays,
getDay,
} from 'date-fns';
import { ko } from 'date-fns/locale';
import type { ScheduleEvent, DayBadge } from './types';
/**
* 월간 달력에 표시할 날짜 배열 생성
* (이전 달/다음 달 날짜 포함)
*/
export function getMonthDays(date: Date, weekStartsOn: 0 | 1 = 0): Date[] {
const monthStart = startOfMonth(date);
const monthEnd = endOfMonth(date);
const calendarStart = startOfWeek(monthStart, { weekStartsOn });
const calendarEnd = endOfWeek(monthEnd, { weekStartsOn });
return eachDayOfInterval({ start: calendarStart, end: calendarEnd });
}
/**
* 주간 달력에 표시할 날짜 배열 생성
*/
export function getWeekDays(date: Date, weekStartsOn: 0 | 1 = 0): Date[] {
const weekStart = startOfWeek(date, { weekStartsOn });
const weekEnd = endOfWeek(date, { weekStartsOn });
return eachDayOfInterval({ start: weekStart, end: weekEnd });
}
/**
* 요일 헤더 배열 생성
*/
export function getWeekdayHeaders(weekStartsOn: 0 | 1 = 0): string[] {
const weekdays = ['일', '월', '화', '수', '목', '금', '토'];
if (weekStartsOn === 1) {
return [...weekdays.slice(1), weekdays[0]];
}
return weekdays;
}
/**
* 날짜가 현재 월에 속하는지 확인
*/
export function isCurrentMonth(date: Date, currentDate: Date): boolean {
return isSameMonth(date, currentDate);
}
/**
* 날짜가 오늘인지 확인
*/
export function checkIsToday(date: Date): boolean {
return isToday(date);
}
/**
* 두 날짜가 같은지 확인
*/
export function isSameDate(date1: Date | null, date2: Date): boolean {
if (!date1) return false;
return isSameDay(date1, date2);
}
/**
* 날짜 포맷
*/
export function formatDate(date: Date, formatStr: string = 'yyyy-MM-dd'): string {
return format(date, formatStr, { locale: ko });
}
/**
* 년월 포맷 (예: "2025년 12월")
*/
export function formatYearMonth(date: Date): string {
return format(date, 'yyyy년 M월', { locale: ko });
}
/**
* 다음 달로 이동
*/
export function getNextMonth(date: Date): Date {
return addMonths(date, 1);
}
/**
* 이전 달로 이동
*/
export function getPrevMonth(date: Date): Date {
return subMonths(date, 1);
}
/**
* 특정 날짜에 해당하는 이벤트 필터링
*/
export function getEventsForDate(events: ScheduleEvent[], date: Date): ScheduleEvent[] {
const dateStr = format(date, 'yyyy-MM-dd');
return events.filter((event) => {
const startDate = parseISO(event.startDate);
const endDate = parseISO(event.endDate);
const targetDate = parseISO(dateStr);
return isWithinInterval(targetDate, { start: startDate, end: endDate });
});
}
/**
* 특정 날짜에 해당하는 뱃지 찾기
*/
export function getBadgeForDate(badges: DayBadge[], date: Date): DayBadge | undefined {
const dateStr = format(date, 'yyyy-MM-dd');
return badges.find((badge) => badge.date === dateStr);
}
/**
* 이벤트가 특정 날짜에 시작하는지 확인
*/
export function isEventStart(event: ScheduleEvent, date: Date): boolean {
return isSameDay(parseISO(event.startDate), date);
}
/**
* 이벤트가 특정 날짜에 끝나는지 확인
*/
export function isEventEnd(event: ScheduleEvent, date: Date): boolean {
return isSameDay(parseISO(event.endDate), date);
}
/**
* 이벤트 기간 (일수)
*/
export function getEventDuration(event: ScheduleEvent): number {
const start = parseISO(event.startDate);
const end = parseISO(event.endDate);
return differenceInDays(end, start) + 1;
}
/**
* 주 단위로 이벤트 분할 (여러 주에 걸치는 이벤트 처리)
*/
export interface WeekEventSegment {
event: ScheduleEvent;
startCol: number; // 0-6 (시작 컬럼)
colSpan: number; // 차지하는 컬럼 수
isStart: boolean; // 이벤트 시작 주인지
isEnd: boolean; // 이벤트 끝 주인지
}
export function getEventSegmentsForWeek(
events: ScheduleEvent[],
weekDays: Date[],
weekStartsOn: 0 | 1 = 0
): WeekEventSegment[] {
const weekStart = weekDays[0];
const weekEnd = weekDays[6];
const segments: WeekEventSegment[] = [];
events.forEach((event) => {
const eventStart = parseISO(event.startDate);
const eventEnd = parseISO(event.endDate);
// 이 주와 겹치는지 확인
if (eventEnd < weekStart || eventStart > weekEnd) {
return; // 이 주와 겹치지 않음
}
// 이 주에서의 시작/끝 날짜 계산
const segmentStart = eventStart < weekStart ? weekStart : eventStart;
const segmentEnd = eventEnd > weekEnd ? weekEnd : eventEnd;
// 컬럼 위치 계산
const startCol = getDay(segmentStart);
const adjustedStartCol = weekStartsOn === 1
? (startCol === 0 ? 6 : startCol - 1)
: startCol;
const colSpan = differenceInDays(segmentEnd, segmentStart) + 1;
segments.push({
event,
startCol: adjustedStartCol,
colSpan,
isStart: isSameDay(eventStart, segmentStart),
isEnd: isSameDay(eventEnd, segmentEnd),
});
});
return segments;
}
/**
* 이벤트 행 배치 계산 (다른 색상은 다른 행에 배치)
* - 같은 색상(작업반장)끼리만 같은 행 공유 가능
* - 다른 색상은 무조건 다른 행에 배치
*/
export function assignEventRows(segments: WeekEventSegment[]): Map<string, number> {
const rowMap = new Map<string, number>();
// 색상별로 그룹화
const colorGroups = new Map<string, WeekEventSegment[]>();
segments.forEach((segment) => {
const color = segment.event.color || 'blue';
if (!colorGroups.has(color)) {
colorGroups.set(color, []);
}
colorGroups.get(color)!.push(segment);
});
let currentBaseRow = 0;
// 각 색상 그룹별로 행 배치
colorGroups.forEach((groupSegments) => {
const occupied: boolean[][] = [];
// 시작 컬럼 순으로 정렬
const sortedSegments = [...groupSegments].sort((a, b) => {
if (a.startCol !== b.startCol) return a.startCol - b.startCol;
return b.colSpan - a.colSpan; // 긴 이벤트 먼저
});
let maxRowInGroup = 0;
sortedSegments.forEach((segment) => {
const { event, startCol, colSpan } = segment;
// 이 색상 그룹 내에서 가능한 가장 낮은 행 찾기
let row = 0;
while (true) {
if (!occupied[row]) {
occupied[row] = Array(7).fill(false);
}
// 이 행에서 해당 컬럼들이 비어있는지 확인
let canPlace = true;
for (let col = startCol; col < startCol + colSpan && col < 7; col++) {
if (occupied[row][col]) {
canPlace = false;
break;
}
}
if (canPlace) {
// 배치
for (let col = startCol; col < startCol + colSpan && col < 7; col++) {
occupied[row][col] = true;
}
rowMap.set(event.id, currentBaseRow + row);
maxRowInGroup = Math.max(maxRowInGroup, row);
break;
}
row++;
if (row > 10) break; // 최대 10행까지
}
});
// 다음 색상 그룹은 이 그룹 아래 행부터 시작
currentBaseRow += maxRowInGroup + 1;
});
return rowMap;
}
/**
* 월간 뷰에서 주 단위로 날짜 분할
*/
export function splitIntoWeeks(days: Date[]): Date[][] {
const weeks: Date[][] = [];
for (let i = 0; i < days.length; i += 7) {
weeks.push(days.slice(i, i + 7));
}
return weeks;
}