'use client'; /** * 견적관리 클라이언트 컴포넌트 * * API 연동된 견적 관리 페이지 * - PageHeader, StatCards, SearchFilter, 체크박스 * - 데스크톱: TabsList + DataTable * - 모바일: 커스텀 버튼 탭 + 카드 리스트 * - 완전한 반응형 지원 */ import { useState, useRef, useEffect, useTransition, useCallback } from 'react'; import { useRouter } from 'next/navigation'; import { FileText, Edit, Trash2, CheckCircle, History, Calculator, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { getQuoteStatusBadge } from '@/components/atoms/BadgeSm'; import { IntegratedListTemplateV2, TabOption, TableColumn, } from '@/components/templates/IntegratedListTemplateV2'; import { toast } from 'sonner'; import { StandardDialog } from '@/components/molecules/StandardDialog'; import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell, } from '@/components/ui/table'; import { Separator } from '@/components/ui/separator'; import { Checkbox } from '@/components/ui/checkbox'; import { formatAmount, formatAmountManwon } from '@/utils/formatAmount'; import { ListMobileCard, InfoField } from '@/components/organisms/ListMobileCard'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog'; import type { Quote, QuoteFilterType } from './types'; import { PRODUCT_CATEGORY_LABELS } from './types'; import { getQuotes, deleteQuote, bulkDeleteQuotes } from './actions'; import type { PaginationMeta } from './actions'; // ===== Props 타입 ===== interface QuoteManagementClientProps { initialData: Quote[]; initialPagination: PaginationMeta; } export function QuoteManagementClient({ initialData, initialPagination, }: QuoteManagementClientProps) { const router = useRouter(); const [isPending, startTransition] = useTransition(); // 상태 const [quotes, setQuotes] = useState(initialData); const [pagination, setPagination] = useState(initialPagination); const [searchTerm, setSearchTerm] = useState(''); const [filterType, setFilterType] = useState('all'); const [isCalculationDialogOpen, setIsCalculationDialogOpen] = useState(false); const [calculationQuote, setCalculationQuote] = useState(null); const [selectedItems, setSelectedItems] = useState>(new Set()); const [currentPage, setCurrentPage] = useState(1); const itemsPerPage = 20; // 삭제 확인 다이얼로그 state const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const [deleteTargetId, setDeleteTargetId] = useState(null); // 일괄 삭제 확인 다이얼로그 state const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false); // 모바일 인피니티 스크롤 state const [mobileDisplayCount, setMobileDisplayCount] = useState(20); const sentinelRef = useRef(null); // API에서 데이터 다시 불러오기 const refreshData = useCallback(async () => { startTransition(async () => { const result = await getQuotes({ page: currentPage, perPage: itemsPerPage, search: searchTerm || undefined, }); if (result.success) { setQuotes(result.data); setPagination(result.pagination); } else { toast.error(result.error || '데이터 조회에 실패했습니다.'); } }); }, [currentPage, searchTerm]); // 검색 또는 페이지 변경 시 데이터 새로고침 useEffect(() => { if (currentPage > 1 || searchTerm) { refreshData(); } }, [currentPage, refreshData]); // 필터링된 데이터 (클라이언트 사이드 필터링) const filteredQuotes = quotes.filter((quote) => { // 탭 필터 if (filterType === 'initial') { return quote.currentRevision === 0 && !quote.isFinal && quote.status !== 'converted'; } else if (filterType === 'revising') { return quote.currentRevision > 0 && !quote.isFinal && quote.status !== 'converted'; } else if (filterType === 'final') { return quote.isFinal && quote.status !== 'converted'; } else if (filterType === 'converted') { return quote.status === 'converted'; } return true; }).sort((a, b) => { return new Date(b.registrationDate).getTime() - new Date(a.registrationDate).getTime(); }); // 페이지네이션 const totalPages = Math.ceil(filteredQuotes.length / itemsPerPage); const paginatedQuotes = filteredQuotes.slice( (currentPage - 1) * itemsPerPage, currentPage * itemsPerPage ); // 모바일용 인피니티 스크롤 데이터 const mobileQuotes = filteredQuotes.slice(0, mobileDisplayCount); // Intersection Observer를 이용한 인피니티 스크롤 useEffect(() => { if (typeof window === 'undefined') return; if (window.innerWidth >= 1280) return; const observer = new IntersectionObserver( (entries) => { if (entries[0].isIntersecting && mobileDisplayCount < filteredQuotes.length) { setMobileDisplayCount((prev) => Math.min(prev + 20, filteredQuotes.length)); } }, { threshold: 0.1, rootMargin: '100px' } ); if (sentinelRef.current) { observer.observe(sentinelRef.current); } return () => observer.disconnect(); }, [mobileDisplayCount, filteredQuotes.length]); // 탭이나 검색어 변경 시 모바일 표시 개수 초기화 useEffect(() => { setMobileDisplayCount(20); }, [searchTerm, filterType]); // 통계 계산 const now = new Date(); const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1); const startOfWeek = new Date(now); startOfWeek.setDate(now.getDate() - now.getDay()); const thisMonthQuotes = quotes.filter( (q) => new Date(q.registrationDate) >= startOfMonth ); const thisMonthAmount = thisMonthQuotes.reduce((sum, q) => sum + q.totalAmount, 0); const ongoingQuotes = quotes.filter((q) => q.status === 'draft'); const ongoingAmount = ongoingQuotes.reduce((sum, q) => sum + q.totalAmount, 0); const thisWeekQuotes = quotes.filter( (q) => new Date(q.registrationDate) >= startOfWeek ); const thisMonthConvertedCount = thisMonthQuotes.filter( (q) => q.status === 'converted' ).length; const thisMonthConversionRate = thisMonthQuotes.length > 0 ? ((thisMonthConvertedCount / thisMonthQuotes.length) * 100).toFixed(1) : '0.0'; const stats = [ { label: '이번 달 견적 금액', value: formatAmountManwon(thisMonthAmount), icon: Calculator, iconColor: 'text-blue-600', }, { label: '진행중 견적 금액', value: formatAmountManwon(ongoingAmount), icon: FileText, iconColor: 'text-orange-600', }, { label: '이번 주 신규 견적', value: `${thisWeekQuotes.length}건`, icon: Edit, iconColor: 'text-green-600', }, { label: '이번 달 수주 전환율', value: `${thisMonthConversionRate}%`, icon: CheckCircle, iconColor: 'text-purple-600', }, ]; // 핸들러 const handleView = (quote: Quote) => { router.push(`/sales/quote-management/${quote.id}`); }; const handleEdit = (quote: Quote) => { router.push(`/sales/quote-management/${quote.id}/edit`); }; const handleDelete = (quoteId: string) => { setDeleteTargetId(quoteId); setIsDeleteDialogOpen(true); }; // 삭제 확인 후 실행 const handleConfirmDelete = async () => { if (!deleteTargetId) return; startTransition(async () => { const result = await deleteQuote(deleteTargetId); if (result.success) { const quote = quotes.find((q) => q.id === deleteTargetId); setQuotes(quotes.filter((q) => q.id !== deleteTargetId)); toast.success(`견적이 삭제되었습니다${quote ? `: ${quote.quoteNumber}` : ''}`); } else { toast.error(result.error || '삭제에 실패했습니다.'); } setIsDeleteDialogOpen(false); setDeleteTargetId(null); }); }; const handleViewHistory = (quote: Quote) => { toast.info(`수정 이력: ${quote.quoteNumber} (${quote.currentRevision}차 수정)`); }; const handleViewCalculation = (quote: Quote) => { setCalculationQuote(quote); setIsCalculationDialogOpen(true); }; // 체크박스 선택 const toggleSelection = (id: string) => { const newSelection = new Set(selectedItems); if (newSelection.has(id)) { newSelection.delete(id); } else { newSelection.add(id); } setSelectedItems(newSelection); }; const toggleSelectAll = () => { if (selectedItems.size === paginatedQuotes.length && paginatedQuotes.length > 0) { setSelectedItems(new Set()); } else { setSelectedItems(new Set(paginatedQuotes.map((q) => q.id))); } }; // 일괄 삭제 const handleBulkDelete = () => { if (selectedItems.size === 0) { toast.error('삭제할 항목을 선택해주세요'); return; } setIsBulkDeleteDialogOpen(true); }; const handleConfirmBulkDelete = async () => { startTransition(async () => { const result = await bulkDeleteQuotes(Array.from(selectedItems)); if (result.success) { setQuotes(quotes.filter((q) => !selectedItems.has(q.id))); toast.success(`${selectedItems.size}개의 견적이 삭제되었습니다`); setSelectedItems(new Set()); } else { toast.error(result.error || '일괄 삭제에 실패했습니다.'); } setIsBulkDeleteDialogOpen(false); }); }; // 상태 뱃지 const getRevisionBadge = (quote: Quote) => { // getQuoteStatusBadge 함수에 맞게 변환 const legacyQuote = { status: quote.status === 'converted' ? 'converted' : 'draft', currentRevision: quote.currentRevision, isFinal: quote.isFinal, }; return getQuoteStatusBadge(legacyQuote as any); }; // 탭 구성 const tabs: TabOption[] = [ { value: 'all', label: '전체', count: quotes.length, color: 'blue', }, { value: 'initial', label: '최초작성', count: quotes.filter( (q) => q.currentRevision === 0 && !q.isFinal && q.status !== 'converted' ).length, color: 'gray', }, { value: 'revising', label: '수정중', count: quotes.filter( (q) => q.currentRevision > 0 && !q.isFinal && q.status !== 'converted' ).length, color: 'orange', }, { value: 'final', label: '최종확정', count: quotes.filter((q) => q.isFinal && q.status !== 'converted').length, color: 'green', }, { value: 'converted', label: '수주전환', count: quotes.filter((q) => q.status === 'converted').length, color: 'purple', }, ]; // 테이블 컬럼 정의 const tableColumns: TableColumn[] = [ { key: 'rowNumber', label: '번호', className: 'px-4' }, { key: 'quoteNumber', label: '견적번호', className: 'px-4' }, { key: 'registrationDate', label: '접수일', className: 'px-4' }, { key: 'status', label: '상태', className: 'px-4' }, { key: 'productCategory', label: '제품분류', className: 'px-4' }, { key: 'quantity', label: '수량', className: 'px-4' }, { key: 'amount', label: '금액', className: 'px-4' }, { key: 'client', label: '발주처', className: 'px-4' }, { key: 'site', label: '현장명', className: 'px-4' }, { key: 'manager', label: '담당자', className: 'px-4' }, { key: 'remarks', label: '비고', className: 'px-4' }, { key: 'actions', label: '작업', className: 'px-4' }, ]; // 테이블 행 렌더링 const renderTableRow = (quote: Quote, index: number, globalIndex: number) => { const itemId = quote.id; const isSelected = selectedItems.has(itemId); return ( handleView(quote)} > e.stopPropagation()} className="text-center"> toggleSelection(itemId)} /> {globalIndex} {quote.quoteNumber || '-'} {quote.registrationDate} {getRevisionBadge(quote)} {PRODUCT_CATEGORY_LABELS[quote.productCategory] || quote.productCategory} {quote.quantity} {formatAmount(quote.totalAmount)} {quote.clientName}
{quote.siteName || '-'} {quote.siteCode && ( {quote.siteCode} )}
{quote.managerName || '-'}
{quote.description || '-'}
e.stopPropagation()}> {isSelected && (
{quote.currentRevision > 0 && ( )} {!quote.isFinal && ( )}
)}
); }; // 모바일 카드 렌더링 const renderMobileCard = ( quote: Quote, index: number, globalIndex: number, isSelected: boolean, onToggle: () => void ) => { return ( handleView(quote)} headerBadges={ <> #{globalIndex} {quote.quoteNumber} } title={quote.clientName} statusBadge={getRevisionBadge(quote)} infoGrid={
} actions={ isSelected ? (
{quote.currentRevision > 0 && ( )} {!quote.isFinal && ( )}
) : undefined } /> ); }; // 검색 핸들러 (디바운스 처리) const handleSearchChange = useCallback((value: string) => { setSearchTerm(value); setCurrentPage(1); // 디바운스된 API 호출 const timeoutId = setTimeout(() => { startTransition(async () => { const result = await getQuotes({ page: 1, perPage: itemsPerPage, search: value || undefined, }); if (result.success) { setQuotes(result.data); setPagination(result.pagination); } }); }, 300); return () => clearTimeout(timeoutId); }, []); return ( <> router.push('/sales/quote-management/new')} > 견적 등록 } stats={stats} searchValue={searchTerm} onSearchChange={handleSearchChange} searchPlaceholder="견적번호, 발주처, 담당자, 현장코드, 현장명 검색..." tabs={tabs} activeTab={filterType} onTabChange={(value) => { setFilterType(value as QuoteFilterType); setCurrentPage(1); }} tableColumns={tableColumns} tableTitle={`${tabs.find((t) => t.value === filterType)?.label || '전체'} (${filteredQuotes.length}개)`} data={paginatedQuotes} totalCount={filteredQuotes.length} allData={mobileQuotes} mobileDisplayCount={mobileDisplayCount} infinityScrollSentinelRef={sentinelRef} selectedItems={selectedItems} onToggleSelection={toggleSelection} onToggleSelectAll={toggleSelectAll} onBulkDelete={handleBulkDelete} getItemId={(quote) => quote.id} renderTableRow={renderTableRow} renderMobileCard={renderMobileCard} pagination={{ currentPage, totalPages, totalItems: filteredQuotes.length, itemsPerPage, onPageChange: setCurrentPage, }} /> {/* 산출내역서 다이얼로그 */} setIsCalculationDialogOpen(false)}>닫기 } > {calculationQuote && (
{/* 기본 정보 */}

견적번호

{calculationQuote.quoteNumber}

발주처

{calculationQuote.clientName}

현장명

{calculationQuote.siteName || '-'}

접수일

{calculationQuote.registrationDate}

{/* 산출 내역 테이블 */}

산출 내역

번호 품목명 규격 수량 단가 공급가 부가세 합계 {calculationQuote.items.length > 0 ? ( calculationQuote.items.map((item, idx) => ( {idx + 1} {item.productName} {item.specification || '-'} {item.quantity} {formatAmount(item.unitPrice)}원 {formatAmount(item.supplyAmount)}원 {formatAmount(item.taxAmount)}원 {formatAmount(item.totalAmount)}원 )) ) : ( 1 {PRODUCT_CATEGORY_LABELS[calculationQuote.productCategory]} - {calculationQuote.quantity} {formatAmount( Math.floor(calculationQuote.totalAmount / calculationQuote.quantity) )} 원 {formatAmount(calculationQuote.supplyAmount)}원 {formatAmount(calculationQuote.taxAmount)}원 {formatAmount(calculationQuote.totalAmount)}원 )}
{/* 합계 */}
총 금액 {formatAmount(calculationQuote.totalAmount)}원
)}
{/* 삭제 확인 다이얼로그 */} 견적 삭제 확인 {deleteTargetId ? `견적번호: ${quotes.find((q) => q.id === deleteTargetId)?.quoteNumber || deleteTargetId}` : ''}
이 견적을 삭제하시겠습니까? 삭제된 데이터는 복구할 수 없습니다.
취소 {isPending ? '삭제 중...' : '삭제'}
{/* 일괄 삭제 확인 다이얼로그 */} 일괄 삭제 확인 선택한 {selectedItems.size}개의 견적을 삭제하시겠습니까?
삭제된 데이터는 복구할 수 없습니다.
취소 {isPending ? '삭제 중...' : '삭제'}
); }