'use client'; /** * 견적관리 - UniversalListPage 마이그레이션 * * 기존 IntegratedListTemplateV2 → UniversalListPage config 기반으로 변환 * - 클라이언트 사이드 필터링 (탭별, 검색) * - 통계 카드 (클라이언트 데이터 기반 계산) * - 산출내역서 다이얼로그 * - 삭제/일괄삭제 다이얼로그 */ import { useState, useMemo, useCallback, useTransition } 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 { Checkbox } from '@/components/ui/checkbox'; import { getQuoteStatusBadge } from '@/components/atoms/BadgeSm'; import { TableRow, TableCell } from '@/components/ui/table'; import { Table, TableHeader, TableHead, TableBody, } from '@/components/ui/table'; import { Separator } from '@/components/ui/separator'; import { UniversalListPage, type UniversalListConfig, type SelectionHandlers, type RowClickHandlers, type TabOption, type StatCard, type ListParams, } from '@/components/templates/UniversalListPage'; import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard'; import { StandardDialog } from '@/components/molecules/StandardDialog'; import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog'; import { toast } from 'sonner'; import { formatAmount, formatAmountManwon } from '@/utils/formatAmount'; 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 [isCalculationDialogOpen, setIsCalculationDialogOpen] = useState(false); const [calculationQuote, setCalculationQuote] = useState(null); // ===== 삭제 다이얼로그 상태 ===== const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const [deleteTargetId, setDeleteTargetId] = useState(null); const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false); const [bulkDeleteIds, setBulkDeleteIds] = useState([]); // ===== 전체 데이터 상태 (통계 계산용) ===== const [allQuotes, setAllQuotes] = useState(initialData); // ===== 핸들러 ===== const handleView = useCallback((quote: Quote) => { router.push(`/sales/quote-management/${quote.id}`); }, [router]); const handleEdit = useCallback((quote: Quote) => { router.push(`/sales/quote-management/${quote.id}?mode=edit`); }, [router]); const handleDeleteClick = useCallback((id: string) => { setDeleteTargetId(id); setIsDeleteDialogOpen(true); }, []); const handleConfirmDelete = useCallback(async () => { if (!deleteTargetId) return; startTransition(async () => { const result = await deleteQuote(deleteTargetId); if (result.success) { const quote = allQuotes.find((q) => q.id === deleteTargetId); setAllQuotes(allQuotes.filter((q) => q.id !== deleteTargetId)); toast.success(`견적이 삭제되었습니다${quote ? `: ${quote.quoteNumber}` : ''}`); window.location.reload(); } else { toast.error(result.error || '삭제에 실패했습니다.'); } setIsDeleteDialogOpen(false); setDeleteTargetId(null); }); }, [deleteTargetId, allQuotes]); const handleBulkDelete = useCallback((selectedIds: string[]) => { if (selectedIds.length === 0) { toast.error('삭제할 항목을 선택해주세요'); return; } setBulkDeleteIds(selectedIds); setIsBulkDeleteDialogOpen(true); }, []); const handleConfirmBulkDelete = useCallback(async () => { startTransition(async () => { const result = await bulkDeleteQuotes(bulkDeleteIds); if (result.success) { setAllQuotes(allQuotes.filter((q) => !bulkDeleteIds.includes(q.id))); toast.success(`${bulkDeleteIds.length}개의 견적이 삭제되었습니다`); window.location.reload(); } else { toast.error(result.error || '일괄 삭제에 실패했습니다.'); } setIsBulkDeleteDialogOpen(false); setBulkDeleteIds([]); }); }, [bulkDeleteIds, allQuotes]); const handleViewHistory = useCallback((quote: Quote) => { toast.info(`수정 이력: ${quote.quoteNumber} (${quote.currentRevision}차 수정)`); }, []); const handleViewCalculation = useCallback((quote: Quote) => { setCalculationQuote(quote); setIsCalculationDialogOpen(true); }, []); // ===== 상태 뱃지 ===== const getRevisionBadge = useCallback((quote: Quote) => { const legacyQuote = { status: quote.status === 'converted' ? 'converted' : 'draft', currentRevision: quote.currentRevision, isFinal: quote.isFinal, }; return getQuoteStatusBadge(legacyQuote as any); }, []); // ===== 탭 옵션 ===== const tabs: TabOption[] = useMemo(() => [ { value: 'all', label: '전체', count: allQuotes.length, color: 'blue' }, { value: 'initial', label: '최초작성', count: allQuotes.filter((q) => q.currentRevision === 0 && !q.isFinal && q.status !== 'converted').length, color: 'gray', }, { value: 'revising', label: '수정중', count: allQuotes.filter((q) => q.currentRevision > 0 && !q.isFinal && q.status !== 'converted').length, color: 'orange', }, { value: 'final', label: '최종확정', count: allQuotes.filter((q) => q.isFinal && q.status !== 'converted').length, color: 'green', }, { value: 'converted', label: '수주전환', count: allQuotes.filter((q) => q.status === 'converted').length, color: 'purple', }, ], [allQuotes]); // ===== 통계 카드 계산 ===== const computeStats = useCallback((data: Quote[]): StatCard[] => { 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 = data.filter( (q) => new Date(q.registrationDate) >= startOfMonth ); const thisMonthAmount = thisMonthQuotes.reduce((sum, q) => sum + q.totalAmount, 0); const ongoingQuotes = data.filter((q) => q.status === 'draft'); const ongoingAmount = ongoingQuotes.reduce((sum, q) => sum + q.totalAmount, 0); const thisWeekQuotes = data.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'; return [ { 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', }, ]; }, []); // ===== UniversalListPage Config ===== const config: UniversalListConfig = useMemo( () => ({ // 페이지 기본 정보 title: '견적 목록', description: '견적서 작성 및 관리', icon: FileText, basePath: '/sales/quote-management', // ID 추출 idField: 'id', getItemId: (item: Quote) => item.id, // API 액션 actions: { getList: async (params?: ListParams) => { try { const result = await getQuotes({ page: params?.page || 1, perPage: 9999, // 클라이언트 사이드 필터링: 전체 데이터 로드 search: params?.search || undefined, }); if (result.success) { setAllQuotes(result.data); return { success: true, data: result.data, totalCount: result.data.length, totalPages: 1, }; } return { success: false, error: result.error || '데이터 조회에 실패했습니다.' }; } catch { return { success: false, error: '서버 오류가 발생했습니다.' }; } }, deleteItem: async (id: string) => { const result = await deleteQuote(id); return { success: result.success, error: result.error }; }, deleteBulk: async (ids: string[]) => { const result = await bulkDeleteQuotes(ids); return { success: result.success, error: result.error }; }, }, // 테이블 컬럼 columns: [ { key: 'rowNumber', label: '번호', className: 'w-[60px] text-center' }, { key: 'quoteNumber', label: '견적번호', className: 'min-w-[120px]' }, { key: 'registrationDate', label: '접수일', className: 'w-[100px]' }, { key: 'status', label: '상태', className: 'w-[80px]' }, { key: 'productCategory', label: '제품분류', className: 'w-[100px]' }, { key: 'quantity', label: '수량', className: 'w-[60px] text-center' }, { key: 'amount', label: '금액', className: 'w-[120px] text-right' }, { key: 'client', label: '발주처', className: 'min-w-[100px]' }, { key: 'site', label: '현장명', className: 'min-w-[120px]' }, { key: 'manager', label: '담당자', className: 'w-[80px]' }, { key: 'remarks', label: '비고', className: 'min-w-[150px]' }, { key: 'actions', label: '작업', className: 'w-[100px]' }, ], // 클라이언트 사이드 필터링 clientSideFiltering: true, itemsPerPage: 20, // 탭 필터 함수 tabFilter: (item: Quote, activeTab: string) => { if (activeTab === 'all') return true; if (activeTab === 'initial') { return item.currentRevision === 0 && !item.isFinal && item.status !== 'converted'; } if (activeTab === 'revising') { return item.currentRevision > 0 && !item.isFinal && item.status !== 'converted'; } if (activeTab === 'final') { return item.isFinal && item.status !== 'converted'; } if (activeTab === 'converted') { return item.status === 'converted'; } return true; }, // 검색 필터 함수 searchFilter: (item: Quote, searchValue: string) => { const search = searchValue.toLowerCase(); return ( (item.quoteNumber?.toLowerCase() || '').includes(search) || (item.clientName?.toLowerCase() || '').includes(search) || (item.managerName?.toLowerCase() || '').includes(search) || (item.siteCode?.toLowerCase() || '').includes(search) || (item.siteName?.toLowerCase() || '').includes(search) ); }, // 커스텀 정렬 (최신순) customSortFn: (items: Quote[]) => { return [...items].sort((a, b) => new Date(b.registrationDate).getTime() - new Date(a.registrationDate).getTime() ); }, // 탭 설정 tabs, defaultTab: 'all', // 통계 카드 computeStats, // 검색 searchPlaceholder: '견적번호, 발주처, 담당자, 현장코드, 현장명 검색...', // 헤더 액션 headerActions: () => ( ), // 일괄 삭제 핸들러 onBulkDelete: handleBulkDelete, // 테이블 행 렌더링 renderTableRow: ( quote: Quote, index: number, globalIndex: number, handlers: SelectionHandlers & RowClickHandlers ) => { return ( handleView(quote)} > e.stopPropagation()} className="text-center"> {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()}> {handlers.isSelected && (
{quote.currentRevision > 0 && ( )} {!quote.isFinal && ( )}
)}
); }, // 모바일 카드 렌더링 renderMobileCard: ( quote: Quote, index: number, globalIndex: number, handlers: SelectionHandlers & RowClickHandlers ) => { return ( handleView(quote)} headerBadges={ <> #{globalIndex} {quote.quoteNumber} } title={quote.clientName} statusBadge={getRevisionBadge(quote)} infoGrid={
} actions={ handlers.isSelected ? (
{quote.currentRevision > 0 && ( )} {!quote.isFinal && ( )}
) : undefined } /> ); }, }), [tabs, computeStats, router, handleView, handleEdit, handleDeleteClick, handleViewHistory, handleBulkDelete, getRevisionBadge, isPending] ); return ( <> {/* 산출내역서 다이얼로그 */} 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 ? `견적번호: ${allQuotes.find((q) => q.id === deleteTargetId)?.quoteNumber || deleteTargetId}` : ''}
이 견적을 삭제하시겠습니까? 삭제된 데이터는 복구할 수 없습니다. } loading={isPending} onConfirm={handleConfirmDelete} /> {/* 일괄 삭제 확인 다이얼로그 */} 선택한 {bulkDeleteIds.length}개의 견적을 삭제하시겠습니까?
삭제된 데이터는 복구할 수 없습니다. } loading={isPending} onConfirm={handleConfirmBulkDelete} /> ); }