Files
sam-react-prod/src/components/business/construction/contract/ContractListClient.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

409 lines
14 KiB
TypeScript

'use client';
/**
* 계약관리 리스트 - UniversalListPage 마이그레이션
*
* 기존 IntegratedListTemplateV2 → UniversalListPage config 기반으로 변환
* - 클라이언트 사이드 필터링 (검색, 필터, 정렬)
* - Stats 카드 클릭 필터링 (activeStatTab)
* - DateRangeSelector (headerActions → dateRangeSelector config)
* - filterConfig (multi: 거래처, 계약담당자, 공사PM / single: 상태, 정렬)
*/
import { useState, useMemo, useEffect } from 'react';
import { FileText, Clock, CheckCircle, 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 { MobileCard } from '@/components/organisms/MobileCard';
import {
UniversalListPage,
type UniversalListConfig,
type SelectionHandlers,
type RowClickHandlers,
type StatCard,
} from '@/components/templates/UniversalListPage';
import type { Contract, ContractStats } from './types';
import {
CONTRACT_STATUS_OPTIONS,
CONTRACT_SORT_OPTIONS,
CONTRACT_STATUS_STYLES,
CONTRACT_STATUS_LABELS,
} from './types';
import { getContractList, getContractStats, deleteContract, deleteContracts } from './actions';
import { formatNumber } from '@/utils/formatAmount';
import { formatDate, formatDateRange } from '@/utils/date';
// 테이블 컬럼 정의
const tableColumns = [
{ key: 'no', label: '번호', className: 'w-[60px] text-center' },
{ key: 'contractCode', label: '계약번호', className: 'w-[120px]' },
{ key: 'partnerName', label: '거래처', className: 'w-[100px]' },
{ key: 'projectName', label: '현장명', className: 'min-w-[150px]' },
{ key: 'contractManager', label: '계약담당자', className: 'w-[100px] text-center' },
{ key: 'constructionPM', label: '공사PM', className: 'w-[80px] text-center' },
{ key: 'totalLocations', label: '총 개소', className: 'w-[80px] text-center' },
{ key: 'contractAmount', label: '계약금액', className: 'w-[120px] text-right' },
{ key: 'contractPeriod', label: '계약기간', className: 'w-[180px] text-center' },
{ key: 'status', label: '상태', className: 'w-[80px] text-center' },
{ key: 'actions', label: '작업', className: 'w-[80px] text-center' },
];
// 목업 데이터
const MOCK_PARTNERS = [
{ value: '1', label: '통신공사' },
{ value: '2', label: '야사건설' },
{ value: '3', label: '여의건설' },
];
const MOCK_CONTRACT_MANAGERS = [
{ value: 'hong', label: '홍길동' },
{ value: 'kim', label: '김철수' },
{ value: 'lee', label: '이영희' },
];
const MOCK_CONSTRUCTION_PMS = [
{ value: 'kim', label: '김PM' },
{ value: 'lee', label: '이PM' },
{ value: 'park', label: '박PM' },
];
interface ContractListClientProps {
initialData?: Contract[];
initialStats?: ContractStats;
}
export default function ContractListClient({ initialData = [], initialStats }: ContractListClientProps) {
// ===== 공통 핸들러 Hook =====
const { handleRowClick, handleEdit } = useListHandlers<Contract>('construction/project/contract');
// ===== 외부 상태 =====
const [activeStatTab, setActiveStatTab] = useState<'all' | 'pending' | 'completed'>('all');
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const [searchQuery, setSearchQuery] = useState('');
const [stats, setStats] = useState<ContractStats | null>(initialStats || null);
// Stats 로드
useEffect(() => {
if (!initialStats) {
getContractStats().then((result) => {
if (result.success && result.data) {
setStats(result.data);
}
});
}
}, [initialStats]);
// ===== UniversalListPage Config =====
const config: UniversalListConfig<Contract> = useMemo(
() => ({
// 페이지 기본 정보
title: '계약관리',
description: '계약 정보를 관리합니다',
icon: FileText,
basePath: '/construction/project/contract',
// ID 추출
idField: 'id',
// API 액션
actions: {
getList: async () => {
const result = await getContractList({
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 deleteContract(id);
return { success: result.success, error: result.error };
},
deleteBulk: async (ids: string[]) => {
const result = await deleteContracts(ids);
return { success: result.success, error: result.error };
},
},
// 테이블 컬럼
columns: tableColumns,
// 클라이언트 사이드 필터링
clientSideFiltering: true,
itemsPerPage: 20,
// 검색 필터
searchPlaceholder: '계약번호, 거래처, 현장명 검색',
searchFilter: (item, searchValue) => {
const search = searchValue.toLowerCase();
return (
item.projectName.toLowerCase().includes(search) ||
item.contractCode.toLowerCase().includes(search) ||
item.partnerName.toLowerCase().includes(search)
);
},
// 필터 설정
filterConfig: [
{
key: 'partner',
label: '거래처',
type: 'multi',
options: MOCK_PARTNERS,
},
{
key: 'contractManager',
label: '계약담당자',
type: 'multi',
options: MOCK_CONTRACT_MANAGERS,
},
{
key: 'constructionPM',
label: '공사PM',
type: 'multi',
options: MOCK_CONSTRUCTION_PMS,
},
{
key: 'status',
label: '상태',
type: 'single',
options: CONTRACT_STATUS_OPTIONS.filter((o) => o.value !== 'all'),
},
{
key: 'sortBy',
label: '정렬',
type: 'single',
options: CONTRACT_SORT_OPTIONS,
},
],
initialFilters: {
partner: [],
contractManager: [],
constructionPM: [],
status: 'all',
sortBy: 'contractDateDesc',
},
filterTitle: '계약 필터',
// 커스텀 필터 함수
customFilterFn: (items, filterValues) => {
if (!items || items.length === 0) return items;
return items.filter((item) => {
// Stats 탭 필터
if (activeStatTab === 'pending' && item.status !== 'pending') return false;
if (activeStatTab === 'completed' && item.status !== 'completed') return false;
// 거래처 필터
const partnerFilters = filterValues.partner as string[];
if (partnerFilters?.length > 0 && !partnerFilters.includes(item.partnerId)) return false;
// 계약담당자 필터
const contractManagerFilters = filterValues.contractManager as string[];
if (contractManagerFilters?.length > 0 && !contractManagerFilters.includes(item.contractManagerId))
return false;
// 공사PM 필터
const constructionPMFilters = filterValues.constructionPM as string[];
if (constructionPMFilters?.length > 0 && !constructionPMFilters.includes(item.constructionPMId || ''))
return false;
// 상태 필터
const statusFilter = filterValues.status as string;
if (statusFilter && statusFilter !== 'all' && item.status !== statusFilter) return false;
return true;
});
},
// 커스텀 정렬 함수
customSortFn: (items, filterValues) => {
const sorted = [...items];
const sortBy = (filterValues.sortBy as string) || 'contractDateDesc';
switch (sortBy) {
case 'contractDateDesc':
sorted.sort((a, b) => {
if (!a.contractStartDate) return 1;
if (!b.contractStartDate) return -1;
return new Date(b.contractStartDate).getTime() - new Date(a.contractStartDate).getTime();
});
break;
case 'contractDateAsc':
sorted.sort((a, b) => {
if (!a.contractStartDate) return 1;
if (!b.contractStartDate) return -1;
return new Date(a.contractStartDate).getTime() - new Date(b.contractStartDate).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 'projectNameAsc':
sorted.sort((a, b) => a.projectName.localeCompare(b.projectName, 'ko'));
break;
case 'projectNameDesc':
sorted.sort((a, b) => b.projectName.localeCompare(a.projectName, 'ko'));
break;
case 'amountDesc':
sorted.sort((a, b) => b.contractAmount - a.contractAmount);
break;
case 'amountAsc':
sorted.sort((a, b) => a.contractAmount - b.contractAmount);
break;
}
return sorted;
},
// 검색창 (공통 컴포넌트에서 자동 생성)
hideSearch: true,
searchValue: searchQuery,
onSearchChange: setSearchQuery,
// 공통 헤더 옵션
dateRangeSelector: {
enabled: true,
startDate,
endDate,
onStartDateChange: setStartDate,
onEndDateChange: setEndDate,
},
// Stats 카드
computeStats: (): StatCard[] => [
{
label: '전체 계약',
value: stats?.total ?? 0,
icon: FileText,
iconColor: 'text-blue-600',
onClick: () => setActiveStatTab('all'),
isActive: activeStatTab === 'all',
},
{
label: '계약대기',
value: stats?.pending ?? 0,
icon: Clock,
iconColor: 'text-orange-500',
onClick: () => setActiveStatTab('pending'),
isActive: activeStatTab === 'pending',
},
{
label: '계약완료',
value: stats?.completed ?? 0,
icon: CheckCircle,
iconColor: 'text-green-600',
onClick: () => setActiveStatTab('completed'),
isActive: activeStatTab === 'completed',
},
],
// 삭제 확인 메시지
deleteConfirmMessage: {
title: '계약 삭제',
description: '선택한 계약을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.',
},
// 테이블 행 렌더링
renderTableRow: (
item: Contract,
index: number,
globalIndex: number,
handlers: SelectionHandlers & RowClickHandlers<Contract>
) => (
<TableRow
key={item.id}
className="cursor-pointer hover:bg-muted/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>{item.contractCode}</TableCell>
<TableCell>{item.partnerName}</TableCell>
<TableCell>{item.projectName}</TableCell>
<TableCell className="text-center">{item.contractManagerName}</TableCell>
<TableCell className="text-center">{item.constructionPMName || '-'}</TableCell>
<TableCell className="text-center">{item.totalLocations}</TableCell>
<TableCell className="text-right">{formatNumber(item.contractAmount)}</TableCell>
<TableCell className="text-center">
{formatDateRange(item.contractStartDate, item.contractEndDate)}
</TableCell>
<TableCell className="text-center">
<span className={CONTRACT_STATUS_STYLES[item.status]}>{CONTRACT_STATUS_LABELS[item.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(item);
}}
>
<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?.(item);
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
)}
</TableCell>
</TableRow>
),
// 모바일 카드 렌더링
renderMobileCard: (
item: Contract,
index: number,
globalIndex: number,
handlers: SelectionHandlers & RowClickHandlers<Contract>
) => (
<MobileCard
key={item.id}
title={item.projectName}
subtitle={item.contractCode}
badge={CONTRACT_STATUS_LABELS[item.status]}
badgeVariant="secondary"
isSelected={handlers.isSelected}
onToggle={handlers.onToggle}
onClick={() => handleRowClick(item)}
details={[
{ label: '거래처', value: item.partnerName },
{ label: '총 개소', value: `${item.totalLocations}` },
{ label: '계약금액', value: `${formatNumber(item.contractAmount)}` },
{ label: '계약담당자', value: item.contractManagerName },
{ label: '공사PM', value: item.constructionPMName || '-' },
]}
/>
),
}),
[startDate, endDate, searchQuery, activeStatTab, stats, handleRowClick, handleEdit]
);
return <UniversalListPage config={config} initialData={initialData} onSearchChange={setSearchQuery} />;
}