'use client'; 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 { MultiSelectOption } from '@/components/ui/multi-select-combobox'; import { IntegratedListTemplateV2, TableColumn, StatCard, FilterFieldConfig, FilterValues } from '@/components/templates/IntegratedListTemplateV2'; import { MobileCard } from '@/components/molecules/MobileCard'; import { DateRangeSelector } from '@/components/molecules/DateRangeSelector'; import { toast } from 'sonner'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog'; import type { Estimate, EstimateStats } from './types'; import { ESTIMATE_STATUS_OPTIONS, ESTIMATE_SORT_OPTIONS, STATUS_STYLES, STATUS_LABELS, } from './types'; import { getEstimateList, getEstimateStats, deleteEstimate, deleteEstimates } from './actions'; // 테이블 컬럼 정의 const tableColumns: TableColumn[] = [ { 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' }, ]; // 목업 거래처 목록 (다중선택용 - 빈 배열 = 전체) const MOCK_PARTNERS: MultiSelectOption[] = [ { value: '1', label: '회사명' }, { value: '2', label: '야사 대림아파트' }, { value: '3', label: '여의 현장아파트' }, ]; // 목업 견적자 목록 (다중선택용 - 빈 배열 = 전체) const MOCK_ESTIMATORS: MultiSelectOption[] = [ { value: 'hong', label: '홍길동' }, { value: 'kim', label: '김철수' }, { value: 'lee', label: '이영희' }, ]; // 금액 포맷팅 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(); // 상태 const [estimates, setEstimates] = useState(initialData); const [stats, setStats] = useState(initialStats || null); const [searchValue, setSearchValue] = useState(''); const [partnerFilters, setPartnerFilters] = useState([]); const [estimatorFilters, setEstimatorFilters] = useState([]); const [statusFilter, setStatusFilter] = useState('all'); const [sortBy, setSortBy] = useState('latest'); const [startDate, setStartDate] = useState(''); const [endDate, setEndDate] = useState(''); const [selectedItems, setSelectedItems] = useState>(new Set()); const [currentPage, setCurrentPage] = useState(1); const [isLoading, setIsLoading] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteTargetId, setDeleteTargetId] = useState(null); const [bulkDeleteDialogOpen, setBulkDeleteDialogOpen] = useState(false); const [activeStatTab, setActiveStatTab] = useState<'all' | 'pending' | 'completed'>('all'); const itemsPerPage = 20; // 데이터 로드 const loadData = useCallback(async () => { setIsLoading(true); try { const [listResult, statsResult] = await Promise.all([ getEstimateList({ size: 100, // API 최대값 100 startDate: startDate || undefined, endDate: endDate || undefined, }), getEstimateStats(), ]); if (listResult.success && listResult.data) { setEstimates(listResult.data.items); } if (statsResult.success && statsResult.data) { setStats(statsResult.data); } } catch { toast.error('데이터 로드에 실패했습니다.'); } finally { setIsLoading(false); } }, [startDate, endDate]); // 초기 데이터가 없으면 로드 useEffect(() => { if (initialData.length === 0) { loadData(); } }, [initialData.length, loadData]); // 필터링된 데이터 const filteredEstimates = useMemo(() => { return estimates.filter((estimate) => { // 상태 탭 필터 if (activeStatTab === 'pending' && estimate.status !== 'pending') return false; if (activeStatTab === 'completed' && estimate.status !== 'completed') return false; // 거래처 필터 (다중선택 - 빈 배열 = 전체) if (partnerFilters.length > 0) { if (!partnerFilters.includes(estimate.partnerId)) return false; } // 견적자 필터 (다중선택 - 빈 배열 = 전체) if (estimatorFilters.length > 0) { if (!estimatorFilters.includes(estimate.estimatorId)) return false; } // 상태 필터 if (statusFilter !== 'all' && estimate.status !== statusFilter) return false; // 검색 필터 if (searchValue) { const search = searchValue.toLowerCase(); return ( estimate.projectName.toLowerCase().includes(search) || estimate.estimateCode.toLowerCase().includes(search) || estimate.partnerName.toLowerCase().includes(search) ); } return true; }); }, [estimates, activeStatTab, partnerFilters, estimatorFilters, statusFilter, searchValue]); // 정렬 const sortedEstimates = useMemo(() => { const sorted = [...filteredEstimates]; 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; }, [filteredEstimates, sortBy]); // 페이지네이션 const totalPages = Math.ceil(sortedEstimates.length / itemsPerPage); const paginatedData = useMemo(() => { const start = (currentPage - 1) * itemsPerPage; return sortedEstimates.slice(start, start + itemsPerPage); }, [sortedEstimates, currentPage, itemsPerPage]); // 핸들러 const handleSearchChange = useCallback((value: string) => { setSearchValue(value); setCurrentPage(1); }, []); const handleToggleSelection = useCallback((id: string) => { setSelectedItems((prev) => { const newSet = new Set(prev); if (newSet.has(id)) { newSet.delete(id); } else { newSet.add(id); } return newSet; }); }, []); const handleToggleSelectAll = useCallback(() => { if (selectedItems.size === paginatedData.length) { setSelectedItems(new Set()); } else { setSelectedItems(new Set(paginatedData.map((e) => e.id))); } }, [selectedItems.size, paginatedData]); const handleRowClick = useCallback( (estimate: Estimate) => { router.push(`/ko/construction/project/bidding/estimates/${estimate.id}`); }, [router] ); const handleEdit = useCallback( (e: React.MouseEvent, estimateId: string) => { e.stopPropagation(); router.push(`/ko/construction/project/bidding/estimates/${estimateId}/edit`); }, [router] ); const handleDeleteClick = useCallback((e: React.MouseEvent, estimateId: string) => { e.stopPropagation(); setDeleteTargetId(estimateId); setDeleteDialogOpen(true); }, []); const handleDeleteConfirm = useCallback(async () => { if (!deleteTargetId) return; setIsLoading(true); try { const result = await deleteEstimate(deleteTargetId); if (result.success) { toast.success('견적이 삭제되었습니다.'); setEstimates((prev) => prev.filter((e) => e.id !== deleteTargetId)); setSelectedItems((prev) => { const newSet = new Set(prev); newSet.delete(deleteTargetId); return newSet; }); } else { toast.error(result.error || '삭제에 실패했습니다.'); } } catch { toast.error('삭제 중 오류가 발생했습니다.'); } finally { setIsLoading(false); setDeleteDialogOpen(false); setDeleteTargetId(null); } }, [deleteTargetId]); const handleBulkDeleteClick = useCallback(() => { if (selectedItems.size === 0) { toast.warning('삭제할 항목을 선택해주세요.'); return; } setBulkDeleteDialogOpen(true); }, [selectedItems.size]); const handleBulkDeleteConfirm = useCallback(async () => { if (selectedItems.size === 0) return; setIsLoading(true); try { const ids = Array.from(selectedItems); const result = await deleteEstimates(ids); if (result.success) { toast.success(`${result.deletedCount}개 항목이 삭제되었습니다.`); await loadData(); setSelectedItems(new Set()); } else { toast.error(result.error || '일괄 삭제에 실패했습니다.'); } } catch { toast.error('일괄 삭제 중 오류가 발생했습니다.'); } finally { setIsLoading(false); setBulkDeleteDialogOpen(false); } }, [selectedItems, loadData]); // ===== filterConfig 기반 통합 필터 시스템 ===== const filterConfig: FilterFieldConfig[] = useMemo(() => [ { key: 'partner', label: '거래처', type: 'multi', options: MOCK_PARTNERS.map(o => ({ value: o.value, label: o.label, })), }, { key: 'estimator', label: '견적자', type: 'multi', options: MOCK_ESTIMATORS.map(o => ({ value: o.value, label: o.label, })), }, { key: 'status', label: '상태', type: 'single', options: ESTIMATE_STATUS_OPTIONS.filter(o => o.value !== 'all').map(o => ({ value: o.value, label: o.label, })), allOptionLabel: '전체', }, { key: 'sortBy', label: '정렬', type: 'single', options: ESTIMATE_SORT_OPTIONS.map(o => ({ value: o.value, label: o.label, })), allOptionLabel: '최신순', }, ], []); const filterValues: FilterValues = useMemo(() => ({ partner: partnerFilters, estimator: estimatorFilters, status: statusFilter, sortBy: sortBy, }), [partnerFilters, estimatorFilters, statusFilter, sortBy]); const handleFilterChange = useCallback((key: string, value: string | string[]) => { switch (key) { case 'partner': setPartnerFilters(value as string[]); break; case 'estimator': setEstimatorFilters(value as string[]); break; case 'status': setStatusFilter(value as string); break; case 'sortBy': setSortBy(value as string); break; } setCurrentPage(1); }, []); const handleFilterReset = useCallback(() => { setPartnerFilters([]); setEstimatorFilters([]); setStatusFilter('all'); setSortBy('latest'); setCurrentPage(1); }, []); // 테이블 행 렌더링 const renderTableRow = useCallback( (estimate: Estimate, index: number, globalIndex: number) => { const isSelected = selectedItems.has(estimate.id); return ( handleRowClick(estimate)} > e.stopPropagation()}> handleToggleSelection(estimate.id)} /> {globalIndex} {estimate.estimateCode} {estimate.partnerName} {estimate.projectName} {estimate.estimatorName} {estimate.itemCount} {formatAmount(estimate.estimateAmount)} {estimate.completedDate || '-'} {estimate.bidDate || '-'} {STATUS_LABELS[estimate.status]} {isSelected && (
)}
); }, [selectedItems, handleToggleSelection, handleRowClick, handleEdit] ); // 모바일 카드 렌더링 const renderMobileCard = useCallback( (estimate: Estimate, index: number, globalIndex: number, isSelected: boolean, onToggle: () => void) => { return ( handleRowClick(estimate)} details={[ { label: '거래처', value: estimate.partnerName }, { label: '견적자', value: estimate.estimatorName }, { label: '견적금액', value: `${formatAmount(estimate.estimateAmount)}원` }, { label: '입찰일', value: estimate.bidDate || '-' }, ]} /> ); }, [handleRowClick] ); // 헤더 액션 (날짜 필터만 - 견적등록은 현장설명회 참석완료 시 자동 등록) const headerActions = ( ); // Stats 카드 데이터 (StatCards 컴포넌트용) const statsCardsData: 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', }, ]; return ( <> item.id} renderTableRow={renderTableRow} renderMobileCard={renderMobileCard} selectedItems={selectedItems} onToggleSelection={handleToggleSelection} onToggleSelectAll={handleToggleSelectAll} pagination={{ currentPage, totalPages, totalItems: sortedEstimates.length, itemsPerPage, onPageChange: setCurrentPage, }} /> {/* 단일 삭제 다이얼로그 */} 견적 삭제 선택한 견적을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다. 취소 삭제 {/* 일괄 삭제 다이얼로그 */} 견적 일괄 삭제 선택한 {selectedItems.size}개 견적을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다. 취소 삭제 ); }