Files
sam-react-prod/src/components/business/construction/order-management/OrderManagementListClient.tsx
유병철 32d6e3bbbd feat(WEB): 공사관리 리스트 공통화 및 캘린더/포맷터 기능 개선
공사관리 리스트 공통화:
- 입찰/계약/견적/인수인계/이슈/품목/노무/현장/파트너/단가/기성/현장브리핑/구조검토/유틸리티/작업자현황 리스트 공통 포맷터 적용
- 중복 포맷팅 로직 제거 (-530줄)

캘린더 기능 개선:
- CEODashboard CalendarSection 기능 확장
- ScheduleCalendar DayCell/MonthView/WeekView 개선
- ui/calendar 컴포넌트 기능 추가

유틸리티 개선:
- date.ts 날짜 유틸 함수 추가
- formatAmount.ts 금액 포맷 함수 추가

신규 추가:
- useListHandlers 훅 추가
- src/constants/ 디렉토리 추가
- 포맷터 공통화 계획 문서 추가
- SAM ERP/MES 정체성 분석 문서 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 17:38:38 +09:00

608 lines
23 KiB
TypeScript

'use client';
/**
* 발주관리 - UniversalListPage 마이그레이션
*
* 기존 IntegratedListTemplateV2 → UniversalListPage config 기반으로 변환
* - 클라이언트 사이드 필터링 (검색, 필터, 정렬)
* - ScheduleCalendar (beforeTableContent)
* - 달력 전용 필터 (siteFilters, workTeamFilters - 달력 이벤트 필터링용)
* - 달력 날짜 선택 필터링 (selectedCalendarDate)
* - DateRangeSelector + 등록 버튼 (dateRangeSelector + createButton)
* - filterConfig (multi 7개 + single 2개)
* - 삭제 기능 (deleteConfirmMessage)
*/
import { useState, useMemo, useCallback, useEffect } from 'react';
import { Package, Pencil, Trash2 } from 'lucide-react';
import { useListHandlers } from '@/hooks/useListHandlers';
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 { MobileCard } from '@/components/organisms/MobileCard';
import { ScheduleCalendar, ScheduleEvent, DayBadge } from '@/components/common/ScheduleCalendar';
import {
UniversalListPage,
type UniversalListConfig,
type SelectionHandlers,
type RowClickHandlers,
} from '@/components/templates/UniversalListPage';
import { format, parseISO, isSameDay, startOfDay } from 'date-fns';
import type { Order, OrderStats } 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,
getOrderStats,
deleteOrder,
deleteOrders,
} from './actions';
// 테이블 컬럼 정의
const tableColumns = [
{ 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' },
];
interface OrderManagementListClientProps {
initialData?: Order[];
initialStats?: OrderStats;
}
export default function OrderManagementListClient({
initialData = [],
initialStats,
}: OrderManagementListClientProps) {
// ===== 공통 핸들러 Hook =====
const { handleRowClick, handleEdit, router } = useListHandlers<Order>(
'construction/order/order-management'
);
// ===== 외부 상태 (UniversalListPage 외부에서 관리) =====
const [startDate, setStartDate] = useState<string>('');
const [endDate, setEndDate] = useState<string>('');
const [searchQuery, setSearchQuery] = useState('');
// 달력 관련 상태
const [selectedCalendarDate, setSelectedCalendarDate] = useState<Date | null>(null);
const [calendarDate, setCalendarDate] = useState<Date>(new Date());
// 달력 전용 필터 (테이블 필터와 별도)
const [calendarSiteFilters, setCalendarSiteFilters] = useState<string[]>([]);
const [calendarWorkTeamFilters, setCalendarWorkTeamFilters] = useState<string[]>([]);
// 전체 데이터 (달력 이벤트용)
const [allOrders, setAllOrders] = useState<Order[]>(initialData);
// 필터 옵션 (memo)
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 calendarEvents: ScheduleEvent[] = useMemo(() => {
return allOrders
.filter((order) => {
// 유효한 날짜가 있는 항목만 달력에 표시
// periodStart/periodEnd가 빈 문자열이면 parseISO가 Invalid Date를 반환하여
// 모든 이벤트가 일요일(0번 컬럼)에 표시되는 버그 방지
if (!order.periodStart || !order.periodEnd) {
return false;
}
// 현장 필터 (달력용)
if (calendarSiteFilters.length > 0) {
const matchingSite = MOCK_SITES.find((s) => order.siteName.includes(s.label.split(' ')[0]));
if (!matchingSite || !calendarSiteFilters.includes(matchingSite.value)) {
return false;
}
}
// 작업반장 필터 (달력용)
if (calendarWorkTeamFilters.length > 0) {
const matchingLeader = MOCK_WORK_TEAM_LEADERS.find((l) => order.orderManager.includes(l.label.replace('반장', '')));
if (!matchingLeader || !calendarWorkTeamFilters.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, calendarSiteFilters, calendarWorkTeamFilters]);
// 달력 뱃지 (사용 안 함)
const calendarBadges: DayBadge[] = [];
// 날짜 포맷
const formatDate = useCallback((dateStr: string | null) => {
if (!dateStr) return '-';
return dateStr.split('T')[0];
}, []);
// ===== 추가 핸들러 =====
const handleCreate = useCallback(() => {
router.push('/ko/construction/order/order-management?mode=new');
}, [router]);
// 달력 이벤트 핸들러
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}?mode=view`);
}
}, [router]);
const handleCalendarMonthChange = useCallback((date: Date) => {
setCalendarDate(date);
}, []);
// 달력 필터 슬롯
const calendarFilterSlot = useMemo(() => (
<div className="flex items-center gap-2">
<MultiSelectCombobox
options={siteOptions}
value={calendarSiteFilters}
onChange={setCalendarSiteFilters}
placeholder="현장"
searchPlaceholder="현장 검색..."
className="w-[160px]"
/>
<MultiSelectCombobox
options={workTeamOptions}
value={calendarWorkTeamFilters}
onChange={setCalendarWorkTeamFilters}
placeholder="작업반장"
searchPlaceholder="작업반장 검색..."
className="w-[130px]"
/>
</div>
), [siteOptions, workTeamOptions, calendarSiteFilters, calendarWorkTeamFilters]);
// ===== 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,
});
if (result.success && result.data) {
return {
success: true,
data: result.data.items,
totalCount: result.data.total,
};
}
return { success: false, error: result.error };
},
deleteItem: async (id: string) => {
const result = await deleteOrder(id);
return { success: result.success, error: result.error };
},
deleteBulk: async (ids: string[]) => {
const result = await deleteOrders(ids);
return { success: result.success, error: result.error };
},
},
// 테이블 컬럼
columns: tableColumns,
// 클라이언트 사이드 필터링
clientSideFiltering: true,
itemsPerPage: 20,
// 데이터 변경 콜백 (달력 이벤트용)
onDataChange: (data) => setAllOrders(data),
// 검색 필터
searchPlaceholder: '발주번호, 거래처, 현장명, 발주담당 검색',
searchFilter: (item, searchValue) => {
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((o) => o.value !== 'all') },
{ key: 'sortBy', label: '정렬', type: 'single', options: ORDER_SORT_OPTIONS },
],
initialFilters: {
partners: [],
sites: [],
constructionPMs: [],
orderManagers: [],
orderCompanies: [],
workTeamLeaders: [],
orderTypes: [],
status: 'all',
sortBy: 'latest',
},
filterTitle: '발주 필터',
// 커스텀 필터 함수
customFilterFn: (items, filterValues) => {
if (!items || items.length === 0) return items;
return items.filter((item) => {
// 거래처 필터 (다중선택)
const partnerFilters = filterValues.partners as string[];
if (partnerFilters?.length > 0) {
const matchingPartner = MOCK_PARTNERS.find((p) => p.label === item.partnerName);
if (!matchingPartner || !partnerFilters.includes(matchingPartner.value)) {
return false;
}
}
// 현장명 필터 (다중선택)
const siteFilters = filterValues.sites as string[];
if (siteFilters?.length > 0) {
const matchingSite = MOCK_SITES.find((s) => s.label === item.siteName);
if (!matchingSite || !siteFilters.includes(matchingSite.value)) {
return false;
}
}
// 공사PM 필터 (다중선택)
const pmFilters = filterValues.constructionPMs as string[];
if (pmFilters?.length > 0) {
const matchingPM = MOCK_CONSTRUCTION_PM.find((p) => p.label === item.constructionPM);
if (!matchingPM || !pmFilters.includes(matchingPM.value)) {
return false;
}
}
// 발주담당자 필터 (다중선택)
const managerFilters = filterValues.orderManagers as string[];
if (managerFilters?.length > 0) {
const matchingManager = MOCK_ORDER_MANAGERS.find((m) => m.label === item.orderManager);
if (!matchingManager || !managerFilters.includes(matchingManager.value)) {
return false;
}
}
// 발주처 필터 (다중선택)
const companyFilters = filterValues.orderCompanies as string[];
if (companyFilters?.length > 0) {
const matchingCompany = MOCK_ORDER_COMPANIES.find((c) => c.label === item.orderCompany);
if (!matchingCompany || !companyFilters.includes(matchingCompany.value)) {
return false;
}
}
// 작업반장 필터 (다중선택)
const teamLeaderFilters = filterValues.workTeamLeaders as string[];
if (teamLeaderFilters?.length > 0) {
const matchingLeader = MOCK_WORK_TEAM_LEADERS.find((l) => l.label === item.workTeamLeader);
if (!matchingLeader || !teamLeaderFilters.includes(matchingLeader.value)) {
return false;
}
}
// 구분 필터 (다중선택)
const typeFilters = filterValues.orderTypes as string[];
if (typeFilters?.length > 0 && !typeFilters.includes(item.orderType)) {
return false;
}
// 상태 필터 (단일선택)
const statusFilter = filterValues.status as string;
if (statusFilter && statusFilter !== 'all' && item.status !== statusFilter) {
return false;
}
// 달력 날짜 필터
if (selectedCalendarDate) {
// periodStart/periodEnd가 빈 문자열이면 필터링에서 제외
if (!item.periodStart || !item.periodEnd) {
return false;
}
const orderStart = startOfDay(parseISO(item.periodStart));
const orderEnd = startOfDay(parseISO(item.periodEnd));
const selected = startOfDay(selectedCalendarDate);
if (selected < orderStart || selected > orderEnd) {
return false;
}
}
return true;
});
},
// 커스텀 정렬 함수
customSortFn: (items, filterValues) => {
const sorted = [...items];
const sortBy = (filterValues.sortBy as string) || 'latest';
switch (sortBy) {
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;
default: // latest
sorted.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
break;
}
return sorted;
},
// 검색창 (공통 컴포넌트에서 자동 생성)
hideSearch: true,
searchValue: searchQuery,
onSearchChange: setSearchQuery,
// 공통 헤더 옵션
dateRangeSelector: {
enabled: true,
startDate,
endDate,
onStartDateChange: setStartDate,
onEndDateChange: setEndDate,
},
createButton: {
label: '발주 등록',
onClick: handleCreate,
},
// 삭제 확인 메시지
deleteConfirmMessage: {
title: '발주 삭제',
description: '선택한 발주를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.',
},
// 테이블 헤더 액션 (총건 + 달력 날짜 필터 해제)
tableHeaderActions: ({ totalCount }) => (
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm text-muted-foreground whitespace-nowrap">
{totalCount}
{selectedCalendarDate && (
<span className="ml-2 text-primary">
({format(selectedCalendarDate, 'M/d')} )
</span>
)}
</span>
{selectedCalendarDate && (
<Button
variant="outline"
size="sm"
onClick={() => setSelectedCalendarDate(null)}
>
</Button>
)}
</div>
),
// 달력 섹션 (beforeTableContent)
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={false}
/>
</div>
),
// 테이블 행 렌더링
renderTableRow: (
order: Order,
index: number,
globalIndex: number,
handlers: SelectionHandlers & RowClickHandlers<Order>
) => (
<TableRow
key={order.id}
className="cursor-pointer hover:bg-muted/50"
onClick={() => handleRowClick(order)}
>
<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>{order.contractNumber}</TableCell>
<TableCell>{order.partnerName}</TableCell>
<TableCell>{order.siteName}</TableCell>
<TableCell>{order.name}</TableCell>
<TableCell>{order.constructionPM}</TableCell>
<TableCell>{order.orderManager}</TableCell>
<TableCell>{order.orderNumber}</TableCell>
<TableCell>{order.orderCompany}</TableCell>
<TableCell>{order.workTeamLeader}</TableCell>
<TableCell>{formatDate(order.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[order.orderType]}
</span>
</TableCell>
<TableCell>{order.item}</TableCell>
<TableCell className="text-right">{order.quantity}</TableCell>
<TableCell>{formatDate(order.orderDate)}</TableCell>
<TableCell>{formatDate(order.plannedDeliveryDate)}</TableCell>
<TableCell>{formatDate(order.actualDeliveryDate)}</TableCell>
<TableCell className="text-center">
<span className={`px-2 py-1 rounded-full text-xs font-medium ${ORDER_STATUS_STYLES[order.status]}`}>
{ORDER_STATUS_LABELS[order.status]}
</span>
</TableCell>
<TableCell className="text-center">
{handlers.isSelected && (
<div className="flex items-center justify-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={(e) => {
e.stopPropagation();
handleEdit(order);
}}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
handlers.onDelete?.(order);
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
)}
</TableCell>
</TableRow>
),
// 모바일 카드 렌더링
renderMobileCard: (
order: Order,
index: number,
globalIndex: number,
handlers: SelectionHandlers & RowClickHandlers<Order>
) => (
<MobileCard
key={order.id}
title={order.siteName}
subtitle={order.orderNumber}
badge={ORDER_STATUS_LABELS[order.status]}
badgeVariant="secondary"
isSelected={handlers.isSelected}
onToggle={handlers.onToggle}
onClick={() => handleRowClick(order)}
details={[
{ label: '거래처', value: order.partnerName },
{ label: '발주담당', value: order.orderManager },
{ label: '계획인수일', value: formatDate(order.plannedDeliveryDate) },
]}
/>
),
}),
[
startDate,
endDate,
searchQuery,
selectedCalendarDate,
calendarEvents,
calendarBadges,
calendarDate,
calendarFilterSlot,
partnerOptions,
siteOptions,
constructionPMOptions,
orderManagerOptions,
orderCompanyOptions,
workTeamOptions,
orderTypeOptions,
handleRowClick,
handleEdit,
handleCreate,
handleCalendarDateClick,
handleCalendarEventClick,
handleCalendarMonthChange,
formatDate,
]
);
return <UniversalListPage config={config} initialData={initialData} onSearchChange={setSearchQuery} />;
}