'use client'; /** * 견적관리 - UniversalListPage 마이그레이션 * * 기존 IntegratedListTemplateV2 → UniversalListPage config 기반으로 변환 * - 클라이언트 사이드 필터링 (탭별, 검색) * - 통계 카드 (클라이언트 데이터 기반 계산) * - 산출내역서 다이얼로그 * - 삭제/일괄삭제 다이얼로그 */ import { useState, useMemo, useCallback } from 'react'; import { useRouter } from 'next/navigation'; import { format, startOfMonth, endOfMonth } from 'date-fns'; 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; 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 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 { useDeleteDialog } from '@/hooks/useDeleteDialog'; import { toast } from 'sonner'; import { formatAmount, formatAmountManwon } from '@/lib/utils/amount'; import type { Quote, QuoteFilterType } from './types'; import { PRODUCT_CATEGORY_LABELS } from './types'; import { getQuotes, deleteQuote, bulkDeleteQuotes } from './actions'; import type { PaginationMeta } from '@/lib/api/types'; // ===== Props 타입 ===== interface QuoteManagementClientProps { initialData: Quote[]; initialPagination: PaginationMeta; } export function QuoteManagementClient({ initialData, initialPagination, }: QuoteManagementClientProps) { const router = useRouter(); const deleteDialog = useDeleteDialog({ onDelete: deleteQuote, onBulkDelete: bulkDeleteQuotes, onSuccess: () => window.location.reload(), entityName: '견적', }); // ===== 날짜 필터 상태 ===== const today = new Date(); const [startDate, setStartDate] = useState(format(startOfMonth(today), 'yyyy-MM-dd')); const [endDate, setEndDate] = useState(format(endOfMonth(today), 'yyyy-MM-dd')); // ===== 필터 상태 ===== const [productCategoryFilter, setProductCategoryFilter] = useState('all'); const [statusFilter, setStatusFilter] = useState('all'); // ===== 산출내역서 다이얼로그 상태 ===== const [isCalculationDialogOpen, setIsCalculationDialogOpen] = useState(false); const [calculationQuote, setCalculationQuote] = useState(null); // ===== 전체 데이터 상태 (통계 계산용) ===== 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 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 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]', sortable: true }, { key: 'registrationDate', label: '접수일', className: 'w-[100px]', sortable: true }, { key: 'status', label: '상태', className: 'w-[80px]', sortable: true }, { key: 'productCategory', label: '제품분류', className: 'w-[100px]', sortable: true }, { key: 'quantity', label: '수량', className: 'w-[60px] text-center', sortable: true }, { key: 'amount', label: '금액', className: 'w-[120px] text-right', sortable: true }, { key: 'client', label: '발주처', className: 'min-w-[100px]', sortable: true }, { key: 'site', label: '현장명', className: 'min-w-[120px]', sortable: true }, { key: 'manager', label: '담당자', className: 'w-[80px]', sortable: true }, { key: 'remarks', label: '비고', className: 'min-w-[150px]' }, { key: 'actions', label: '작업', className: 'w-[100px]' }, ], // 클라이언트 사이드 필터링 clientSideFiltering: true, itemsPerPage: 20, // 필터링 함수 (날짜 + 제품분류 + 상태) customFilterFn: (items: Quote[]) => { return items.filter((item) => { // 날짜 필터 const itemDate = item.registrationDate; if (itemDate) { if (startDate && itemDate < startDate) return false; if (endDate && itemDate > endDate) return false; } // 제품분류 필터 if (productCategoryFilter !== 'all') { const category = item.productCategory as string; if (productCategoryFilter === 'STEEL' && category !== 'STEEL') return false; if (productCategoryFilter === 'SCREEN' && category !== 'SCREEN') return false; if (productCategoryFilter === 'MIXED' && category !== 'MIXED') return false; } // 상태 필터 if (statusFilter !== 'all') { if (statusFilter === 'initial') { // 최초작성: currentRevision === 0 && !isFinal if (!(item.currentRevision === 0 && !item.isFinal)) return false; } if (statusFilter === 'revising') { // N차수정: currentRevision > 0 && !isFinal if (!(item.currentRevision > 0 && !item.isFinal)) return false; } if (statusFilter === 'final') { // 최종확정: isFinal === true if (!item.isFinal) return false; } } 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: [], // 통계 카드 computeStats, // 테이블 우측 필터 (탭 영역에 표시) tableHeaderActions: (
{/* 제품분류 필터 */} {/* 상태 필터 */}
), // 날짜 필터 dateRangeSelector: { enabled: true, showPresets: true, startDate, endDate, onStartDateChange: setStartDate, onEndDateChange: setEndDate, }, // 검색 searchPlaceholder: '견적번호, 발주처, 담당자, 현장코드, 현장명 검색...', // 헤더 액션 headerActions: () => ( ), // 일괄 삭제 핸들러 onBulkDelete: deleteDialog.bulk.open, // 테이블 행 렌더링 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 } /> ); }, }), [computeStats, router, handleView, handleEdit, handleViewHistory, getRevisionBadge, deleteDialog, startDate, endDate, productCategoryFilter, statusFilter] ); 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)}원
)}
{/* 삭제 확인 다이얼로그 */} {deleteDialog.single.targetId ? `견적번호: ${allQuotes.find((q) => q.id === deleteDialog.single.targetId)?.quoteNumber || deleteDialog.single.targetId}` : ''}
이 견적을 삭제하시겠습니까? 삭제된 데이터는 복구할 수 없습니다. } loading={deleteDialog.isPending} onConfirm={deleteDialog.single.confirm} /> {/* 일괄 삭제 확인 다이얼로그 */} ); }