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:
유병철
2026-02-02 11:14:05 +09:00
parent e684c495ee
commit 1a69324d59
41 changed files with 4134 additions and 1440 deletions

View File

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

View File

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

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

View File

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

View File

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