Files
sam-react-prod/src/components/outbound/ShipmentManagement/ShipmentList.tsx
유병철 81affdc441 feat: ESLint 정리 및 전체 코드 품질 개선
- eslint.config.mjs 규칙 강화 및 정리
- 전역 unused import/변수 제거 (312개 파일)
- next.config.ts, middleware, proxy route 개선
- CopyableCell molecule 추가
- 회계/결재/HR/생산/건설/품질/영업 등 전 도메인 lint 정리
- IntegratedListTemplateV2, DataTable, MobileCard 등 공통 컴포넌트 개선
- execute-server-action 에러 핸들링 보강
2026-03-11 10:27:10 +09:00

411 lines
14 KiB
TypeScript

'use client';
/**
* 출고 목록 - 리뉴얼 버전
*
* 변경 사항:
* - 제목: 출고 목록 → 출고 목록
* - DateRangeSelector 추가
* - 통계 카드: 3개 (당일 출고대기, 출고대기, 출고완료)
* - 탭 삭제 → 배송방식 필터로 대체
* - 테이블 컬럼: 19개
* - 하단 출고 스케줄 캘린더 (시간축 주간 뷰)
*/
import { useState, useEffect, useMemo, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { useStatsLoader } from '@/hooks/useStatsLoader';
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 } from './types';
import { parseISO } from 'date-fns';
import { getLocalDateString } from '@/lib/utils/date';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
// 페이지당 항목 수
const ITEMS_PER_PAGE = 20;
export function ShipmentList() {
const router = useRouter();
// ===== 통계 (외부 관리) =====
const { data: shipmentStats, reload: reloadStats } = useStatsLoader(getShipmentStats);
// ===== 날짜 범위 =====
const today = new Date();
const [startDate, setStartDate] = useState(() => {
const d = new Date(today.getFullYear(), today.getMonth(), 1);
return getLocalDateString(d);
});
const [endDate, setEndDate] = useState(() => {
const d = new Date(today.getFullYear(), today.getMonth() + 1, 0);
return getLocalDateString(d);
});
// ===== 캘린더 상태 =====
const [calendarDate, setCalendarDate] = useState(new Date());
const [scheduleView, setScheduleView] = useState<CalendarView>('week-time');
const [shipmentData, setShipmentData] = useState<ShipmentItem[]>([]);
// startDate 변경 시 캘린더 월 자동 이동
useEffect(() => {
if (startDate) {
const parsed = parseISO(startDate);
if (!isNaN(parsed.getTime())) {
setCalendarDate(parsed);
}
}
}, [startDate]);
// ===== 행 클릭 핸들러 =====
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) {
// 통계 다시 로드
await reloadStats();
// 캘린더용 데이터 저장
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]', copyable: true },
{ key: 'siteName', label: '현장명', className: 'min-w-[100px]', copyable: true },
{ key: 'orderCustomer', label: '수주처', className: 'min-w-[100px]', copyable: true },
{ key: 'receiver', label: '수신자', className: 'w-[80px] text-center', copyable: true },
{ key: 'receiverAddress', label: '수신주소', className: 'min-w-[140px]', copyable: true },
{ key: 'receiverCompany', label: '수신처', className: 'min-w-[100px]', copyable: true },
{ key: 'status', label: '상태', className: 'w-[80px] text-center' },
{ key: 'dispatch', label: '배차', className: 'w-[80px] text-center', copyable: true },
{ key: 'writer', label: '작성자', className: 'w-[80px] text-center', copyable: true },
{ key: 'shipmentDate', label: '출고일', className: 'w-[100px] text-center', copyable: true },
],
// 배송방식 필터
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} />;
}