- 등록(?mode=new), 상세(?mode=view), 수정(?mode=edit) URL 패턴 일괄 적용
- 중복 패턴 제거: /edit?mode=edit → ?mode=edit (16개 파일)
- 제목 일관성: {기능} 등록/상세/수정 패턴 적용
- 검수 체크리스트 문서 추가 (79개 페이지)
- UniversalListPage, IntegratedDetailTemplate 공통 컴포넌트 개선
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
396 lines
14 KiB
TypeScript
396 lines
14 KiB
TypeScript
'use client';
|
|
|
|
/**
|
|
* 견적관리 리스트 - UniversalListPage 마이그레이션
|
|
*
|
|
* 기존 IntegratedListTemplateV2 → UniversalListPage config 기반으로 변환
|
|
* - 클라이언트 사이드 필터링 (검색, 필터, 정렬)
|
|
* - Stats 카드 클릭 필터링 (activeStatTab)
|
|
* - DateRangeSelector (headerActions → dateRangeSelector config)
|
|
* - filterConfig (multi: 거래처, 견적자 / single: 상태, 정렬)
|
|
*/
|
|
|
|
import { useState, useMemo, useCallback, useEffect } from 'react';
|
|
import { useRouter } from 'next/navigation';
|
|
import { FileText, FileTextIcon, Clock, FileCheck, Pencil } 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 { Estimate, EstimateStats } from './types';
|
|
import {
|
|
ESTIMATE_STATUS_OPTIONS,
|
|
ESTIMATE_SORT_OPTIONS,
|
|
STATUS_STYLES,
|
|
STATUS_LABELS,
|
|
} from './types';
|
|
import { getEstimateList, getEstimateStats, deleteEstimate, deleteEstimates, getClientOptions, getUserOptions } from './actions';
|
|
import type { ClientOption, UserOption } from './actions';
|
|
|
|
// 테이블 컬럼 정의
|
|
const tableColumns = [
|
|
{ key: 'no', label: '번호', className: 'w-[60px] text-center' },
|
|
{ key: 'estimateCode', label: '견적번호', className: 'w-[100px]' },
|
|
{ key: 'partnerName', label: '거래처', className: 'w-[120px]' },
|
|
{ key: 'projectName', label: '현장명', className: 'min-w-[150px]' },
|
|
{ key: 'estimatorName', label: '견적자', className: 'w-[80px] text-center' },
|
|
{ key: 'itemCount', label: '총 개소', className: 'w-[80px] text-center' },
|
|
{ key: 'estimateAmount', label: '견적금액', className: 'w-[120px] text-right' },
|
|
{ key: 'completedDate', label: '견적완료일', className: 'w-[110px] text-center' },
|
|
{ key: 'bidDate', label: '입찰일', className: 'w-[110px] text-center' },
|
|
{ key: 'status', label: '상태', className: 'w-[100px] text-center' },
|
|
{ key: 'actions', label: '작업', className: 'w-[80px] text-center' },
|
|
];
|
|
|
|
// 금액 포맷팅
|
|
function formatAmount(amount: number): string {
|
|
return new Intl.NumberFormat('ko-KR').format(amount);
|
|
}
|
|
|
|
interface EstimateListClientProps {
|
|
initialData?: Estimate[];
|
|
initialStats?: EstimateStats;
|
|
}
|
|
|
|
export default function EstimateListClient({ initialData = [], initialStats }: EstimateListClientProps) {
|
|
const router = useRouter();
|
|
|
|
// ===== 외부 상태 (UniversalListPage 외부에서 관리) =====
|
|
// Stats 카드 클릭 필터용
|
|
const [activeStatTab, setActiveStatTab] = useState<'all' | 'pending' | 'completed'>('all');
|
|
// 날짜 범위
|
|
const [startDate, setStartDate] = useState('');
|
|
const [endDate, setEndDate] = useState('');
|
|
// Stats 데이터
|
|
const [stats, setStats] = useState<EstimateStats | null>(initialStats || null);
|
|
// 필터 옵션 데이터
|
|
const [partnerOptions, setPartnerOptions] = useState<ClientOption[]>([]);
|
|
const [estimatorOptions, setEstimatorOptions] = useState<UserOption[]>([]);
|
|
|
|
// Stats 로드
|
|
useEffect(() => {
|
|
if (!initialStats) {
|
|
getEstimateStats().then((result) => {
|
|
if (result.success && result.data) {
|
|
setStats(result.data);
|
|
}
|
|
});
|
|
}
|
|
}, [initialStats]);
|
|
|
|
// 거래처/견적자 옵션 로드
|
|
useEffect(() => {
|
|
// 거래처 옵션 로드
|
|
getClientOptions().then((result) => {
|
|
if (result.success && result.data) {
|
|
setPartnerOptions(result.data);
|
|
}
|
|
});
|
|
// 견적자(사용자) 옵션 로드
|
|
getUserOptions().then((result) => {
|
|
if (result.success && result.data) {
|
|
setEstimatorOptions(result.data);
|
|
}
|
|
});
|
|
}, []);
|
|
|
|
// ===== 핸들러 =====
|
|
const handleRowClick = useCallback(
|
|
(item: Estimate) => {
|
|
router.push(`/ko/construction/project/bidding/estimates/${item.id}?mode=view`);
|
|
},
|
|
[router]
|
|
);
|
|
|
|
const handleEdit = useCallback(
|
|
(item: Estimate) => {
|
|
router.push(`/ko/construction/project/bidding/estimates/${item.id}?mode=edit`);
|
|
},
|
|
[router]
|
|
);
|
|
|
|
// ===== UniversalListPage Config =====
|
|
const config: UniversalListConfig<Estimate> = useMemo(
|
|
() => ({
|
|
// 페이지 기본 정보
|
|
title: '견적관리',
|
|
description: '견적을 관리합니다',
|
|
icon: FileText,
|
|
basePath: '/construction/project/bidding/estimates',
|
|
|
|
// ID 추출
|
|
idField: 'id',
|
|
|
|
// API 액션
|
|
actions: {
|
|
getList: async () => {
|
|
const result = await getEstimateList({
|
|
size: 100,
|
|
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 deleteEstimate(id);
|
|
return { success: result.success, error: result.error };
|
|
},
|
|
deleteBulk: async (ids: string[]) => {
|
|
const result = await deleteEstimates(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.estimateCode.toLowerCase().includes(search) ||
|
|
item.partnerName.toLowerCase().includes(search)
|
|
);
|
|
},
|
|
|
|
// 필터 설정 (PC: 인라인, 모바일: 바텀시트)
|
|
filterConfig: [
|
|
{
|
|
key: 'partner',
|
|
label: '거래처',
|
|
type: 'multi',
|
|
options: partnerOptions,
|
|
},
|
|
{
|
|
key: 'estimator',
|
|
label: '견적자',
|
|
type: 'multi',
|
|
options: estimatorOptions,
|
|
},
|
|
{
|
|
key: 'status',
|
|
label: '상태',
|
|
type: 'single',
|
|
options: ESTIMATE_STATUS_OPTIONS.filter((o) => o.value !== 'all'),
|
|
},
|
|
{
|
|
key: 'sortBy',
|
|
label: '정렬',
|
|
type: 'single',
|
|
options: ESTIMATE_SORT_OPTIONS,
|
|
},
|
|
],
|
|
initialFilters: {
|
|
partner: [],
|
|
estimator: [],
|
|
status: 'all',
|
|
sortBy: 'latest',
|
|
},
|
|
filterTitle: '견적 필터',
|
|
|
|
// 커스텀 필터 함수 (activeStatTab + filterValues 기반)
|
|
customFilterFn: (items, filterValues) => {
|
|
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 estimatorFilters = filterValues.estimator as string[];
|
|
if (estimatorFilters?.length > 0 && !estimatorFilters.includes(item.estimatorId)) 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) => {
|
|
const dateA = a.completedDate ? new Date(a.completedDate).getTime() : new Date(a.createdAt).getTime();
|
|
const dateB = b.completedDate ? new Date(b.completedDate).getTime() : new Date(b.createdAt).getTime();
|
|
return dateB - dateA;
|
|
});
|
|
break;
|
|
case 'oldest':
|
|
sorted.sort((a, b) => {
|
|
const dateA = a.completedDate ? new Date(a.completedDate).getTime() : new Date(a.createdAt).getTime();
|
|
const dateB = b.completedDate ? new Date(b.completedDate).getTime() : new Date(b.createdAt).getTime();
|
|
return dateA - dateB;
|
|
});
|
|
break;
|
|
case 'bidDateDesc':
|
|
sorted.sort((a, b) => {
|
|
if (!a.bidDate) return 1;
|
|
if (!b.bidDate) return -1;
|
|
return new Date(b.bidDate).getTime() - new Date(a.bidDate).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;
|
|
}
|
|
return sorted;
|
|
},
|
|
|
|
// 공통 헤더 옵션: 날짜 선택기
|
|
dateRangeSelector: {
|
|
enabled: true,
|
|
startDate,
|
|
endDate,
|
|
onStartDateChange: setStartDate,
|
|
onEndDateChange: setEndDate,
|
|
},
|
|
|
|
// Stats 카드 (동적 계산 with onClick)
|
|
computeStats: (): StatCard[] => [
|
|
{
|
|
label: '전체 견적',
|
|
value: stats?.total ?? 0,
|
|
icon: FileTextIcon,
|
|
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: FileCheck,
|
|
iconColor: 'text-green-600',
|
|
onClick: () => setActiveStatTab('completed'),
|
|
isActive: activeStatTab === 'completed',
|
|
},
|
|
],
|
|
|
|
// 삭제 확인 메시지
|
|
deleteConfirmMessage: {
|
|
title: '견적 삭제',
|
|
description: '선택한 견적을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.',
|
|
},
|
|
|
|
// 테이블 행 렌더링
|
|
renderTableRow: (
|
|
item: Estimate,
|
|
index: number,
|
|
globalIndex: number,
|
|
handlers: SelectionHandlers & RowClickHandlers<Estimate>
|
|
) => (
|
|
<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.estimateCode}</TableCell>
|
|
<TableCell>{item.partnerName}</TableCell>
|
|
<TableCell>{item.projectName}</TableCell>
|
|
<TableCell className="text-center">{item.estimatorName}</TableCell>
|
|
<TableCell className="text-center">{item.itemCount}</TableCell>
|
|
<TableCell className="text-right">{formatAmount(item.estimateAmount)}</TableCell>
|
|
<TableCell className="text-center">{item.completedDate || '-'}</TableCell>
|
|
<TableCell className="text-center">{item.bidDate || '-'}</TableCell>
|
|
<TableCell className="text-center">
|
|
<span className={STATUS_STYLES[item.status]}>{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>
|
|
</div>
|
|
)}
|
|
</TableCell>
|
|
</TableRow>
|
|
),
|
|
|
|
// 모바일 카드 렌더링
|
|
renderMobileCard: (
|
|
item: Estimate,
|
|
index: number,
|
|
globalIndex: number,
|
|
handlers: SelectionHandlers & RowClickHandlers<Estimate>
|
|
) => (
|
|
<MobileCard
|
|
key={item.id}
|
|
title={item.projectName}
|
|
subtitle={item.estimateCode}
|
|
badge={STATUS_LABELS[item.status]}
|
|
badgeVariant="secondary"
|
|
isSelected={handlers.isSelected}
|
|
onToggle={handlers.onToggle}
|
|
onClick={() => handleRowClick(item)}
|
|
details={[
|
|
{ label: '거래처', value: item.partnerName },
|
|
{ label: '견적자', value: item.estimatorName },
|
|
{ label: '견적금액', value: `${formatAmount(item.estimateAmount)}원` },
|
|
{ label: '입찰일', value: item.bidDate || '-' },
|
|
]}
|
|
/>
|
|
),
|
|
}),
|
|
[startDate, endDate, activeStatTab, stats, partnerOptions, estimatorOptions, handleRowClick, handleEdit]
|
|
);
|
|
|
|
return <UniversalListPage config={config} initialData={initialData} />;
|
|
} |