자재관리: - 입고관리 재고조정 다이얼로그 신규 추가, 상세/목록 기능 확장 - 재고현황 컴포넌트 리팩토링 출고관리: - 출하관리 생성/수정/목록/상세 개선 - 차량배차관리 상세/수정/목록 기능 보강 생산관리: - 작업지시서 WIP 생산 모달 신규 추가 - 벤딩WIP/슬랫조인트바 검사 콘텐츠 신규 추가 - 작업자화면 기능 대폭 확장 (카드/목록 개선) - 검사성적서 모달 개선 품질관리: - 실적보고서 관리 페이지 신규 추가 - 검사관리 문서/타입/목데이터 개선 단가관리: - 단가배포 페이지 및 컴포넌트 신규 추가 - 단가표 관리 페이지 및 컴포넌트 신규 추가 공통: - 권한 시스템 추가 개선 (PermissionContext, usePermission, PermissionGuard) - 메뉴 폴링 훅 개선, 레이아웃 수정 - 모바일 줌/패닝 CSS 수정 - locale 유틸 추가 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
417 lines
15 KiB
TypeScript
417 lines
15 KiB
TypeScript
'use client';
|
|
|
|
/**
|
|
* 출고 목록 - 리뉴얼 버전
|
|
*
|
|
* 변경 사항:
|
|
* - 제목: 출고 목록 → 출고 목록
|
|
* - DateRangeSelector 추가
|
|
* - 통계 카드: 3개 (당일 출고대기, 출고대기, 출고완료)
|
|
* - 탭 삭제 → 배송방식 필터로 대체
|
|
* - 테이블 컬럼: 19개
|
|
* - 하단 출고 스케줄 캘린더 (시간축 주간 뷰)
|
|
*/
|
|
|
|
import { useState, useEffect, useMemo, useCallback } from 'react';
|
|
import { useRouter } from 'next/navigation';
|
|
import {
|
|
Truck,
|
|
Package,
|
|
Clock,
|
|
CheckCircle2,
|
|
Eye,
|
|
Plus,
|
|
} from 'lucide-react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { TableCell, TableRow } from '@/components/ui/table';
|
|
import { Checkbox } from '@/components/ui/checkbox';
|
|
import {
|
|
UniversalListPage,
|
|
type UniversalListConfig,
|
|
type SelectionHandlers,
|
|
type RowClickHandlers,
|
|
type StatCard,
|
|
type ListParams,
|
|
} from '@/components/templates/UniversalListPage';
|
|
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
|
|
import { ScheduleCalendar } from '@/components/common/ScheduleCalendar/ScheduleCalendar';
|
|
import type { ScheduleEvent, CalendarView } from '@/components/common/ScheduleCalendar/types';
|
|
import { getShipments, getShipmentStats } from './actions';
|
|
import {
|
|
SHIPMENT_STATUS_LABELS,
|
|
SHIPMENT_STATUS_STYLES,
|
|
DELIVERY_METHOD_LABELS,
|
|
} from './types';
|
|
import type { ShipmentItem, ShipmentStatus, ShipmentStats } from './types';
|
|
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
|
|
|
// 페이지당 항목 수
|
|
const ITEMS_PER_PAGE = 20;
|
|
|
|
export function ShipmentList() {
|
|
const router = useRouter();
|
|
|
|
// ===== 통계 (외부 관리) =====
|
|
const [shipmentStats, setShipmentStats] = useState<ShipmentStats | null>(null);
|
|
|
|
// ===== 날짜 범위 =====
|
|
const today = new Date();
|
|
const [startDate, setStartDate] = useState(() => {
|
|
const d = new Date(today.getFullYear(), today.getMonth(), 1);
|
|
return d.toISOString().split('T')[0];
|
|
});
|
|
const [endDate, setEndDate] = useState(() => {
|
|
const d = new Date(today.getFullYear(), today.getMonth() + 1, 0);
|
|
return d.toISOString().split('T')[0];
|
|
});
|
|
|
|
// ===== 캘린더 상태 =====
|
|
const [calendarDate, setCalendarDate] = useState(new Date());
|
|
const [scheduleView, setScheduleView] = useState<CalendarView>('day-time');
|
|
const [shipmentData, setShipmentData] = useState<ShipmentItem[]>([]);
|
|
|
|
// 초기 통계 로드
|
|
useEffect(() => {
|
|
const loadStats = async () => {
|
|
try {
|
|
const statsResult = await getShipmentStats();
|
|
if (statsResult.success && statsResult.data) {
|
|
setShipmentStats(statsResult.data);
|
|
}
|
|
} catch (error) {
|
|
if (isNextRedirectError(error)) throw error;
|
|
console.error('[ShipmentList] loadStats error:', error);
|
|
}
|
|
};
|
|
loadStats();
|
|
}, []);
|
|
|
|
// ===== 행 클릭 핸들러 =====
|
|
const handleRowClick = useCallback(
|
|
(item: ShipmentItem) => {
|
|
router.push(`/ko/outbound/shipments/${item.id}?mode=view`);
|
|
},
|
|
[router]
|
|
);
|
|
|
|
// ===== 등록 핸들러 =====
|
|
const handleCreate = useCallback(() => {
|
|
router.push('/ko/outbound/shipments?mode=new');
|
|
}, [router]);
|
|
|
|
// ===== 통계 카드 (3개: 당일 출고대기, 출고대기, 출고완료) =====
|
|
const stats: StatCard[] = useMemo(
|
|
() => [
|
|
{
|
|
label: '당일 출고대기',
|
|
value: `${shipmentStats?.todayPendingCount || shipmentStats?.todayShipmentCount || 0}건`,
|
|
icon: Package,
|
|
iconColor: 'text-orange-600',
|
|
},
|
|
{
|
|
label: '출고대기',
|
|
value: `${shipmentStats?.pendingCount || shipmentStats?.scheduledCount || 0}건`,
|
|
icon: Clock,
|
|
iconColor: 'text-yellow-600',
|
|
},
|
|
{
|
|
label: '출고완료',
|
|
value: `${shipmentStats?.completedCount || 0}건`,
|
|
icon: CheckCircle2,
|
|
iconColor: 'text-green-600',
|
|
},
|
|
],
|
|
[shipmentStats]
|
|
);
|
|
|
|
// ===== 캘린더 이벤트 변환 =====
|
|
const scheduleEvents: ScheduleEvent[] = useMemo(() => {
|
|
return shipmentData.map((item) => {
|
|
const deliveryLabel = item.deliveryMethodLabel || DELIVERY_METHOD_LABELS[item.deliveryMethod] || '';
|
|
const statusLabel = SHIPMENT_STATUS_LABELS[item.status] || '';
|
|
const title = `${deliveryLabel} ${item.siteName} / ${statusLabel}`;
|
|
|
|
// 색상 매핑: 상태별
|
|
const colorMap: Record<ShipmentStatus, string> = {
|
|
scheduled: 'gray',
|
|
ready: 'yellow',
|
|
shipping: 'blue',
|
|
completed: 'green',
|
|
};
|
|
|
|
return {
|
|
id: item.id,
|
|
title,
|
|
startDate: item.scheduledDate,
|
|
endDate: item.shipmentDate || item.scheduledDate,
|
|
startTime: item.shipmentTime,
|
|
color: colorMap[item.status] || 'blue',
|
|
status: item.status,
|
|
data: item,
|
|
};
|
|
});
|
|
}, [shipmentData]);
|
|
|
|
// ===== 캘린더 이벤트 클릭 =====
|
|
const handleCalendarEventClick = useCallback((event: ScheduleEvent) => {
|
|
if (event.data) {
|
|
const item = event.data as ShipmentItem;
|
|
router.push(`/ko/outbound/shipments/${item.id}?mode=view`);
|
|
}
|
|
}, [router]);
|
|
|
|
// ===== 캘린더 날짜 클릭 =====
|
|
const handleCalendarDateClick = useCallback((date: Date) => {
|
|
setCalendarDate(date);
|
|
}, []);
|
|
|
|
// ===== UniversalListPage Config =====
|
|
const config: UniversalListConfig<ShipmentItem> = useMemo(
|
|
() => ({
|
|
// 페이지 기본 정보
|
|
title: '출고 목록',
|
|
description: '출고 관리',
|
|
icon: Truck,
|
|
basePath: '/outbound/shipments',
|
|
|
|
// ID 추출
|
|
idField: 'id',
|
|
|
|
// API 액션 (서버 사이드 페이지네이션)
|
|
actions: {
|
|
getList: async (params?: ListParams) => {
|
|
try {
|
|
const result = await getShipments({
|
|
page: params?.page || 1,
|
|
perPage: params?.pageSize || ITEMS_PER_PAGE,
|
|
status: undefined, // 탭 삭제 - 필터로 대체
|
|
search: params?.search || undefined,
|
|
});
|
|
|
|
if (result.success) {
|
|
// 통계 다시 로드
|
|
const statsResult = await getShipmentStats();
|
|
if (statsResult.success && statsResult.data) {
|
|
setShipmentStats(statsResult.data);
|
|
}
|
|
|
|
// 캘린더용 데이터 저장
|
|
setShipmentData(result.data);
|
|
|
|
return {
|
|
success: true,
|
|
data: result.data,
|
|
totalCount: result.pagination.total,
|
|
totalPages: result.pagination.lastPage,
|
|
};
|
|
}
|
|
return { success: false, error: result.error };
|
|
} catch (error) {
|
|
if (isNextRedirectError(error)) throw error;
|
|
return { success: false, error: '데이터 로드 중 오류가 발생했습니다.' };
|
|
}
|
|
},
|
|
},
|
|
|
|
// 날짜 범위 선택기
|
|
dateRangeSelector: {
|
|
enabled: true,
|
|
showPresets: true,
|
|
startDate,
|
|
endDate,
|
|
onStartDateChange: setStartDate,
|
|
onEndDateChange: setEndDate,
|
|
},
|
|
|
|
// 등록 버튼
|
|
createButton: {
|
|
label: '출고 등록',
|
|
onClick: handleCreate,
|
|
icon: Plus,
|
|
},
|
|
|
|
// 테이블 컬럼 (11개)
|
|
columns: [
|
|
{ key: 'no', label: '번호', className: 'w-[50px] text-center' },
|
|
{ key: 'lotNo', label: '로트번호', className: 'min-w-[120px]' },
|
|
{ key: 'siteName', label: '현장명', className: 'min-w-[100px]' },
|
|
{ key: 'orderCustomer', label: '수주처', className: 'min-w-[100px]' },
|
|
{ key: 'receiver', label: '수신자', className: 'w-[80px] text-center' },
|
|
{ key: 'receiverAddress', label: '수신주소', className: 'min-w-[140px]' },
|
|
{ key: 'receiverCompany', label: '수신처', className: 'min-w-[100px]' },
|
|
{ key: 'status', label: '상태', className: 'w-[80px] text-center' },
|
|
{ key: 'dispatch', label: '배차', className: 'w-[80px] text-center' },
|
|
{ key: 'writer', label: '작성자', className: 'w-[80px] text-center' },
|
|
{ key: 'shipmentDate', label: '출고일', className: 'w-[100px] text-center' },
|
|
],
|
|
|
|
// 배송방식 필터
|
|
filterConfig: [{
|
|
key: 'deliveryMethod',
|
|
label: '배송방식',
|
|
type: 'single' as const,
|
|
options: [
|
|
{ value: 'direct_dispatch', label: '직접배차' },
|
|
{ value: 'loading', label: '상차' },
|
|
{ value: 'kyungdong_delivery', label: '경동택배' },
|
|
{ value: 'daesin_delivery', label: '대신택배' },
|
|
{ value: 'kyungdong_freight', label: '경동화물' },
|
|
{ value: 'daesin_freight', label: '대신화물' },
|
|
{ value: 'self_pickup', label: '직접수령' },
|
|
],
|
|
allOptionLabel: '전체',
|
|
}],
|
|
|
|
// 서버 사이드 페이지네이션
|
|
clientSideFiltering: false,
|
|
itemsPerPage: ITEMS_PER_PAGE,
|
|
|
|
// 검색
|
|
searchPlaceholder: '출고번호, 로트번호, 현장명, 수주처 검색...',
|
|
searchFilter: (item: ShipmentItem, search: string) => {
|
|
const s = search.toLowerCase();
|
|
return (
|
|
item.shipmentNo?.toLowerCase().includes(s) ||
|
|
item.lotNo?.toLowerCase().includes(s) ||
|
|
item.siteName?.toLowerCase().includes(s) ||
|
|
item.orderCustomer?.toLowerCase().includes(s) ||
|
|
item.customerName?.toLowerCase().includes(s) ||
|
|
false
|
|
);
|
|
},
|
|
|
|
// 탭 삭제 (tabs 미설정)
|
|
|
|
// 통계 카드
|
|
stats,
|
|
|
|
// 테이블 행 렌더링 (11개 컬럼)
|
|
renderTableRow: (
|
|
item: ShipmentItem,
|
|
index: number,
|
|
globalIndex: number,
|
|
handlers: SelectionHandlers & RowClickHandlers<ShipmentItem>
|
|
) => {
|
|
return (
|
|
<TableRow
|
|
key={item.id}
|
|
className={`cursor-pointer hover:bg-muted/50 ${handlers.isSelected ? 'bg-blue-50' : ''}`}
|
|
onClick={() => handleRowClick(item)}
|
|
>
|
|
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
|
<Checkbox
|
|
checked={handlers.isSelected}
|
|
onCheckedChange={handlers.onToggle}
|
|
/>
|
|
</TableCell>
|
|
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
|
|
<TableCell className="font-medium">{item.lotNo || item.shipmentNo || '-'}</TableCell>
|
|
<TableCell className="max-w-[100px] truncate">{item.siteName}</TableCell>
|
|
<TableCell>{item.orderCustomer || item.customerName || '-'}</TableCell>
|
|
<TableCell className="text-center">{item.receiver || '-'}</TableCell>
|
|
<TableCell className="max-w-[140px] truncate">{item.receiverAddress || '-'}</TableCell>
|
|
<TableCell>{item.receiverCompany || '-'}</TableCell>
|
|
<TableCell className="text-center">
|
|
<Badge className={`text-xs ${SHIPMENT_STATUS_STYLES[item.status]}`}>
|
|
{SHIPMENT_STATUS_LABELS[item.status]}
|
|
</Badge>
|
|
</TableCell>
|
|
<TableCell className="text-center">{item.dispatch || item.deliveryMethodLabel || '-'}</TableCell>
|
|
<TableCell className="text-center">{item.writer || item.manager || '-'}</TableCell>
|
|
<TableCell className="text-center">{item.shipmentDate || '-'}</TableCell>
|
|
</TableRow>
|
|
);
|
|
},
|
|
|
|
// 모바일 카드 렌더링
|
|
renderMobileCard: (
|
|
item: ShipmentItem,
|
|
index: number,
|
|
globalIndex: number,
|
|
handlers: SelectionHandlers & RowClickHandlers<ShipmentItem>
|
|
) => {
|
|
return (
|
|
<ListMobileCard
|
|
key={item.id}
|
|
id={item.id}
|
|
isSelected={handlers.isSelected}
|
|
onToggleSelection={handlers.onToggle}
|
|
onClick={() => handleRowClick(item)}
|
|
headerBadges={
|
|
<>
|
|
<Badge variant="outline" className="text-xs">
|
|
#{globalIndex}
|
|
</Badge>
|
|
<Badge variant="outline" className="text-xs">
|
|
{item.shipmentNo}
|
|
</Badge>
|
|
</>
|
|
}
|
|
title={item.siteName}
|
|
statusBadge={
|
|
<Badge className={`text-xs ${SHIPMENT_STATUS_STYLES[item.status]}`}>
|
|
{SHIPMENT_STATUS_LABELS[item.status]}
|
|
</Badge>
|
|
}
|
|
infoGrid={
|
|
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
|
<InfoField label="로트번호" value={item.lotNo || item.shipmentNo} />
|
|
<InfoField label="현장명" value={item.siteName} />
|
|
<InfoField label="수주처" value={item.orderCustomer || item.customerName || '-'} />
|
|
<InfoField label="수신자" value={item.receiver || '-'} />
|
|
<InfoField label="수신주소" value={item.receiverAddress || '-'} />
|
|
<InfoField label="수신처" value={item.receiverCompany || '-'} />
|
|
<InfoField label="배차" value={item.dispatch || item.deliveryMethodLabel || '-'} />
|
|
<InfoField label="작성자" value={item.writer || item.manager || '-'} />
|
|
<InfoField label="출고일" value={item.shipmentDate || '-'} />
|
|
</div>
|
|
}
|
|
actions={
|
|
handlers.isSelected && (
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="flex-1"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleRowClick(item);
|
|
}}
|
|
>
|
|
<Eye className="w-4 h-4 mr-1" />
|
|
상세
|
|
</Button>
|
|
</div>
|
|
)
|
|
}
|
|
/>
|
|
);
|
|
},
|
|
|
|
// 하단 캘린더 (일/주 토글)
|
|
afterTableContent: (
|
|
<ScheduleCalendar
|
|
events={scheduleEvents}
|
|
currentDate={calendarDate}
|
|
view={scheduleView}
|
|
onDateClick={handleCalendarDateClick}
|
|
onEventClick={handleCalendarEventClick}
|
|
onMonthChange={setCalendarDate}
|
|
onViewChange={setScheduleView}
|
|
titleSlot="출고 스케줄"
|
|
weekStartsOn={0}
|
|
availableViews={[
|
|
{ value: 'day-time', label: '일' },
|
|
{ value: 'week-time', label: '주' },
|
|
]}
|
|
timeRange={{ start: 1, end: 12 }}
|
|
/>
|
|
),
|
|
}),
|
|
[stats, startDate, endDate, scheduleEvents, calendarDate, scheduleView, handleRowClick, handleCreate, handleCalendarDateClick, handleCalendarEventClick]
|
|
);
|
|
|
|
return <UniversalListPage config={config} />;
|
|
}
|