Files
sam-react-prod/src/components/outbound/ShipmentManagement/ShipmentList.tsx
유병철 c1b63b850a feat(WEB): 자재/출고/생산/품질/단가 기능 대폭 개선 및 신규 페이지 추가
자재관리:
- 입고관리 재고조정 다이얼로그 신규 추가, 상세/목록 기능 확장
- 재고현황 컴포넌트 리팩토링

출고관리:
- 출하관리 생성/수정/목록/상세 개선
- 차량배차관리 상세/수정/목록 기능 보강

생산관리:
- 작업지시서 WIP 생산 모달 신규 추가
- 벤딩WIP/슬랫조인트바 검사 콘텐츠 신규 추가
- 작업자화면 기능 대폭 확장 (카드/목록 개선)
- 검사성적서 모달 개선

품질관리:
- 실적보고서 관리 페이지 신규 추가
- 검사관리 문서/타입/목데이터 개선

단가관리:
- 단가배포 페이지 및 컴포넌트 신규 추가
- 단가표 관리 페이지 및 컴포넌트 신규 추가

공통:
- 권한 시스템 추가 개선 (PermissionContext, usePermission, PermissionGuard)
- 메뉴 폴링 훅 개선, 레이아웃 수정
- 모바일 줌/패닝 CSS 수정
- locale 유틸 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 12:46:19 +09:00

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} />;
}