fix: [quotes] QuoteFormData → QuoteFormDataV2 타입명 변경 반영
This commit is contained in:
539
src/components/quotes/QuoteCalculationReport.tsx
Executable file
539
src/components/quotes/QuoteCalculationReport.tsx
Executable file
@@ -0,0 +1,539 @@
|
||||
/**
|
||||
* 견적 산출내역서 / 견적서 컴포넌트
|
||||
* - documentType="견적서": 간단한 견적서
|
||||
* - documentType="견적산출내역서": 상세 산출내역서 + 소요자재 내역
|
||||
*/
|
||||
|
||||
import { QuoteFormDataV2 } from "./QuoteRegistration";
|
||||
import type { BomMaterial } from "./types";
|
||||
import type { CompanyFormData } from "@/components/settings/CompanyInfoManagement/types";
|
||||
|
||||
interface QuoteCalculationReportProps {
|
||||
quote: QuoteFormDataV2;
|
||||
companyInfo?: CompanyFormData | null;
|
||||
documentType?: "견적산출내역서" | "견적서";
|
||||
showDetailedBreakdown?: boolean;
|
||||
showMaterialList?: boolean;
|
||||
}
|
||||
|
||||
export function QuoteCalculationReport({
|
||||
quote,
|
||||
companyInfo,
|
||||
documentType = "견적산출내역서",
|
||||
showDetailedBreakdown = true,
|
||||
showMaterialList = true
|
||||
}: QuoteCalculationReportProps) {
|
||||
const formatAmount = (amount: number | null | undefined) => {
|
||||
if (amount == null) return '0';
|
||||
return Number(amount).toLocaleString('ko-KR');
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
if (!dateStr) return '';
|
||||
const date = new Date(dateStr);
|
||||
return `${date.getFullYear()}년 ${String(date.getMonth() + 1).padStart(2, '0')}월 ${String(date.getDate()).padStart(2, '0')}일`;
|
||||
};
|
||||
|
||||
// 총 금액 계산 (totalAmount > unitPrice * quantity > inspectionFee 우선순위)
|
||||
const totalAmount = quote.items?.reduce((sum, item) => {
|
||||
const itemTotal = item.totalAmount ||
|
||||
(item.unitPrice || 0) * (item.quantity || 1) ||
|
||||
(item.inspectionFee || 0) * (item.quantity || 1);
|
||||
return sum + itemTotal;
|
||||
}, 0) || 0;
|
||||
|
||||
// 소요자재 내역 - BOM 자재 목록 (quote.bomMaterials)에서 가져옴
|
||||
// bomMaterials가 없으면 빈 배열 (BOM 계산 데이터 없음)
|
||||
const materialItems = (quote.bomMaterials || []).map((material, index) => ({
|
||||
no: index + 1,
|
||||
itemCode: material.itemCode || '-',
|
||||
name: material.itemName || '-',
|
||||
spec: material.specification || '-',
|
||||
quantity: Math.floor(material.quantity || 1),
|
||||
unit: material.unit || 'EA',
|
||||
unitPrice: material.unitPrice || 0,
|
||||
totalPrice: material.totalPrice || 0,
|
||||
}));
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{`
|
||||
@media print {
|
||||
@page {
|
||||
size: A4 portrait;
|
||||
margin: 15mm;
|
||||
}
|
||||
|
||||
body {
|
||||
background: white !important;
|
||||
}
|
||||
|
||||
.print\\:hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
#quote-report-content {
|
||||
background: white !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
table {
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
.page-break-after {
|
||||
page-break-after: always;
|
||||
}
|
||||
}
|
||||
|
||||
/* 공문서 스타일 */
|
||||
.official-doc {
|
||||
font-family: 'Malgun Gothic', 'Apple SD Gothic Neo', sans-serif;
|
||||
background: white;
|
||||
color: #000;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.doc-header {
|
||||
text-align: center;
|
||||
border-bottom: 3px double #000;
|
||||
padding-bottom: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.doc-title {
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 2px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.doc-number {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
border: 2px solid #000;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.info-box-header {
|
||||
background: #f0f0f0;
|
||||
border-bottom: 2px solid #000;
|
||||
padding: 8px 12px;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.info-box-content {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.info-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.info-table th {
|
||||
background: #f8f8f8;
|
||||
border: 1px solid #999;
|
||||
padding: 8px 10px;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.info-table td {
|
||||
border: 1px solid #999;
|
||||
padding: 8px 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.amount-box {
|
||||
border: 3px double #000;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
margin: 30px 0;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.amount-label {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.amount-value {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: #000;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.amount-note {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
background: #000;
|
||||
color: white;
|
||||
padding: 10px 15px;
|
||||
font-weight: 700;
|
||||
font-size: 15px;
|
||||
margin: 30px 0 15px 0;
|
||||
text-align: center;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.detail-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
border: 2px solid #000;
|
||||
}
|
||||
|
||||
.detail-table thead th {
|
||||
background: #e8e8e8;
|
||||
border: 1px solid #666;
|
||||
padding: 10px 6px;
|
||||
text-align: center;
|
||||
font-weight: 700;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.detail-table tbody td {
|
||||
border: 1px solid #999;
|
||||
padding: 8px 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.detail-table tbody tr:hover {
|
||||
background: #f9f9f9;
|
||||
}
|
||||
|
||||
.detail-table tfoot td {
|
||||
background: #f0f0f0;
|
||||
border: 1px solid #666;
|
||||
padding: 10px;
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.material-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
border: 2px solid #000;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.material-table th {
|
||||
background: #e8e8e8;
|
||||
border: 1px solid #666;
|
||||
padding: 8px;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.material-table td {
|
||||
border: 1px solid #999;
|
||||
padding: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.stamp-area {
|
||||
border: 2px solid #000;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
.stamp-text {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
font-size: 10px;
|
||||
color: #999;
|
||||
text-align: center;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.footer-note {
|
||||
margin-top: 40px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #ccc;
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.signature-section {
|
||||
margin-top: 30px;
|
||||
text-align: right;
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{/* 문서 컴포넌트 */}
|
||||
<div className="official-doc">
|
||||
{/* 문서 헤더 */}
|
||||
<div className="doc-header">
|
||||
<div className="doc-title">
|
||||
{documentType === "견적서" ? "견 적 서" : "견 적 산 출 내 역 서"}
|
||||
</div>
|
||||
<div className="doc-number">
|
||||
문서번호: {quote.id || '-'} | 작성일자: {formatDate(quote.registrationDate || '')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 수요자 정보 */}
|
||||
<div className="info-box">
|
||||
<div className="info-box-header">수 요 자</div>
|
||||
<div className="info-box-content">
|
||||
<table className="info-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>업체명</th>
|
||||
<td colSpan={3}>{quote.clientName || '-'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>현장명</th>
|
||||
<td>{quote.siteName || '-'}</td>
|
||||
<th>담당자</th>
|
||||
<td>{quote.manager || '-'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>제품명</th>
|
||||
<td>{quote.items?.[0]?.productName || '-'}</td>
|
||||
<th>연락처</th>
|
||||
<td>{quote.contact || '-'}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 공급자 정보 */}
|
||||
<div className="info-box">
|
||||
<div className="info-box-header">공 급 자</div>
|
||||
<div className="info-box-content">
|
||||
<table className="info-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>상호</th>
|
||||
<td>{companyInfo?.companyName || '-'}</td>
|
||||
<th>사업자등록번호</th>
|
||||
<td>{companyInfo?.businessNumber || '-'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>대표자</th>
|
||||
<td>{companyInfo?.representativeName || '-'}</td>
|
||||
<th>업태</th>
|
||||
<td>{companyInfo?.businessType || '-'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>종목</th>
|
||||
<td colSpan={3}>{companyInfo?.businessCategory || '-'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>사업장주소</th>
|
||||
<td colSpan={3}>{companyInfo?.address || '-'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>전화</th>
|
||||
<td>{companyInfo?.managerPhone || '-'}</td>
|
||||
<th>이메일</th>
|
||||
<td>{companyInfo?.email || '-'}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 총 견적금액 */}
|
||||
<div className="amount-box">
|
||||
<div className="amount-label">총 견적금액</div>
|
||||
<div className="amount-value">₩ {formatAmount(totalAmount)}</div>
|
||||
<div className="amount-note">※ 부가가치세 별도</div>
|
||||
</div>
|
||||
|
||||
{/* 세부 산출내역서 */}
|
||||
{showDetailedBreakdown && quote.items && quote.items.length > 0 && (
|
||||
<div className="page-break-after">
|
||||
<div className="section-title">세 부 산 출 내 역</div>
|
||||
|
||||
<table className="detail-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: '40px' }}>No.</th>
|
||||
<th style={{ width: '200px' }}>품목명</th>
|
||||
<th style={{ width: '150px' }}>규격</th>
|
||||
<th style={{ width: '70px' }}>수량</th>
|
||||
<th style={{ width: '50px' }}>단위</th>
|
||||
<th style={{ width: '110px' }}>단가</th>
|
||||
<th style={{ width: '130px' }}>금액</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{quote.items.map((item, index) => {
|
||||
// 단가: unitPrice > inspectionFee 우선순위
|
||||
const unitPrice = item.unitPrice || item.inspectionFee || 0;
|
||||
// 금액: totalAmount > unitPrice * quantity 우선순위
|
||||
const itemTotal = item.totalAmount || unitPrice * (item.quantity || 1);
|
||||
return (
|
||||
<tr key={item.id || `item-${index}`}>
|
||||
<td style={{ textAlign: 'center' }}>{index + 1}</td>
|
||||
<td>{item.productName}</td>
|
||||
<td style={{ fontSize: '11px' }}>{`${item.openWidth}×${item.openHeight}mm`}</td>
|
||||
<td style={{ textAlign: 'right' }}>{Math.floor(item.quantity || 0)}</td>
|
||||
<td style={{ textAlign: 'center' }}>{item.unit || 'SET'}</td>
|
||||
<td style={{ textAlign: 'right' }}>{formatAmount(unitPrice)}</td>
|
||||
<td style={{ textAlign: 'right', fontWeight: '600' }}>{formatAmount(itemTotal)}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colSpan={6} style={{ textAlign: 'right', padding: '12px', background: '#e0e0e0', fontWeight: '700' }}>공급가액 합계</td>
|
||||
<td style={{ textAlign: 'right', padding: '12px', background: '#e0e0e0', fontSize: '15px', fontWeight: '700' }}>{formatAmount(totalAmount)}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 소요자재 내역 */}
|
||||
{showMaterialList && documentType !== "견적서" && (
|
||||
<div>
|
||||
<div className="section-title">소 요 자 재 내 역</div>
|
||||
|
||||
{/* 제품 정보 */}
|
||||
<div className="info-box" style={{ marginTop: '15px' }}>
|
||||
<div className="info-box-content">
|
||||
<table className="info-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>제품구분</th>
|
||||
<td>{quote.items?.[0]?.productCategory === 'steel' ? '철재' : '스크린'}</td>
|
||||
<th>부호</th>
|
||||
<td>{quote.items?.[0]?.code || '-'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>오픈사이즈</th>
|
||||
<td>W {quote.items?.[0]?.openWidth || '-'} × H {quote.items?.[0]?.openHeight || '-'} (mm)</td>
|
||||
<th>제작사이즈</th>
|
||||
<td>W {Number(quote.items?.[0]?.openWidth || 0) + 100} × H {Number(quote.items?.[0]?.openHeight || 0) + 100} (mm)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>수량</th>
|
||||
<td>{Math.floor(quote.items?.[0]?.quantity || 1)} {quote.items?.[0]?.unit || 'SET'}</td>
|
||||
<th>케이스</th>
|
||||
<td>2438 × 550 (mm)</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 자재 목록 테이블 */}
|
||||
{materialItems.length > 0 ? (
|
||||
<table className="material-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: '40px' }}>No.</th>
|
||||
<th style={{ width: '100px' }}>품목코드</th>
|
||||
<th>자재명</th>
|
||||
<th style={{ width: '200px' }}>규격</th>
|
||||
<th style={{ width: '80px' }}>수량</th>
|
||||
<th style={{ width: '60px' }}>단위</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{materialItems.map((item, index) => (
|
||||
<tr key={index}>
|
||||
<td style={{ textAlign: 'center' }}>{index + 1}</td>
|
||||
<td style={{ textAlign: 'center', fontSize: '11px' }}>{item.itemCode}</td>
|
||||
<td>{item.name}</td>
|
||||
<td style={{ fontSize: '11px' }}>{item.spec}</td>
|
||||
<td style={{ textAlign: 'center', fontWeight: '600' }}>{item.quantity}</td>
|
||||
<td style={{ textAlign: 'center' }}>{item.unit}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<div style={{
|
||||
border: '2px solid #000',
|
||||
padding: '30px',
|
||||
textAlign: 'center',
|
||||
marginTop: '15px',
|
||||
color: '#666'
|
||||
}}>
|
||||
소요자재 정보가 없습니다. (BOM 계산 데이터가 필요합니다)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 비고사항 */}
|
||||
{quote.remarks && (
|
||||
<div style={{ marginTop: '30px' }}>
|
||||
<div className="section-title">비 고 사 항</div>
|
||||
<div style={{
|
||||
border: '2px solid #000',
|
||||
padding: '15px',
|
||||
minHeight: '100px',
|
||||
whiteSpace: 'pre-wrap',
|
||||
fontSize: '13px',
|
||||
lineHeight: '1.8',
|
||||
marginTop: '15px'
|
||||
}}>
|
||||
{quote.remarks}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 서명란 */}
|
||||
<div className="signature-section">
|
||||
<div style={{ display: 'inline-block', textAlign: 'left' }}>
|
||||
<div style={{ marginBottom: '15px', fontSize: '14px' }}>
|
||||
상기와 같이 견적합니다.
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '20px' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: '13px', marginBottom: '5px' }}>{formatDate(quote.registrationDate || '')}</div>
|
||||
<div style={{ fontSize: '15px', fontWeight: '600' }}>
|
||||
공급자: {companyInfo?.companyName || '-'} (인)
|
||||
</div>
|
||||
</div>
|
||||
<div className="stamp-area">
|
||||
<div className="stamp-text">
|
||||
(인감<br/>날인)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 하단 안내사항 */}
|
||||
<div className="footer-note">
|
||||
<p style={{ fontWeight: '600', marginBottom: '8px' }}>【 유의사항 】</p>
|
||||
<p>1. 본 견적서는 {formatDate(quote.registrationDate || '')} 기준으로 작성되었으며, 자재 가격 변동 시 조정될 수 있습니다.</p>
|
||||
<p>2. 견적 유효기간은 발행일로부터 30일이며, 기간 경과 시 재견적이 필요합니다.</p>
|
||||
<p>3. 제작 사양 및 수량 변경 시 견적 금액이 변동될 수 있습니다.</p>
|
||||
<p>4. 현장 여건에 따라 추가 비용이 발생할 수 있습니다.</p>
|
||||
<p style={{ marginTop: '12px', textAlign: 'center', fontWeight: '600' }}>
|
||||
문의: {companyInfo?.managerName || quote.manager || '담당자'} | {companyInfo?.managerPhone || '-'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user