Files
sam-react-prod/src/components/business/construction/order-management/OrderManagementUnified.tsx
byeongcheolryu e76fac0ab1 feat(WEB): UniversalListPage 컴포넌트 및 파일럿 마이그레이션
- UniversalListPage 템플릿 컴포넌트 생성
- 카드관리(HR) 파일럿 마이그레이션 (기본 케이스)
- 게시판목록 파일럿 마이그레이션 (동적 탭 fetchTabs)
- 발주관리 파일럿 마이그레이션 (ScheduleCalendar beforeTableContent)
- 클라이언트 사이드 필터링 지원 (customFilterFn, customSortFn)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 15:27:59 +09:00

641 lines
23 KiB
TypeScript

'use client';
/**
* 발주관리 리스트 - UniversalListPage 버전
*
* 특이 케이스:
* - ScheduleCalendar 컴포넌트 (beforeTableContent)
* - 9개의 다중선택 필터
* - 달력 날짜 클릭 시 테이블 필터링
* - 클라이언트 사이드 필터링/페이지네이션
*/
import { useState, useMemo, useCallback, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { Package, Pencil, Trash2, Plus } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { TableCell, TableRow } from '@/components/ui/table';
import { Checkbox } from '@/components/ui/checkbox';
import { MultiSelectCombobox, MultiSelectOption } from '@/components/ui/multi-select-combobox';
import { ListMobileCard, InfoField } from '@/components/organisms/ListMobileCard';
import { Badge } from '@/components/ui/badge';
import {
UniversalListPage,
type UniversalListConfig,
type SelectionHandlers,
type RowClickHandlers,
type FilterFieldConfig,
} from '@/components/templates/UniversalListPage';
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
import { ScheduleCalendar, ScheduleEvent, DayBadge } from '@/components/common/ScheduleCalendar';
import { format, parseISO, isSameDay, startOfDay } from 'date-fns';
import type { Order } from './types';
import {
ORDER_STATUS_OPTIONS,
ORDER_SORT_OPTIONS,
ORDER_STATUS_STYLES,
ORDER_STATUS_LABELS,
ORDER_TYPE_OPTIONS,
ORDER_TYPE_LABELS,
MOCK_PARTNERS,
MOCK_SITES,
MOCK_CONSTRUCTION_PM,
MOCK_ORDER_MANAGERS,
MOCK_ORDER_COMPANIES,
MOCK_WORK_TEAM_LEADERS,
getScheduleColorByManager,
} from './types';
import {
getOrderList,
deleteOrder,
deleteOrders,
} from './actions';
interface OrderManagementUnifiedProps {
initialData?: Order[];
}
export function OrderManagementUnified({ initialData }: OrderManagementUnifiedProps) {
const router = useRouter();
// 달력 관련 상태 (beforeTableContent에서 사용하므로 config 외부에서 관리)
const [selectedCalendarDate, setSelectedCalendarDate] = useState<Date | null>(null);
const [calendarDate, setCalendarDate] = useState<Date>(new Date());
const [siteFilters, setSiteFilters] = useState<string[]>([]);
const [workTeamFilters, setWorkTeamFilters] = useState<string[]>([]);
// 날짜 범위 필터 상태
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
// 전체 데이터 (달력 이벤트용)
const [allOrders, setAllOrders] = useState<Order[]>(initialData || []);
const [isLoading, setIsLoading] = useState(false);
// 필터 옵션들
const siteOptions: MultiSelectOption[] = useMemo(() => MOCK_SITES, []);
const workTeamOptions: MultiSelectOption[] = useMemo(() => MOCK_WORK_TEAM_LEADERS, []);
const partnerOptions: MultiSelectOption[] = useMemo(() => MOCK_PARTNERS, []);
const constructionPMOptions: MultiSelectOption[] = useMemo(() => MOCK_CONSTRUCTION_PM, []);
const orderManagerOptions: MultiSelectOption[] = useMemo(() => MOCK_ORDER_MANAGERS, []);
const orderCompanyOptions: MultiSelectOption[] = useMemo(() => MOCK_ORDER_COMPANIES, []);
const orderTypeOptions: MultiSelectOption[] = useMemo(() => ORDER_TYPE_OPTIONS, []);
// 데이터 로드
const loadData = useCallback(async () => {
setIsLoading(true);
try {
const result = await getOrderList({
size: 1000,
startDate: startDate || undefined,
endDate: endDate || undefined,
});
if (result.success && result.data) {
setAllOrders(result.data.items);
}
} catch {
console.error('데이터 로드 실패');
} finally {
setIsLoading(false);
}
}, [startDate, endDate]);
// 초기 데이터가 없으면 로드
useEffect(() => {
if (!initialData || initialData.length === 0) {
loadData();
}
}, [initialData, loadData]);
// 달력용 이벤트 데이터 변환 (필터 적용)
const calendarEvents: ScheduleEvent[] = useMemo(() => {
return allOrders
.filter((order) => {
// 현장 필터
if (siteFilters.length > 0) {
const matchingSite = MOCK_SITES.find((s) => order.siteName.includes(s.label.split(' ')[0]));
if (!matchingSite || !siteFilters.includes(matchingSite.value)) {
return false;
}
}
// 작업반장 필터
if (workTeamFilters.length > 0) {
const matchingLeader = MOCK_WORK_TEAM_LEADERS.find((l) => order.orderManager.includes(l.label.replace('반장', '')));
if (!matchingLeader || !workTeamFilters.includes(matchingLeader.value)) {
return false;
}
}
return true;
})
.map((order) => ({
id: order.id,
title: `${order.orderManager} - ${order.siteName} / ${order.orderNumber}`,
startDate: order.periodStart,
endDate: order.periodEnd,
color: getScheduleColorByManager(order.orderManager),
status: order.status,
data: order,
}));
}, [allOrders, siteFilters, workTeamFilters]);
// 달력용 뱃지 데이터 - 사용하지 않음
const calendarBadges: DayBadge[] = [];
// 달력 이벤트 핸들러
const handleCalendarDateClick = useCallback((date: Date) => {
if (selectedCalendarDate && isSameDay(selectedCalendarDate, date)) {
setSelectedCalendarDate(null);
} else {
setSelectedCalendarDate(date);
}
}, [selectedCalendarDate]);
const handleCalendarEventClick = useCallback((event: ScheduleEvent) => {
if (event.data) {
router.push(`/ko/construction/order/order-management/${event.id}`);
}
}, [router]);
const handleCalendarMonthChange = useCallback((date: Date) => {
setCalendarDate(date);
}, []);
// 날짜 포맷
const formatDate = (dateStr: string | null) => {
if (!dateStr) return '-';
return dateStr.split('T')[0];
};
// 달력 필터 슬롯
const calendarFilterSlot = (
<div className="flex items-center gap-2">
<MultiSelectCombobox
options={siteOptions}
value={siteFilters}
onChange={setSiteFilters}
placeholder="현장"
searchPlaceholder="현장 검색..."
className="w-[160px]"
/>
<MultiSelectCombobox
options={workTeamOptions}
value={workTeamFilters}
onChange={setWorkTeamFilters}
placeholder="작업반장"
searchPlaceholder="작업반장 검색..."
className="w-[130px]"
/>
</div>
);
// UniversalListPage Config 정의
const config: UniversalListConfig<Order> = useMemo(() => ({
// ===== 페이지 기본 정보 =====
title: '발주관리',
description: '발주 스케줄 및 목록을 관리합니다',
icon: Package,
basePath: '/construction/order/order-management',
// ===== ID 추출 =====
idField: 'id',
// ===== API 액션 =====
actions: {
getList: async () => {
const result = await getOrderList({
size: 1000,
startDate: startDate || undefined,
endDate: endDate || undefined,
});
return {
success: result.success,
data: result.data?.items || [],
totalCount: result.data?.items?.length || 0,
error: result.error,
};
},
deleteItem: async (id: string) => {
return await deleteOrder(id);
},
deleteBulk: async (ids: string[]) => {
return await deleteOrders(ids);
},
},
// ===== 테이블 컬럼 =====
columns: [
{ key: 'no', label: '번호', className: 'w-[50px] text-center' },
{ key: 'contractNumber', label: '계약번호', className: 'w-[100px]' },
{ key: 'partnerName', label: '거래처', className: 'w-[80px]' },
{ key: 'siteName', label: '현장명', className: 'min-w-[100px]' },
{ key: 'name', label: '명칭', className: 'w-[80px]' },
{ key: 'constructionPM', label: '공사PM', className: 'w-[70px]' },
{ key: 'orderManager', label: '발주담당자', className: 'w-[80px]' },
{ key: 'orderNumber', label: '발주번호', className: 'w-[100px]' },
{ key: 'orderCompany', label: '발주처명', className: 'w-[80px]' },
{ key: 'workTeamLeader', label: '작업반장', className: 'w-[70px]' },
{ key: 'constructionStartDate', label: '시공투입일', className: 'w-[90px]' },
{ key: 'orderType', label: '구분', className: 'w-[80px] text-center' },
{ key: 'item', label: '품목', className: 'w-[80px]' },
{ key: 'quantity', label: '수량', className: 'w-[60px] text-right' },
{ key: 'orderDate', label: '발주일', className: 'w-[90px]' },
{ key: 'plannedDeliveryDate', label: '계획인수일', className: 'w-[90px]' },
{ key: 'actualDeliveryDate', label: '실제인수일', className: 'w-[90px]' },
{ key: 'status', label: '상태', className: 'w-[80px] text-center' },
{ key: 'actions', label: '작업', className: 'w-[80px] text-center' },
],
// ===== 클라이언트 사이드 필터링 =====
clientSideFiltering: true,
// 검색 필터 함수
searchFilter: (item: Order, searchValue: string) => {
const search = searchValue.toLowerCase();
return (
item.orderNumber.toLowerCase().includes(search) ||
item.partnerName.toLowerCase().includes(search) ||
item.siteName.toLowerCase().includes(search) ||
item.orderManager.toLowerCase().includes(search)
);
},
// ===== 필터 설정 =====
filterConfig: [
{ key: 'partners', label: '거래처', type: 'multi', options: partnerOptions },
{ key: 'sites', label: '현장명', type: 'multi', options: siteOptions },
{ key: 'constructionPMs', label: '공사PM', type: 'multi', options: constructionPMOptions },
{ key: 'orderManagers', label: '발주담당자', type: 'multi', options: orderManagerOptions },
{ key: 'orderCompanies', label: '발주처', type: 'multi', options: orderCompanyOptions },
{ key: 'workTeamLeaders', label: '작업반장', type: 'multi', options: workTeamOptions },
{ key: 'orderTypes', label: '구분', type: 'multi', options: orderTypeOptions },
{ key: 'status', label: '상태', type: 'single', options: ORDER_STATUS_OPTIONS.filter(opt => opt.value !== 'all') },
{ key: 'sortBy', label: '정렬', type: 'single', options: ORDER_SORT_OPTIONS, allOptionLabel: '최신순' },
] as FilterFieldConfig[],
// 커스텀 필터 적용 함수
customFilterFn: (items: Order[], filterValues: Record<string, string | string[]>) => {
return items.filter((order) => {
// 거래처 필터
const partners = filterValues.partners as string[] || [];
if (partners.length > 0) {
const matchingPartner = MOCK_PARTNERS.find((p) => p.label === order.partnerName);
if (!matchingPartner || !partners.includes(matchingPartner.value)) {
return false;
}
}
// 현장명 필터
const sites = filterValues.sites as string[] || [];
if (sites.length > 0) {
const matchingSite = MOCK_SITES.find((s) => s.label === order.siteName);
if (!matchingSite || !sites.includes(matchingSite.value)) {
return false;
}
}
// 공사PM 필터
const constructionPMs = filterValues.constructionPMs as string[] || [];
if (constructionPMs.length > 0) {
const matchingPM = MOCK_CONSTRUCTION_PM.find((p) => p.label === order.constructionPM);
if (!matchingPM || !constructionPMs.includes(matchingPM.value)) {
return false;
}
}
// 발주담당자 필터
const orderManagers = filterValues.orderManagers as string[] || [];
if (orderManagers.length > 0) {
const matchingManager = MOCK_ORDER_MANAGERS.find((m) => m.label === order.orderManager);
if (!matchingManager || !orderManagers.includes(matchingManager.value)) {
return false;
}
}
// 발주처 필터
const orderCompanies = filterValues.orderCompanies as string[] || [];
if (orderCompanies.length > 0) {
const matchingCompany = MOCK_ORDER_COMPANIES.find((c) => c.label === order.orderCompany);
if (!matchingCompany || !orderCompanies.includes(matchingCompany.value)) {
return false;
}
}
// 작업반장 필터
const workTeamLeaders = filterValues.workTeamLeaders as string[] || [];
if (workTeamLeaders.length > 0) {
const matchingLeader = MOCK_WORK_TEAM_LEADERS.find((l) => l.label === order.workTeamLeader);
if (!matchingLeader || !workTeamLeaders.includes(matchingLeader.value)) {
return false;
}
}
// 구분 필터
const orderTypes = filterValues.orderTypes as string[] || [];
if (orderTypes.length > 0 && !orderTypes.includes(order.orderType)) {
return false;
}
// 상태 필터
const status = filterValues.status as string || 'all';
if (status !== 'all' && order.status !== status) {
return false;
}
// 달력 날짜 필터 (selectedCalendarDate)
if (selectedCalendarDate) {
const orderStart = startOfDay(parseISO(order.periodStart));
const orderEnd = startOfDay(parseISO(order.periodEnd));
const selected = startOfDay(selectedCalendarDate);
if (selected < orderStart || selected > orderEnd) {
return false;
}
}
return true;
});
},
// 커스텀 정렬 함수
customSortFn: (items: Order[], filterValues: Record<string, string | string[]>) => {
const sortBy = filterValues.sortBy as string || 'latest';
const sorted = [...items];
switch (sortBy) {
case 'latest':
sorted.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
break;
case 'oldest':
sorted.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
break;
case 'partnerNameAsc':
sorted.sort((a, b) => a.partnerName.localeCompare(b.partnerName, 'ko'));
break;
case 'partnerNameDesc':
sorted.sort((a, b) => b.partnerName.localeCompare(a.partnerName, 'ko'));
break;
case 'siteNameAsc':
sorted.sort((a, b) => a.siteName.localeCompare(b.siteName, 'ko'));
break;
case 'siteNameDesc':
sorted.sort((a, b) => b.siteName.localeCompare(a.siteName, 'ko'));
break;
case 'deliveryDateAsc':
sorted.sort((a, b) => a.plannedDeliveryDate.localeCompare(b.plannedDeliveryDate));
break;
case 'deliveryDateDesc':
sorted.sort((a, b) => b.plannedDeliveryDate.localeCompare(a.plannedDeliveryDate));
break;
}
return sorted;
},
// ===== 검색 설정 =====
searchPlaceholder: '발주번호, 거래처, 현장명, 발주담당 검색',
// ===== 상세 보기 모드 =====
detailMode: 'page',
// ===== 헤더 액션 =====
headerActions: ({ onCreate }) => (
<DateRangeSelector
startDate={startDate}
endDate={endDate}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
extraActions={
<Button onClick={onCreate}>
<Plus className="mr-2 h-4 w-4" />
</Button>
}
/>
),
// ===== 테이블 헤더 추가 액션 =====
tableHeaderActions: (
<div className="flex items-center gap-2 flex-wrap">
{selectedCalendarDate && (
<>
<span className="text-sm text-primary">
({format(selectedCalendarDate, 'M/d')} )
</span>
<Button
variant="outline"
size="sm"
onClick={() => setSelectedCalendarDate(null)}
>
</Button>
</>
)}
</div>
),
// ===== 삭제 확인 메시지 =====
deleteConfirmMessage: {
title: '발주 삭제',
description: '선택한 발주를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.',
},
// ===== 테이블 행 렌더링 =====
renderTableRow: (
item: Order,
index: number,
globalIndex: number,
handlers: SelectionHandlers & RowClickHandlers<Order>
) => {
const { isSelected, onToggle, onRowClick, onEdit, onDelete } = handlers;
return (
<TableRow
key={item.id}
className="cursor-pointer hover:bg-muted/50"
onClick={() => onRowClick(item)}
>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={isSelected}
onCheckedChange={onToggle}
/>
</TableCell>
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
<TableCell>{item.contractNumber}</TableCell>
<TableCell>{item.partnerName}</TableCell>
<TableCell>{item.siteName}</TableCell>
<TableCell>{item.name}</TableCell>
<TableCell>{item.constructionPM}</TableCell>
<TableCell>{item.orderManager}</TableCell>
<TableCell>{item.orderNumber}</TableCell>
<TableCell>{item.orderCompany}</TableCell>
<TableCell>{item.workTeamLeader}</TableCell>
<TableCell>{formatDate(item.constructionStartDate)}</TableCell>
<TableCell className="text-center">
<span className="px-2 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
{ORDER_TYPE_LABELS[item.orderType]}
</span>
</TableCell>
<TableCell>{item.item}</TableCell>
<TableCell className="text-right">{item.quantity}</TableCell>
<TableCell>{formatDate(item.orderDate)}</TableCell>
<TableCell>{formatDate(item.plannedDeliveryDate)}</TableCell>
<TableCell>{formatDate(item.actualDeliveryDate)}</TableCell>
<TableCell className="text-center">
<span className={`px-2 py-1 rounded-full text-xs font-medium ${ORDER_STATUS_STYLES[item.status]}`}>
{ORDER_STATUS_LABELS[item.status]}
</span>
</TableCell>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
{isSelected && (
<div className="flex items-center justify-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => onEdit?.(item)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={() => onDelete?.(item)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
)}
</TableCell>
</TableRow>
);
},
// ===== 모바일 카드 렌더링 =====
renderMobileCard: (
item: Order,
index: number,
globalIndex: number,
handlers: SelectionHandlers & RowClickHandlers<Order>
) => {
const { isSelected, onToggle, onRowClick, onEdit, onDelete } = handlers;
return (
<ListMobileCard
key={item.id}
id={item.id}
title={item.siteName}
headerBadges={
<div className="flex items-center gap-2 flex-wrap">
<Badge variant="outline" className="text-xs">
#{globalIndex}
</Badge>
<span className="text-xs text-muted-foreground">
{item.orderNumber}
</span>
</div>
}
statusBadge={
<Badge className={ORDER_STATUS_STYLES[item.status]}>
{ORDER_STATUS_LABELS[item.status]}
</Badge>
}
isSelected={isSelected}
onToggleSelection={onToggle}
onCardClick={() => onRowClick(item)}
infoGrid={
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
<InfoField label="거래처" value={item.partnerName} />
<InfoField label="발주담당" value={item.orderManager} />
<InfoField label="계획납품일" value={formatDate(item.plannedDeliveryDate)} />
<InfoField label="구분" value={ORDER_TYPE_LABELS[item.orderType]} />
</div>
}
actions={
isSelected ? (
<div className="flex gap-2 flex-wrap">
<Button
variant="default"
size="default"
className="flex-1 min-w-[100px] h-11"
onClick={(e) => { e.stopPropagation(); onEdit?.(item); }}
>
<Pencil className="h-4 w-4 mr-2" />
</Button>
<Button
variant="outline"
size="default"
className="flex-1 min-w-[100px] h-11 border-red-200 text-red-600 hover:border-red-300 bg-transparent"
onClick={(e) => { e.stopPropagation(); onDelete?.(item); }}
>
<Trash2 className="h-4 w-4 mr-2" />
</Button>
</div>
) : undefined
}
/>
);
},
// ===== 테이블 전 콘텐츠 (달력) =====
beforeTableContent: (
<div className="w-full flex-shrink-0 mb-6">
<ScheduleCalendar
events={calendarEvents}
badges={calendarBadges}
currentDate={calendarDate}
selectedDate={selectedCalendarDate}
onDateClick={handleCalendarDateClick}
onEventClick={handleCalendarEventClick}
onMonthChange={handleCalendarMonthChange}
titleSlot="발주 스케줄"
filterSlot={calendarFilterSlot}
maxEventsPerDay={5}
weekStartsOn={0}
isLoading={isLoading}
/>
</div>
),
// ===== 추가 옵션 =====
showCheckbox: true,
showRowNumber: true,
itemsPerPage: 20,
}), [
startDate,
endDate,
selectedCalendarDate,
calendarDate,
calendarEvents,
calendarBadges,
calendarFilterSlot,
isLoading,
handleCalendarDateClick,
handleCalendarEventClick,
handleCalendarMonthChange,
partnerOptions,
siteOptions,
constructionPMOptions,
orderManagerOptions,
orderCompanyOptions,
workTeamOptions,
orderTypeOptions,
router,
]);
return (
<UniversalListPage<Order>
config={config}
initialData={initialData}
/>
);
}
export default OrderManagementUnified;