feat(WEB): 수입검사 관리 대폭 개선, 캘린더 DayTimeView 추가 및 출고 기능 보완
- 수입검사: InspectionCreate/Detail/List 대폭 개선, OrderSelectModal/문서 컴포넌트 신규 추가 - 수입검사: actions/types/mockData/inspectionConfig 전면 리팩토링 - QMS: InspectionModalV2/ImportInspectionDocument 개선 - 캘린더: DayTimeView 신규 추가, CalendarHeader/ScheduleCalendar/utils 확장 - 출고: ShipmentDetail/List/actions 개선, ShipmentOrderDocument/ShippingSlip 수정 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,7 @@ 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';
|
||||
import { formatYearMonth, formatYearMonthDay } from './utils';
|
||||
|
||||
/**
|
||||
* 달력 헤더 컴포넌트
|
||||
@@ -27,6 +27,11 @@ export function CalendarHeader({
|
||||
{ value: 'month', label: '월' },
|
||||
];
|
||||
|
||||
// 뷰에 따른 날짜 표시 형식
|
||||
const dateLabel = view === 'day-time'
|
||||
? formatYearMonthDay(currentDate)
|
||||
: formatYearMonth(currentDate);
|
||||
|
||||
// 뷰 전환 버튼 렌더링 (재사용)
|
||||
const renderViewTabs = (className?: string) => (
|
||||
<div className={cn('flex rounded-md border', className)}>
|
||||
@@ -71,7 +76,7 @@ export function CalendarHeader({
|
||||
</Button>
|
||||
|
||||
<span className="text-sm font-bold text-center whitespace-nowrap px-1">
|
||||
{formatYearMonth(currentDate)}
|
||||
{dateLabel}
|
||||
</span>
|
||||
|
||||
<Button
|
||||
@@ -111,8 +116,8 @@ export function CalendarHeader({
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<span className="text-lg font-bold min-w-[120px] text-center">
|
||||
{formatYearMonth(currentDate)}
|
||||
<span className="text-lg font-bold min-w-[120px] text-center whitespace-nowrap">
|
||||
{dateLabel}
|
||||
</span>
|
||||
|
||||
<Button
|
||||
|
||||
166
src/components/common/ScheduleCalendar/DayTimeView.tsx
Normal file
166
src/components/common/ScheduleCalendar/DayTimeView.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { cn } from '@/components/ui/utils';
|
||||
import { formatDate, checkIsToday } from './utils';
|
||||
import { EVENT_COLORS } from './types';
|
||||
import type { DayTimeViewProps, ScheduleEvent } from './types';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import { ko } from 'date-fns/locale';
|
||||
|
||||
/**
|
||||
* 일간 시간축 뷰 (day-time)
|
||||
*
|
||||
* 좌측에 시간 라벨, 오른쪽에 해당 날짜의 이벤트 블록 표시
|
||||
* startTime이 없는 이벤트는 all-day 영역에 표시
|
||||
*/
|
||||
export function DayTimeView({
|
||||
currentDate,
|
||||
events,
|
||||
timeRange = { start: 1, end: 12 },
|
||||
onDateClick,
|
||||
onEventClick,
|
||||
}: DayTimeViewProps) {
|
||||
const today = checkIsToday(currentDate);
|
||||
const dayStr = formatDate(currentDate, 'yyyy-MM-dd');
|
||||
const weekdayLabel = format(currentDate, 'EEEE', { locale: ko });
|
||||
|
||||
// 시간 슬롯 생성
|
||||
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 { allDayEvents, timedEvents } = useMemo(() => {
|
||||
const allDay: ScheduleEvent[] = [];
|
||||
const timed: ScheduleEvent[] = [];
|
||||
|
||||
events.forEach((event) => {
|
||||
const eventStart = parseISO(event.startDate);
|
||||
const eventEnd = parseISO(event.endDate);
|
||||
const current = parseISO(dayStr);
|
||||
|
||||
if (current >= eventStart && current <= eventEnd) {
|
||||
if (event.startTime) {
|
||||
timed.push(event);
|
||||
} else {
|
||||
allDay.push(event);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { allDayEvents: allDay, timedEvents: timed };
|
||||
}, [events, dayStr]);
|
||||
|
||||
// 시간 문자열에서 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-[300px]">
|
||||
{/* 헤더: 시간라벨컬럼 + 날짜 */}
|
||||
<div className="grid grid-cols-[60px_1fr] border-b">
|
||||
<div className="border-r bg-muted/30" />
|
||||
<div
|
||||
className={cn(
|
||||
'text-center py-2 cursor-pointer transition-colors',
|
||||
today && 'bg-primary/5',
|
||||
)}
|
||||
onClick={() => onDateClick(currentDate)}
|
||||
>
|
||||
<div className={cn(
|
||||
'text-xs text-muted-foreground',
|
||||
today && 'text-primary font-semibold',
|
||||
)}>
|
||||
{weekdayLabel}
|
||||
</div>
|
||||
<div className={cn(
|
||||
'text-sm font-medium',
|
||||
today && 'text-primary',
|
||||
)}>
|
||||
{format(currentDate, 'd')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* All-day 영역 */}
|
||||
{allDayEvents.length > 0 && (
|
||||
<div className="grid grid-cols-[60px_1fr] border-b">
|
||||
<div className="border-r bg-muted/30 flex items-center justify-center">
|
||||
<span className="text-[10px] text-muted-foreground">종일</span>
|
||||
</div>
|
||||
<div className="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) => {
|
||||
const slotEvents = timedEvents.filter((event) => {
|
||||
if (!event.startTime) return false;
|
||||
return getHourFromTime(event.startTime) === slot.hour;
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
key={slot.hour}
|
||||
className="grid grid-cols-[60px_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>
|
||||
{/* 이벤트 셀 */}
|
||||
<div className={cn(
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -3,11 +3,12 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { cn } from '@/components/ui/utils';
|
||||
import { CalendarHeader } from './CalendarHeader';
|
||||
import { DayTimeView } from './DayTimeView';
|
||||
import { MonthView } from './MonthView';
|
||||
import { WeekView } from './WeekView';
|
||||
import { WeekTimeView } from './WeekTimeView';
|
||||
import type { ScheduleCalendarProps, CalendarView } from './types';
|
||||
import { getNextMonth, getPrevMonth } from './utils';
|
||||
import { getNextMonth, getPrevMonth, getNextDay, getPrevDay, getNextWeek, getPrevWeek } from './utils';
|
||||
|
||||
/**
|
||||
* 스케줄 달력 공통 컴포넌트
|
||||
@@ -64,23 +65,37 @@ export function ScheduleCalendar({
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// 이전 달
|
||||
// 이전 (뷰에 따라 일/주/월 단위)
|
||||
const handlePrevMonth = useCallback(() => {
|
||||
const newDate = getPrevMonth(currentDate);
|
||||
let newDate: Date;
|
||||
if (view === 'day-time') {
|
||||
newDate = getPrevDay(currentDate);
|
||||
} else if (view === 'week-time' || view === 'week') {
|
||||
newDate = getPrevWeek(currentDate);
|
||||
} else {
|
||||
newDate = getPrevMonth(currentDate);
|
||||
}
|
||||
if (controlledDate === undefined) {
|
||||
setInternalDate(newDate);
|
||||
}
|
||||
onMonthChange?.(newDate);
|
||||
}, [currentDate, controlledDate, onMonthChange]);
|
||||
}, [currentDate, view, controlledDate, onMonthChange]);
|
||||
|
||||
// 다음 달
|
||||
// 다음 (뷰에 따라 일/주/월 단위)
|
||||
const handleNextMonth = useCallback(() => {
|
||||
const newDate = getNextMonth(currentDate);
|
||||
let newDate: Date;
|
||||
if (view === 'day-time') {
|
||||
newDate = getNextDay(currentDate);
|
||||
} else if (view === 'week-time' || view === 'week') {
|
||||
newDate = getNextWeek(currentDate);
|
||||
} else {
|
||||
newDate = getNextMonth(currentDate);
|
||||
}
|
||||
if (controlledDate === undefined) {
|
||||
setInternalDate(newDate);
|
||||
}
|
||||
onMonthChange?.(newDate);
|
||||
}, [currentDate, controlledDate, onMonthChange]);
|
||||
}, [currentDate, view, controlledDate, onMonthChange]);
|
||||
|
||||
// 뷰 변경
|
||||
const handleViewChange = useCallback((newView: CalendarView) => {
|
||||
@@ -130,6 +145,15 @@ 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 === 'day-time' ? (
|
||||
<DayTimeView
|
||||
currentDate={currentDate}
|
||||
events={events}
|
||||
selectedDate={selectedDate}
|
||||
timeRange={timeRange}
|
||||
onDateClick={handleDateClick}
|
||||
onEventClick={handleEventClick}
|
||||
/>
|
||||
) : view === 'week-time' ? (
|
||||
<WeekTimeView
|
||||
currentDate={currentDate}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
/**
|
||||
* 달력 뷰 모드
|
||||
*/
|
||||
export type CalendarView = 'week' | 'month' | 'week-time';
|
||||
export type CalendarView = 'day-time' | 'week' | 'month' | 'week-time';
|
||||
|
||||
/**
|
||||
* 일정 이벤트
|
||||
@@ -170,6 +170,19 @@ export interface WeekTimeViewProps {
|
||||
onEventClick: (event: ScheduleEvent) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 일간 시간축 뷰 Props (day-time)
|
||||
*/
|
||||
export interface DayTimeViewProps {
|
||||
currentDate: Date;
|
||||
events: ScheduleEvent[];
|
||||
selectedDate: Date | null;
|
||||
/** 표시할 시간 범위 (기본: 1~12) */
|
||||
timeRange?: { start: number; end: number };
|
||||
onDateClick: (date: Date) => void;
|
||||
onEventClick: (event: ScheduleEvent) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 주간 뷰 Props
|
||||
*/
|
||||
|
||||
@@ -345,6 +345,41 @@ export function assignGlobalEventRows(events: ScheduleEvent[]): Map<string, numb
|
||||
return rowMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* 다음 날로 이동
|
||||
*/
|
||||
export function getNextDay(date: Date): Date {
|
||||
return addDays(date, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 이전 날로 이동
|
||||
*/
|
||||
export function getPrevDay(date: Date): Date {
|
||||
return addDays(date, -1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 다음 주로 이동
|
||||
*/
|
||||
export function getNextWeek(date: Date): Date {
|
||||
return addDays(date, 7);
|
||||
}
|
||||
|
||||
/**
|
||||
* 이전 주로 이동
|
||||
*/
|
||||
export function getPrevWeek(date: Date): Date {
|
||||
return addDays(date, -7);
|
||||
}
|
||||
|
||||
/**
|
||||
* 년월일 포맷 (예: "2026년 2월 2일 (월)")
|
||||
*/
|
||||
export function formatYearMonthDay(date: Date): string {
|
||||
return format(date, 'yyyy년 M월 d일 (EEE)', { locale: ko });
|
||||
}
|
||||
|
||||
/**
|
||||
* 월간 뷰에서 주 단위로 날짜 분할
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user