feat(WEB): 출고관리 대폭 개선, 차량배차관리 신규 추가 및 QMS/캘린더 기능 강화
- 출고관리: ShipmentCreate/Detail/Edit/List 개선, ShipmentOrderDocument 신규 추가 - 차량배차관리: VehicleDispatchManagement 모듈 신규 추가 - QMS: InspectionModalV2 개선 - 캘린더: WeekTimeView 신규 추가, CalendarHeader/types 확장 - 문서: ConstructionApprovalTable/SalesOrderDocument/DeliveryConfirmation/ShippingSlip 개선 - 작업지시서: 검사보고서/작업일지 문서 개선 - 템플릿: IntegratedListTemplateV2/UniversalListPage 기능 확장 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -20,8 +20,9 @@ export function CalendarHeader({
|
||||
onViewChange,
|
||||
titleSlot,
|
||||
filterSlot,
|
||||
availableViews,
|
||||
}: CalendarHeaderProps) {
|
||||
const views: { value: CalendarView; label: string }[] = [
|
||||
const views: { value: CalendarView; label: string }[] = availableViews || [
|
||||
{ value: 'week', label: '주' },
|
||||
{ value: 'month', label: '월' },
|
||||
];
|
||||
@@ -84,9 +85,11 @@ export function CalendarHeader({
|
||||
</div>
|
||||
|
||||
{/* 2줄: 뷰 전환 탭 */}
|
||||
<div className="flex justify-center">
|
||||
{renderViewTabs()}
|
||||
</div>
|
||||
{views.length > 1 && (
|
||||
<div className="flex justify-center">
|
||||
{renderViewTabs()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* PC: 타이틀 + 네비게이션 | 뷰전환 + 필터 (한 줄) */}
|
||||
@@ -125,7 +128,7 @@ export function CalendarHeader({
|
||||
|
||||
{/* 우측: 뷰 전환 + 필터 */}
|
||||
<div className="flex items-center gap-3">
|
||||
{renderViewTabs('px-4 py-1.5')}
|
||||
{views.length > 1 && renderViewTabs('px-4 py-1.5')}
|
||||
{filterSlot && <div className="flex items-center gap-2">{filterSlot}</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { cn } from '@/components/ui/utils';
|
||||
import { CalendarHeader } from './CalendarHeader';
|
||||
import { MonthView } from './MonthView';
|
||||
import { WeekView } from './WeekView';
|
||||
import { WeekTimeView } from './WeekTimeView';
|
||||
import type { ScheduleCalendarProps, CalendarView } from './types';
|
||||
import { getNextMonth, getPrevMonth } from './utils';
|
||||
|
||||
@@ -44,6 +45,8 @@ export function ScheduleCalendar({
|
||||
weekStartsOn = 0,
|
||||
isLoading = false,
|
||||
className,
|
||||
availableViews,
|
||||
timeRange,
|
||||
}: ScheduleCalendarProps) {
|
||||
// 내부 상태 (controlled/uncontrolled 지원)
|
||||
const [internalDate, setInternalDate] = useState(() => new Date());
|
||||
@@ -118,6 +121,7 @@ export function ScheduleCalendar({
|
||||
onViewChange={handleViewChange}
|
||||
titleSlot={titleSlot}
|
||||
filterSlot={filterSlot}
|
||||
availableViews={availableViews}
|
||||
/>
|
||||
|
||||
{/* 본문 */}
|
||||
@@ -126,6 +130,16 @@ export function ScheduleCalendar({
|
||||
<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 === 'week-time' ? (
|
||||
<WeekTimeView
|
||||
currentDate={currentDate}
|
||||
events={events}
|
||||
selectedDate={selectedDate}
|
||||
weekStartsOn={weekStartsOn}
|
||||
timeRange={timeRange}
|
||||
onDateClick={handleDateClick}
|
||||
onEventClick={handleEventClick}
|
||||
/>
|
||||
) : view === 'month' ? (
|
||||
<MonthView
|
||||
currentDate={currentDate}
|
||||
|
||||
216
src/components/common/ScheduleCalendar/WeekTimeView.tsx
Normal file
216
src/components/common/ScheduleCalendar/WeekTimeView.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { cn } from '@/components/ui/utils';
|
||||
import { getWeekDays, getWeekdayHeaders, formatDate, checkIsToday, isSameDate } from './utils';
|
||||
import { EVENT_COLORS } from './types';
|
||||
import type { WeekTimeViewProps, ScheduleEvent } from './types';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
|
||||
/**
|
||||
* 시간축 주간 뷰 (week-time)
|
||||
*
|
||||
* 좌측에 시간 라벨, 상단에 요일+날짜, 시간 슬롯에 이벤트 블록 표시
|
||||
* startTime이 없는 이벤트는 all-day 영역에 표시
|
||||
*/
|
||||
export function WeekTimeView({
|
||||
currentDate,
|
||||
events,
|
||||
selectedDate,
|
||||
weekStartsOn,
|
||||
timeRange = { start: 1, end: 12 },
|
||||
onDateClick,
|
||||
onEventClick,
|
||||
}: WeekTimeViewProps) {
|
||||
const weekDays = useMemo(() => getWeekDays(currentDate, weekStartsOn), [currentDate, weekStartsOn]);
|
||||
const weekdayHeaders = useMemo(() => getWeekdayHeaders(weekStartsOn), [weekStartsOn]);
|
||||
|
||||
// 시간 슬롯 생성 (AM 시간대)
|
||||
const timeSlots = useMemo(() => {
|
||||
const slots: { hour: number; label: string }[] = [];
|
||||
for (let h = timeRange.start; h <= timeRange.end; h++) {
|
||||
slots.push({
|
||||
hour: h,
|
||||
label: `AM ${h}시`,
|
||||
});
|
||||
}
|
||||
return slots;
|
||||
}, [timeRange]);
|
||||
|
||||
// 이벤트를 날짜별로 분류
|
||||
const eventsByDate = useMemo(() => {
|
||||
const map = new Map<string, { allDay: ScheduleEvent[]; timed: ScheduleEvent[] }>();
|
||||
|
||||
weekDays.forEach((day) => {
|
||||
const dateStr = formatDate(day, 'yyyy-MM-dd');
|
||||
map.set(dateStr, { allDay: [], timed: [] });
|
||||
});
|
||||
|
||||
events.forEach((event) => {
|
||||
const eventStartDate = parseISO(event.startDate);
|
||||
const eventEndDate = parseISO(event.endDate);
|
||||
|
||||
weekDays.forEach((day) => {
|
||||
const dateStr = formatDate(day, 'yyyy-MM-dd');
|
||||
// 이벤트가 이 날짜를 포함하는지 확인
|
||||
if (day >= eventStartDate && day <= eventEndDate) {
|
||||
const bucket = map.get(dateStr);
|
||||
if (bucket) {
|
||||
if (event.startTime) {
|
||||
bucket.timed.push(event);
|
||||
} else {
|
||||
bucket.allDay.push(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return map;
|
||||
}, [events, weekDays]);
|
||||
|
||||
// all-day 이벤트가 있는지 확인
|
||||
const hasAllDayEvents = useMemo(() => {
|
||||
for (const [, bucket] of eventsByDate) {
|
||||
if (bucket.allDay.length > 0) return true;
|
||||
}
|
||||
return false;
|
||||
}, [eventsByDate]);
|
||||
|
||||
// 시간 문자열에서 hour 추출
|
||||
const getHourFromTime = (time: string): number => {
|
||||
const [hours] = time.split(':').map(Number);
|
||||
return hours;
|
||||
};
|
||||
|
||||
// 이벤트 색상 클래스
|
||||
const getEventColorClasses = (event: ScheduleEvent): string => {
|
||||
const color = event.color || 'blue';
|
||||
return EVENT_COLORS[color] || EVENT_COLORS.blue;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<div className="min-w-[700px]">
|
||||
{/* 헤더: 시간라벨컬럼 + 요일/날짜 */}
|
||||
<div className="grid grid-cols-[60px_repeat(7,1fr)] border-b">
|
||||
{/* 빈 코너셀 */}
|
||||
<div className="border-r bg-muted/30" />
|
||||
{/* 요일 + 날짜 */}
|
||||
{weekDays.map((day, i) => {
|
||||
const today = checkIsToday(day);
|
||||
const selected = isSameDate(selectedDate, day);
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
'text-center py-2 border-r last:border-r-0 cursor-pointer transition-colors',
|
||||
today && 'bg-primary/5',
|
||||
selected && 'bg-primary/10',
|
||||
)}
|
||||
onClick={() => onDateClick(day)}
|
||||
>
|
||||
<div className={cn(
|
||||
'text-xs text-muted-foreground',
|
||||
today && 'text-primary font-semibold',
|
||||
)}>
|
||||
{weekdayHeaders[i]}
|
||||
</div>
|
||||
<div className={cn(
|
||||
'text-sm font-medium',
|
||||
today && 'text-primary',
|
||||
)}>
|
||||
{format(day, 'd')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* All-day 영역 */}
|
||||
{hasAllDayEvents && (
|
||||
<div className="grid grid-cols-[60px_repeat(7,1fr)] border-b">
|
||||
<div className="border-r bg-muted/30 flex items-center justify-center">
|
||||
<span className="text-[10px] text-muted-foreground">종일</span>
|
||||
</div>
|
||||
{weekDays.map((day, i) => {
|
||||
const dateStr = formatDate(day, 'yyyy-MM-dd');
|
||||
const allDayEvents = eventsByDate.get(dateStr)?.allDay || [];
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="border-r last:border-r-0 p-0.5 min-h-[28px]"
|
||||
>
|
||||
{allDayEvents.map((event) => (
|
||||
<div
|
||||
key={event.id}
|
||||
className={cn(
|
||||
'text-[10px] px-1 py-0.5 rounded truncate cursor-pointer hover:opacity-80 mb-0.5',
|
||||
getEventColorClasses(event),
|
||||
)}
|
||||
title={event.title}
|
||||
onClick={() => onEventClick(event)}
|
||||
>
|
||||
{event.title}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 시간 그리드 */}
|
||||
{timeSlots.map((slot) => (
|
||||
<div
|
||||
key={slot.hour}
|
||||
className="grid grid-cols-[60px_repeat(7,1fr)] border-b last:border-b-0"
|
||||
>
|
||||
{/* 시간 라벨 */}
|
||||
<div className="border-r bg-muted/30 flex items-start justify-center pt-1">
|
||||
<span className="text-[10px] text-muted-foreground whitespace-nowrap">
|
||||
{slot.label}
|
||||
</span>
|
||||
</div>
|
||||
{/* 요일별 셀 */}
|
||||
{weekDays.map((day, i) => {
|
||||
const dateStr = formatDate(day, 'yyyy-MM-dd');
|
||||
const timedEvents = eventsByDate.get(dateStr)?.timed || [];
|
||||
const slotEvents = timedEvents.filter((event) => {
|
||||
if (!event.startTime) return false;
|
||||
const eventHour = getHourFromTime(event.startTime);
|
||||
return eventHour === slot.hour;
|
||||
});
|
||||
|
||||
const today = checkIsToday(day);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
'border-r last:border-r-0 p-0.5 min-h-[40px] relative',
|
||||
today && 'bg-primary/[0.02]',
|
||||
)}
|
||||
>
|
||||
{slotEvents.map((event) => (
|
||||
<div
|
||||
key={event.id}
|
||||
className={cn(
|
||||
'text-[10px] px-1.5 py-1 rounded cursor-pointer hover:opacity-80 mb-0.5 leading-tight',
|
||||
getEventColorClasses(event),
|
||||
)}
|
||||
title={event.title}
|
||||
onClick={() => onEventClick(event)}
|
||||
>
|
||||
<span className="line-clamp-2">{event.title}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ export { ScheduleCalendar } from './ScheduleCalendar';
|
||||
export { CalendarHeader } from './CalendarHeader';
|
||||
export { MonthView } from './MonthView';
|
||||
export { WeekView } from './WeekView';
|
||||
export { WeekTimeView } from './WeekTimeView';
|
||||
export { DayCell } from './DayCell';
|
||||
export { ScheduleBar } from './ScheduleBar';
|
||||
export { MorePopover } from './MorePopover';
|
||||
@@ -23,6 +24,7 @@ export type {
|
||||
MorePopoverProps,
|
||||
MonthViewProps,
|
||||
WeekViewProps,
|
||||
WeekTimeViewProps,
|
||||
} from './types';
|
||||
|
||||
export { EVENT_COLORS, BADGE_COLORS } from './types';
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
/**
|
||||
* 달력 뷰 모드
|
||||
*/
|
||||
export type CalendarView = 'week' | 'month';
|
||||
export type CalendarView = 'week' | 'month' | 'week-time';
|
||||
|
||||
/**
|
||||
* 일정 이벤트
|
||||
@@ -23,6 +23,10 @@ export interface ScheduleEvent {
|
||||
color?: string;
|
||||
/** 이벤트 상태 */
|
||||
status?: string;
|
||||
/** 시작 시간 (HH:mm 형식, week-time 뷰용) */
|
||||
startTime?: string;
|
||||
/** 종료 시간 (HH:mm 형식, week-time 뷰용) */
|
||||
endTime?: string;
|
||||
/** 추가 데이터 */
|
||||
data?: unknown;
|
||||
}
|
||||
@@ -73,6 +77,10 @@ export interface ScheduleCalendarProps {
|
||||
isLoading?: boolean;
|
||||
/** 추가 클래스 */
|
||||
className?: string;
|
||||
/** 사용 가능한 뷰 목록 (기본: 주/월) */
|
||||
availableViews?: { value: CalendarView; label: string }[];
|
||||
/** 시간축 뷰 시간 범위 (기본: 1~12) */
|
||||
timeRange?: { start: number; end: number };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -87,6 +95,8 @@ export interface CalendarHeaderProps {
|
||||
/** 타이틀 영역 (년월 네비게이션 왼쪽) */
|
||||
titleSlot?: React.ReactNode;
|
||||
filterSlot?: React.ReactNode;
|
||||
/** 사용 가능한 뷰 목록 (기본: [{value:'week',label:'주'},{value:'month',label:'월'}]) */
|
||||
availableViews?: { value: CalendarView; label: string }[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -146,6 +156,20 @@ export interface MonthViewProps {
|
||||
onEventClick: (event: ScheduleEvent) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 시간축 주간 뷰 Props (week-time)
|
||||
*/
|
||||
export interface WeekTimeViewProps {
|
||||
currentDate: Date;
|
||||
events: ScheduleEvent[];
|
||||
selectedDate: Date | null;
|
||||
weekStartsOn: 0 | 1;
|
||||
/** 표시할 시간 범위 (기본: 1~12) */
|
||||
timeRange?: { start: number; end: number };
|
||||
onDateClick: (date: Date) => void;
|
||||
onEventClick: (event: ScheduleEvent) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 주간 뷰 Props
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user