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:
81
src/components/common/ScheduleCalendar/CalendarHeader.tsx
Normal file
81
src/components/common/ScheduleCalendar/CalendarHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
73
src/components/common/ScheduleCalendar/DayCell.tsx
Normal file
73
src/components/common/ScheduleCalendar/DayCell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
236
src/components/common/ScheduleCalendar/MonthView.tsx
Normal file
236
src/components/common/ScheduleCalendar/MonthView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
45
src/components/common/ScheduleCalendar/MorePopover.tsx
Normal file
45
src/components/common/ScheduleCalendar/MorePopover.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
73
src/components/common/ScheduleCalendar/ScheduleBar.tsx
Normal file
73
src/components/common/ScheduleCalendar/ScheduleBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
153
src/components/common/ScheduleCalendar/ScheduleCalendar.tsx
Normal file
153
src/components/common/ScheduleCalendar/ScheduleCalendar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
200
src/components/common/ScheduleCalendar/WeekView.tsx
Normal file
200
src/components/common/ScheduleCalendar/WeekView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
src/components/common/ScheduleCalendar/index.ts
Normal file
30
src/components/common/ScheduleCalendar/index.ts
Normal 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';
|
||||
179
src/components/common/ScheduleCalendar/types.ts
Normal file
179
src/components/common/ScheduleCalendar/types.ts
Normal 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',
|
||||
};
|
||||
292
src/components/common/ScheduleCalendar/utils.ts
Normal file
292
src/components/common/ScheduleCalendar/utils.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user