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:
유병철
2026-02-02 16:46:52 +09:00
parent 1a69324d59
commit ca6247286a
28 changed files with 4195 additions and 1776 deletions

View File

@@ -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

View 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>
);
}

View File

@@ -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}

View File

@@ -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
*/

View File

@@ -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 });
}
/**
* 월간 뷰에서 주 단위로 날짜 분할
*/