feat(WEB): 견적 시스템 개선, 엑셀 다운로드, PDF 생성 기능 추가

견적 시스템:
- QuoteRegistrationV2: 할인 모달, 거래명세서 모달, vatType 필드 추가
- DiscountModal: 할인율/할인금액 상호 계산 모달
- QuoteTransactionModal: 거래명세서 미리보기 모달
- LocationDetailPanel, LocationListPanel 개선

템플릿 기능:
- UniversalListPage: 엑셀 다운로드 기능 추가 (전체/선택 다운로드)
- DocumentViewer: PDF 생성 기능 개선

신규 API:
- /api/pdf/generate: Puppeteer 기반 PDF 생성 엔드포인트

UI 개선:
- 입력 컴포넌트 placeholder 스타일 개선 (opacity 50%)
- 각종 리스트 컴포넌트 정렬/필터링 개선

패키지 추가:
- html2canvas, jspdf, puppeteer, dom-to-image-more

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
유병철
2026-01-27 19:49:03 +09:00
parent c4644489e7
commit afd7bda269
35 changed files with 3493 additions and 946 deletions

View File

@@ -12,6 +12,7 @@
import { useState, useMemo, useCallback, useTransition } from 'react';
import { useRouter } from 'next/navigation';
import { format, startOfMonth, endOfMonth } from 'date-fns';
import {
FileText,
Edit,
@@ -23,6 +24,13 @@ import {
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 {
@@ -37,7 +45,6 @@ import {
type UniversalListConfig,
type SelectionHandlers,
type RowClickHandlers,
type TabOption,
type StatCard,
type ListParams,
} from '@/components/templates/UniversalListPage';
@@ -64,6 +71,15 @@ export function QuoteManagementClient({
const router = useRouter();
const [isPending, startTransition] = useTransition();
// ===== 날짜 필터 상태 =====
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<string>('all');
const [statusFilter, setStatusFilter] = useState<string>('all');
// ===== 산출내역서 다이얼로그 상태 =====
const [isCalculationDialogOpen, setIsCalculationDialogOpen] = useState(false);
const [calculationQuote, setCalculationQuote] = useState<Quote | null>(null);
@@ -156,35 +172,6 @@ export function QuoteManagementClient({
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();
@@ -307,22 +294,41 @@ export function QuoteManagementClient({
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;
// 필터 함수 (날짜 + 제품분류 + 상태)
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') {
if (productCategoryFilter === 'STEEL' && item.productCategory !== 'STEEL') return false;
if (productCategoryFilter === 'SCREEN' && item.productCategory !== 'SCREEN') return false;
if (productCategoryFilter === 'MIXED' && item.productCategory !== '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;
});
},
// 검색 필터 함수
@@ -344,13 +350,53 @@ export function QuoteManagementClient({
);
},
// 탭 설정
tabs,
defaultTab: 'all',
// 탭 비활성화 (필터로 대체)
tabs: [],
// 통계 카드
computeStats,
// 테이블 우측 필터 (탭 영역에 표시)
tableHeaderActions: (
<div className="flex items-center gap-2">
{/* 제품분류 필터 */}
<Select value={productCategoryFilter} onValueChange={setProductCategoryFilter}>
<SelectTrigger className="w-[100px]">
<SelectValue placeholder="제품분류" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="STEEL"></SelectItem>
<SelectItem value="SCREEN"></SelectItem>
<SelectItem value="MIXED"></SelectItem>
</SelectContent>
</Select>
{/* 상태 필터 */}
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[100px]">
<SelectValue placeholder="상태" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="initial"></SelectItem>
<SelectItem value="revising">N차수정</SelectItem>
<SelectItem value="final"></SelectItem>
</SelectContent>
</Select>
</div>
),
// 날짜 필터
dateRangeSelector: {
enabled: true,
showPresets: true,
startDate,
endDate,
onStartDateChange: setStartDate,
onEndDateChange: setEndDate,
},
// 검색
searchPlaceholder: '견적번호, 발주처, 담당자, 현장코드, 현장명 검색...',
@@ -553,7 +599,7 @@ export function QuoteManagementClient({
);
},
}),
[tabs, computeStats, router, handleView, handleEdit, handleDeleteClick, handleViewHistory, handleBulkDelete, getRevisionBadge, isPending]
[computeStats, router, handleView, handleEdit, handleDeleteClick, handleViewHistory, handleBulkDelete, getRevisionBadge, isPending, startDate, endDate, productCategoryFilter, statusFilter]
);
return (