Files
sam-react-prod/src/components/business/construction/utility-management/UtilityManagementListClient.tsx
유병철 1f6b592b9f feat(WEB): 리스트 페이지 UI 레이아웃 표준화
- 공통 레이아웃 패턴 적용: [달력] → [프리셋] → [검색창] → [버튼들]
- beforeTableContent → headerActions + createButton 마이그레이션
- DateRangeSelector extraActions prop 활용하여 검색창 통합
- PricingListClient 테이블 행 클릭 → 상세 이동 기능 추가
- 회계 관련 페이지 (입금/출금/매입/매출/어음/카드/예상지출 등) 정리
- 건설 관련 페이지 검색 영역 정리
- 부모 메뉴 리다이렉트 컴포넌트 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 22:04:36 +09:00

404 lines
14 KiB
TypeScript

'use client';
/**
* 공과관리 - UniversalListPage 마이그레이션
*
* 기존 IntegratedListTemplateV2 → UniversalListPage config 기반으로 변환
* - 클라이언트 사이드 필터링 (검색, 필터, 정렬)
* - Stats 카드 클릭 필터링 (activeStatTab)
* - DateRangeSelector (headerActions → dateRangeSelector config)
* - filterConfig (multi: 거래처, 현장명, 공사PM, 작업반장 / single: 공과, 상태, 정렬)
* - 삭제 기능 (deleteConfirmMessage로 AlertDialog 대체)
*/
import { useState, useMemo, useEffect } from 'react';
import { Zap, Trash2, FileText, CheckCircle, Clock } from 'lucide-react';
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 { Utility, UtilityStats } from './types';
import {
UTILITY_STATUS_OPTIONS,
UTILITY_SORT_OPTIONS,
UTILITY_STATUS_STYLES,
UTILITY_STATUS_LABELS,
MOCK_PARTNERS,
MOCK_SITES,
MOCK_CONSTRUCTION_PM,
MOCK_UTILITY_TYPES,
MOCK_WORK_TEAM_LEADERS,
} from './types';
import {
getUtilityList,
getUtilityStats,
deleteUtility,
deleteUtilities,
} from './actions';
// 테이블 컬럼 정의
const tableColumns = [
{ key: 'no', label: '번호', className: 'w-[50px] text-center' },
{ key: 'utilityNumber', label: '공과번호', className: 'w-[120px]' },
{ key: 'partnerName', label: '거래처', className: 'w-[100px]' },
{ key: 'siteName', label: '현장명', className: 'min-w-[120px]' },
{ key: 'constructionPM', label: '공사PM', className: 'w-[80px]' },
{ key: 'utilityType', label: '공과', className: 'w-[80px]' },
{ key: 'scheduledDate', label: '공과예정일시', className: 'w-[110px]' },
{ key: 'amount', label: '금액', className: 'w-[100px] text-right' },
{ key: 'workTeamLeader', label: '작업반장', className: 'w-[80px]' },
{ key: 'constructionStartDate', label: '시공투입일', className: 'w-[100px]' },
{ key: 'status', label: '상태', className: 'w-[80px] text-center' },
{ key: 'actions', label: '작업', className: 'w-[100px] text-center' },
];
// 날짜 포맷
function formatDate(dateStr: string | null): string {
if (!dateStr) return '-';
return dateStr.split('T')[0];
}
// 금액 포맷
function formatAmount(amount: number): string {
return amount.toLocaleString('ko-KR') + '원';
}
interface UtilityManagementListClientProps {
initialData?: Utility[];
initialStats?: UtilityStats;
}
export default function UtilityManagementListClient({
initialData = [],
initialStats,
}: UtilityManagementListClientProps) {
// ===== 외부 상태 (UniversalListPage 외부에서 관리) =====
const [activeStatTab, setActiveStatTab] = useState<'all' | 'waiting' | 'complete'>('all');
const [startDate, setStartDate] = useState<string>('');
const [endDate, setEndDate] = useState<string>('');
const [stats, setStats] = useState<UtilityStats | null>(initialStats || null);
const [searchQuery, setSearchQuery] = useState('');
// Stats 로드
useEffect(() => {
if (!initialStats) {
getUtilityStats().then((result) => {
if (result.success && result.data) {
setStats(result.data);
}
});
}
}, [initialStats]);
// ===== UniversalListPage Config =====
const config: UniversalListConfig<Utility> = useMemo(
() => ({
// 페이지 기본 정보
title: '공과관리',
description: '공과 목록을 관리합니다',
icon: Zap,
basePath: '/construction/project/utility-management',
// ID 추출
idField: 'id',
// API 액션
actions: {
getList: async () => {
const result = await getUtilityList({
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 deleteUtility(id);
return { success: result.success, error: result.error };
},
deleteBulk: async (ids: string[]) => {
const result = await deleteUtilities(ids);
return { success: result.success, error: result.error };
},
},
// 테이블 컬럼
columns: tableColumns,
// 클라이언트 사이드 필터링
clientSideFiltering: true,
itemsPerPage: 20,
// 검색 필터
searchPlaceholder: '공과번호, 거래처, 현장명, 공사PM 검색',
searchFilter: (item, searchValue) => {
const search = searchValue.toLowerCase();
return (
item.utilityNumber.toLowerCase().includes(search) ||
item.partnerName.toLowerCase().includes(search) ||
item.siteName.toLowerCase().includes(search) ||
item.constructionPM.toLowerCase().includes(search)
);
},
// 필터 설정
filterConfig: [
{
key: 'partners',
label: '거래처',
type: 'multi',
options: MOCK_PARTNERS,
},
{
key: 'sites',
label: '현장명',
type: 'multi',
options: MOCK_SITES,
},
{
key: 'constructionPMs',
label: '공사PM',
type: 'multi',
options: MOCK_CONSTRUCTION_PM,
},
{
key: 'utilityType',
label: '공과',
type: 'single',
options: MOCK_UTILITY_TYPES,
},
{
key: 'workTeamLeaders',
label: '작업반장',
type: 'multi',
options: MOCK_WORK_TEAM_LEADERS,
},
{
key: 'status',
label: '상태',
type: 'single',
options: UTILITY_STATUS_OPTIONS.filter((o) => o.value !== 'all'),
},
{
key: 'sortBy',
label: '정렬',
type: 'single',
options: UTILITY_SORT_OPTIONS,
},
],
initialFilters: {
partners: [],
sites: [],
constructionPMs: [],
utilityType: 'all',
workTeamLeaders: [],
status: 'all',
sortBy: 'latest',
},
filterTitle: '공과 필터',
// 커스텀 필터 함수
customFilterFn: (items, filterValues) => {
if (!items || items.length === 0) return items;
return items.filter((item) => {
// Stats 탭 필터
if (activeStatTab === 'waiting' && item.status !== 'scheduled' && item.status !== 'issued') return false;
if (activeStatTab === 'complete' && item.status !== 'completed') return false;
// 거래처 필터 (다중선택)
const partnerFilters = filterValues.partners as string[];
if (partnerFilters?.length > 0 && !partnerFilters.includes(item.partnerId)) return false;
// 현장명 필터 (다중선택)
const siteFilters = filterValues.sites as string[];
if (siteFilters?.length > 0 && !siteFilters.includes(item.siteId)) return false;
// 공사PM 필터 (다중선택)
const constructionPMFilters = filterValues.constructionPMs as string[];
if (constructionPMFilters?.length > 0 && !constructionPMFilters.includes(item.constructionPMId)) return false;
// 공과 유형 필터 (단일선택)
const utilityTypeFilter = filterValues.utilityType as string;
if (utilityTypeFilter && utilityTypeFilter !== 'all') {
const matchingType = MOCK_UTILITY_TYPES.find((t) => t.value === utilityTypeFilter);
if (!matchingType || item.utilityType !== matchingType.label) {
return false;
}
}
// 작업반장 필터 (다중선택)
const workTeamFilters = filterValues.workTeamLeaders as string[];
if (workTeamFilters?.length > 0 && !workTeamFilters.includes(item.workTeamLeaderId)) 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) || 'latest';
switch (sortBy) {
case 'latest':
sorted.sort((a, b) => new Date(b.scheduledDate).getTime() - new Date(a.scheduledDate).getTime());
break;
case 'oldest':
sorted.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
break;
case 'partnerNameDesc':
sorted.sort((a, b) => b.partnerName.localeCompare(a.partnerName, 'ko'));
break;
case 'issuedDate':
sorted.sort((a, b) => new Date(b.scheduledDate).getTime() - new Date(a.scheduledDate).getTime());
break;
case 'completedDate':
sorted.sort((a, b) => {
if (a.status === 'completed' && b.status !== 'completed') return -1;
if (a.status !== 'completed' && b.status === 'completed') return 1;
return 0;
});
break;
}
return sorted;
},
// 검색창 (공통 컴포넌트에서 자동 생성)
hideSearch: true,
searchValue: searchQuery,
onSearchChange: setSearchQuery,
// 공통 헤더 옵션
dateRangeSelector: {
enabled: true,
startDate,
endDate,
onStartDateChange: setStartDate,
onEndDateChange: setEndDate,
},
// Stats 카드
computeStats: (): StatCard[] => [
{
label: '전체 계약',
value: stats?.totalContract ?? 0,
icon: FileText,
iconColor: 'text-blue-600',
onClick: () => setActiveStatTab('all'),
isActive: activeStatTab === 'all',
},
{
label: '계약 대기',
value: stats?.contractWaiting ?? 0,
icon: Clock,
iconColor: 'text-yellow-600',
onClick: () => setActiveStatTab('waiting'),
isActive: activeStatTab === 'waiting',
},
{
label: '계약 완료',
value: stats?.contractComplete ?? 0,
icon: CheckCircle,
iconColor: 'text-green-600',
onClick: () => setActiveStatTab('complete'),
isActive: activeStatTab === 'complete',
},
],
// 삭제 확인 메시지 (AlertDialog 대체)
deleteConfirmMessage: {
title: '공과 삭제',
description: '선택한 공과를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.',
},
// 테이블 행 렌더링
renderTableRow: (
item: Utility,
index: number,
globalIndex: number,
handlers: SelectionHandlers & RowClickHandlers<Utility>
) => (
<TableRow
key={item.id}
className="hover:bg-muted/50"
>
<TableCell className="text-center">
<Checkbox checked={handlers.isSelected} onCheckedChange={handlers.onToggle} />
</TableCell>
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
<TableCell>{item.utilityNumber}</TableCell>
<TableCell>{item.partnerName}</TableCell>
<TableCell>{item.siteName}</TableCell>
<TableCell>{item.constructionPM}</TableCell>
<TableCell>{item.utilityType}</TableCell>
<TableCell>{formatDate(item.scheduledDate)}</TableCell>
<TableCell className="text-right">{formatAmount(item.amount)}</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 ${UTILITY_STATUS_STYLES[item.status]}`}>
{UTILITY_STATUS_LABELS[item.status]}
</span>
</TableCell>
<TableCell className="text-center">
{handlers.isSelected && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={() => handlers.onDelete?.(item)}
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</TableCell>
</TableRow>
),
// 모바일 카드 렌더링
renderMobileCard: (
item: Utility,
index: number,
globalIndex: number,
handlers: SelectionHandlers & RowClickHandlers<Utility>
) => (
<MobileCard
key={item.id}
title={item.siteName}
subtitle={item.utilityNumber}
badge={UTILITY_STATUS_LABELS[item.status]}
badgeVariant="secondary"
isSelected={handlers.isSelected}
onToggle={handlers.onToggle}
details={[
{ label: '거래처', value: item.partnerName },
{ label: '공사PM', value: item.constructionPM },
{ label: '금액', value: formatAmount(item.amount) },
]}
/>
),
}),
[startDate, endDate, activeStatTab, stats, searchQuery]
);
return <UniversalListPage config={config} initialData={initialData} onSearchChange={setSearchQuery} />;
}