fix: 품목관리 수정 기능 버그 수정 및 Sales 페이지 추가
## 품목관리 수정 버그 수정 - FG(제품) 수정 시 품목명 반영 안되는 문제 해결 - productName → name 필드 매핑 추가 - FG 품목코드 = 품목명 동기화 로직 추가 - Materials(SM, RM, CS) 수정페이지 진입 오류 해결 - UNIQUE 제약조건 위반 오류 해결 ## Sales 페이지 - 거래처관리 (client-management-sales-admin) 페이지 구현 - 견적관리 (quote-management) 페이지 구현 - 관련 컴포넌트 및 훅 추가 ## 기타 - 회원가입 페이지 차단 처리 - 디버깅용 콘솔 로그 추가 (PUT 요청/응답 확인용) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
425
src/components/quotes/PurchaseOrderDocument.tsx
Normal file
425
src/components/quotes/PurchaseOrderDocument.tsx
Normal file
@@ -0,0 +1,425 @@
|
||||
/**
|
||||
* 발주서 (Purchase Order Document)
|
||||
*
|
||||
* - 로트번호 및 결재란
|
||||
* - 신청업체 정보
|
||||
* - 신청내용
|
||||
* - 부자재 목록
|
||||
*/
|
||||
|
||||
import { QuoteFormData } from "./QuoteRegistration";
|
||||
|
||||
interface PurchaseOrderDocumentProps {
|
||||
quote: QuoteFormData;
|
||||
}
|
||||
|
||||
export function PurchaseOrderDocument({ quote }: PurchaseOrderDocumentProps) {
|
||||
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')}일`;
|
||||
};
|
||||
|
||||
// 발주번호 생성 (견적번호 기반)
|
||||
const purchaseOrderNumber = quote.id?.replace('Q', 'KQ#-SC-') + '-01' || 'KQ#-SC-XXXXXX-01';
|
||||
|
||||
// BOM에서 부자재 목록 추출 (샘플 데이터)
|
||||
const materialItems = quote.items?.map((item, index) => ({
|
||||
no: index + 1,
|
||||
name: item.productName || '아연도각파이프',
|
||||
spec: `${item.openWidth}×${item.openHeight}`,
|
||||
length: Number(item.openHeight) || 3000,
|
||||
quantity: item.quantity || 1,
|
||||
note: ''
|
||||
})) || [
|
||||
{ no: 1, name: '아연도각파이프', spec: '100-50-2T', length: 3000, quantity: 6, note: '' },
|
||||
{ no: 2, name: '아연도각파이프', spec: '100-100-2T', length: 3000, quantity: 6, note: '' },
|
||||
{ no: 3, name: '아연도앵글', spec: '50-50-4T', length: 2500, quantity: 10, note: '' },
|
||||
{ no: 4, name: '외주 발주 코팅 비비그레스', spec: '', length: 0, quantity: 1, note: '' },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{`
|
||||
@media print {
|
||||
@page {
|
||||
size: A4 portrait;
|
||||
margin: 10mm;
|
||||
}
|
||||
|
||||
body {
|
||||
background: white !important;
|
||||
}
|
||||
|
||||
#purchase-order-content {
|
||||
background: white !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* 발주서 공문서 스타일 */
|
||||
.purchase-order {
|
||||
font-family: 'Malgun Gothic', 'Apple SD Gothic Neo', sans-serif;
|
||||
background: white;
|
||||
color: #000;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.po-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 15px;
|
||||
border-bottom: 2px solid #000;
|
||||
}
|
||||
|
||||
.po-title {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.po-title h1 {
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 8px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.po-approval-section {
|
||||
border: 2px solid #000;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.po-lot-number-row {
|
||||
display: grid;
|
||||
grid-template-columns: 100px 1fr;
|
||||
border-bottom: 2px solid #000;
|
||||
}
|
||||
|
||||
.po-lot-label {
|
||||
background: #e8e8e8;
|
||||
border-right: 2px solid #000;
|
||||
padding: 8px;
|
||||
text-align: center;
|
||||
font-weight: 700;
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.po-lot-value {
|
||||
background: white;
|
||||
padding: 8px;
|
||||
text-align: center;
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
color: #000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.po-approval-box {
|
||||
width: 100%;
|
||||
border: none;
|
||||
display: grid;
|
||||
grid-template-columns: 60px 1fr;
|
||||
grid-template-rows: auto auto auto;
|
||||
}
|
||||
|
||||
.po-approval-merged-vertical-cell {
|
||||
border-right: 1px solid #000;
|
||||
padding: 4px;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
font-size: 11px;
|
||||
background: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
grid-row: 1 / 4;
|
||||
}
|
||||
|
||||
.po-approval-header {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
border-bottom: 1px solid #000;
|
||||
}
|
||||
|
||||
.po-approval-header-cell {
|
||||
border-right: 1px solid #000;
|
||||
padding: 8px;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.po-approval-header-cell:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.po-approval-content-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
border-bottom: 1px solid #000;
|
||||
}
|
||||
|
||||
.po-approval-name-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
}
|
||||
|
||||
.po-approval-signature-cell {
|
||||
border-right: 1px solid #000;
|
||||
padding: 8px;
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
height: 50px;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.po-approval-signature-cell:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.po-approval-name-cell {
|
||||
border-right: 1px solid #000;
|
||||
padding: 6px;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
font-size: 11px;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.po-approval-name-cell:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.po-section-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
border: 2px solid #000;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.po-section-header {
|
||||
background: #e8e8e8;
|
||||
border: 1px solid #666;
|
||||
padding: 8px;
|
||||
text-align: center;
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.po-section-content {
|
||||
border: 1px solid #999;
|
||||
padding: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.po-materials-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
border: 2px solid #000;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.po-materials-table th {
|
||||
background: #e8e8e8;
|
||||
border: 1px solid #666;
|
||||
padding: 8px;
|
||||
text-align: center;
|
||||
font-weight: 700;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.po-materials-table td {
|
||||
border: 1px solid #999;
|
||||
padding: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.po-notes {
|
||||
margin-top: 20px;
|
||||
padding: 15px;
|
||||
background: #f9f9f9;
|
||||
border: 1px solid #ddd;
|
||||
font-size: 11px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{/* 발주서 내용 */}
|
||||
<div id="purchase-order-content" className="purchase-order p-12 print:p-8">
|
||||
|
||||
{/* 헤더: 제목 + 결재란 */}
|
||||
<div className="po-header">
|
||||
{/* 제목 */}
|
||||
<div className="po-title">
|
||||
<h1>발 주 서</h1>
|
||||
</div>
|
||||
|
||||
{/* 로트번호 + 결재란 */}
|
||||
<div className="po-approval-section">
|
||||
{/* 로트번호 */}
|
||||
<div className="po-lot-number-row">
|
||||
<div className="po-lot-label">
|
||||
로트번호
|
||||
</div>
|
||||
<div className="po-lot-value">
|
||||
{purchaseOrderNumber}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 결재란 */}
|
||||
<div className="po-approval-box">
|
||||
<div className="po-approval-merged-vertical-cell">결<br/>재</div>
|
||||
{/* 결재란 헤더 */}
|
||||
<div className="po-approval-header">
|
||||
<div className="po-approval-header-cell">작성</div>
|
||||
<div className="po-approval-header-cell">검토</div>
|
||||
<div className="po-approval-header-cell">승인</div>
|
||||
</div>
|
||||
{/* 결재+서명란 */}
|
||||
<div className="po-approval-content-row">
|
||||
<div className="po-approval-signature-cell">전진</div>
|
||||
<div className="po-approval-signature-cell"></div>
|
||||
<div className="po-approval-signature-cell"></div>
|
||||
</div>
|
||||
{/* 이름란 */}
|
||||
<div className="po-approval-name-row">
|
||||
<div className="po-approval-name-cell">판매/전진</div>
|
||||
<div className="po-approval-name-cell">회계</div>
|
||||
<div className="po-approval-name-cell">생산</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 신청업체 */}
|
||||
<table className="po-section-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th className="po-section-header" rowSpan={3}>신 청 업 체</th>
|
||||
<th className="po-section-header" style={{ width: '100px' }}>발주처</th>
|
||||
<td className="po-section-content">{quote.clientName || '-'}</td>
|
||||
<th className="po-section-header" style={{ width: '100px' }}>발주일</th>
|
||||
<td className="po-section-content">{formatDate(quote.registrationDate || '')}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th className="po-section-header">담당자</th>
|
||||
<td className="po-section-content">{quote.manager || '-'}</td>
|
||||
<th className="po-section-header">연락처</th>
|
||||
<td className="po-section-content">{quote.contact || '-'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th className="po-section-header">F A X</th>
|
||||
<td className="po-section-content">-</td>
|
||||
<th className="po-section-header">설치개소(총)</th>
|
||||
<td className="po-section-content">{quote.items?.reduce((sum, item) => sum + (item.quantity || 0), 0) || 0}개소</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* 신청내용 */}
|
||||
<table className="po-section-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th className="po-section-header" rowSpan={5}>신 청 내 용</th>
|
||||
<th className="po-section-header" style={{ width: '100px' }}>현장명</th>
|
||||
<td className="po-section-content" colSpan={3}>{quote.siteName || '-'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th className="po-section-header">납기요청일</th>
|
||||
<td className="po-section-content" colSpan={3}>{formatDate(quote.dueDate || '')}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th className="po-section-header">출고일</th>
|
||||
<td className="po-section-content">{formatDate(quote.registrationDate || '')}</td>
|
||||
<th className="po-section-header" style={{ width: '100px' }}>배송방법</th>
|
||||
<td className="po-section-content">상차</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th className="po-section-header">납품주소</th>
|
||||
<td className="po-section-content" colSpan={3}>경기도 안성시 서운면 서운신기 16-180</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th className="po-section-header">수신자</th>
|
||||
<td className="po-section-content">{quote.manager || '-'}</td>
|
||||
<th className="po-section-header" style={{ width: '100px' }}>연락처</th>
|
||||
<td className="po-section-content">{quote.contact || '-'}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* 발주개소 정보 */}
|
||||
<table className="po-section-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th className="po-section-header" style={{ width: '120px' }}>누적발주개소</th>
|
||||
<td className="po-section-content" style={{ width: '200px' }}>-</td>
|
||||
<th className="po-section-header" style={{ width: '120px' }}>금번발주개소</th>
|
||||
<td className="po-section-content">{quote.items?.reduce((sum, item) => sum + (item.quantity || 0), 0) || 0}개소</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* 유의사항 */}
|
||||
<div style={{ marginBottom: '10px', fontSize: '11px', lineHeight: '1.6' }}>
|
||||
<p>1. 귀사의 일익번영을 기원합니다.</p>
|
||||
<p>2. 아래와 같이 주문하오니 품질 및 납기일을 준수하여 주시기 바랍니다.</p>
|
||||
</div>
|
||||
|
||||
{/* 부자재 테이블 */}
|
||||
<div style={{ fontWeight: '700', marginBottom: '10px', fontSize: '14px' }}>■ 부자재</div>
|
||||
<table className="po-materials-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: '50px' }}>구분</th>
|
||||
<th>품명</th>
|
||||
<th style={{ width: '200px' }}>규격</th>
|
||||
<th style={{ width: '100px' }}>길이(mm)</th>
|
||||
<th style={{ width: '80px' }}>수량</th>
|
||||
<th style={{ width: '150px' }}>비고</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{materialItems.map((item) => (
|
||||
<tr key={item.no}>
|
||||
<td style={{ textAlign: 'center' }}>{item.no}</td>
|
||||
<td>{item.name}</td>
|
||||
<td>{item.spec}</td>
|
||||
<td style={{ textAlign: 'right' }}>{item.length > 0 ? item.length.toLocaleString() : ''}</td>
|
||||
<td style={{ textAlign: 'center', fontWeight: '600' }}>{item.quantity}</td>
|
||||
<td>{item.note}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* 특이사항 */}
|
||||
<div style={{ marginBottom: '20px', padding: '10px', border: '1px solid #ddd', background: '#fafafa' }}>
|
||||
<strong style={{ fontSize: '12px' }}>【특이사항】</strong>
|
||||
<div style={{ fontSize: '11px', marginTop: '5px', lineHeight: '1.6' }}>
|
||||
{quote.remarks || '스크린 셔터 부품구성표 기반 자동 견적'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 하단 안내사항 */}
|
||||
<div className="po-notes">
|
||||
<p style={{ fontWeight: '600', marginBottom: '5px' }}>【 유의사항 】</p>
|
||||
<p>• 발주서 승인 완료 후 작업을 진행해주시기 바랍니다.</p>
|
||||
<p>• 납기 엄수 부탁드리며, 품질 기준에 맞춰 납품해주시기 바랍니다.</p>
|
||||
<p>• 기타 문의사항은 담당자에게 연락 부탁드립니다.</p>
|
||||
<p style={{ marginTop: '10px', textAlign: 'center', fontWeight: '600' }}>
|
||||
문의: {quote.writer || '담당자'} | {quote.contact || '031-983-5130'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
508
src/components/quotes/QuoteCalculationReport.tsx
Normal file
508
src/components/quotes/QuoteCalculationReport.tsx
Normal file
@@ -0,0 +1,508 @@
|
||||
/**
|
||||
* 견적 산출내역서 / 견적서 컴포넌트
|
||||
* - documentType="견적서": 간단한 견적서
|
||||
* - documentType="견적산출내역서": 상세 산출내역서 + 소요자재 내역
|
||||
*/
|
||||
|
||||
import { QuoteFormData } from "./QuoteRegistration";
|
||||
|
||||
interface QuoteCalculationReportProps {
|
||||
quote: QuoteFormData;
|
||||
documentType?: "견적산출내역서" | "견적서";
|
||||
showDetailedBreakdown?: boolean;
|
||||
showMaterialList?: boolean;
|
||||
}
|
||||
|
||||
export function QuoteCalculationReport({
|
||||
quote,
|
||||
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')}일`;
|
||||
};
|
||||
|
||||
// 총 금액 계산
|
||||
const totalAmount = quote.items?.reduce((sum, item) => {
|
||||
return sum + (item.inspectionFee || 0) * (item.quantity || 1);
|
||||
}, 0) || 0;
|
||||
|
||||
// 소요자재 내역 생성 (샘플 데이터)
|
||||
const materialItems = quote.items?.map((item, index) => ({
|
||||
no: index + 1,
|
||||
name: item.productName || '가이드레일',
|
||||
spec: `${item.openWidth || 0}×${item.openHeight || 0}mm`,
|
||||
quantity: item.quantity || 1,
|
||||
unit: 'SET'
|
||||
})) || [];
|
||||
|
||||
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>(주)염진건설</td>
|
||||
<th>사업자등록번호</th>
|
||||
<td>139-87-00353</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>대표자</th>
|
||||
<td>김 용 진</td>
|
||||
<th>업태</th>
|
||||
<td>제조</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>종목</th>
|
||||
<td colSpan={3}>방창, 셔터, 금속창호</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>사업장주소</th>
|
||||
<td colSpan={3}>경기도 안성시 공업용지 오성길 45-22</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>전화</th>
|
||||
<td>031-983-5130</td>
|
||||
<th>팩스</th>
|
||||
<td>02-6911-6315</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) => (
|
||||
<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' }}>{item.quantity}</td>
|
||||
<td style={{ textAlign: 'center' }}>SET</td>
|
||||
<td style={{ textAlign: 'right' }}>{formatAmount(item.inspectionFee)}</td>
|
||||
<td style={{ textAlign: 'right', fontWeight: '600' }}>{formatAmount((item.inspectionFee || 0) * (item.quantity || 1))}</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>{quote.items?.[0]?.quantity || 1} SET</td>
|
||||
<th>케이스</th>
|
||||
<td>2438 × 550 (mm)</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 자재 목록 테이블 */}
|
||||
<table className="material-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: '40px' }}>No.</th>
|
||||
<th>자재명</th>
|
||||
<th style={{ width: '250px' }}>규격</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>{item.name}</td>
|
||||
<td>{item.spec}</td>
|
||||
<td style={{ textAlign: 'center', fontWeight: '600' }}>{item.quantity}</td>
|
||||
<td style={{ textAlign: 'center' }}>{item.unit}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</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' }}>
|
||||
공급자: (주)염진건설 (인)
|
||||
</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' }}>
|
||||
문의: {quote.manager || '담당자'} | {quote.contact || '031-983-5130'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
461
src/components/quotes/QuoteDocument.tsx
Normal file
461
src/components/quotes/QuoteDocument.tsx
Normal file
@@ -0,0 +1,461 @@
|
||||
/**
|
||||
* 견적서 (Quote Document)
|
||||
*
|
||||
* - 수요자/공급자 정보
|
||||
* - 총 견적금액
|
||||
* - 제품구성 정보
|
||||
* - 품목 내역 테이블
|
||||
* - 비용 산출
|
||||
* - 비고사항
|
||||
* - 서명란
|
||||
*/
|
||||
|
||||
import { QuoteFormData } from "./QuoteRegistration";
|
||||
|
||||
interface QuoteDocumentProps {
|
||||
quote: QuoteFormData;
|
||||
}
|
||||
|
||||
export function QuoteDocument({ quote }: QuoteDocumentProps) {
|
||||
const formatAmount = (amount: number | undefined) => {
|
||||
if (amount === undefined || amount === null) return '0';
|
||||
return 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')}일`;
|
||||
};
|
||||
|
||||
// 품목 내역 생성
|
||||
const quoteItems = quote.items?.map((item, index) => ({
|
||||
no: index + 1,
|
||||
itemName: item.productName || '스크린셔터',
|
||||
spec: `${item.openWidth}×${item.openHeight}`,
|
||||
quantity: item.quantity || 1,
|
||||
unit: '개소',
|
||||
unitPrice: item.unitPrice || 0,
|
||||
totalPrice: item.totalAmount || 0,
|
||||
})) || [];
|
||||
|
||||
// 합계 계산
|
||||
const subtotal = quoteItems.reduce((sum, item) => sum + item.totalPrice, 0);
|
||||
const vat = Math.round(subtotal * 0.1);
|
||||
const totalAmount = subtotal + vat;
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{`
|
||||
@media print {
|
||||
@page {
|
||||
size: A4 portrait;
|
||||
margin: 15mm;
|
||||
}
|
||||
|
||||
body {
|
||||
background: white !important;
|
||||
}
|
||||
|
||||
.print\\:hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
#quote-document-content {
|
||||
background: white !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
table {
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
}
|
||||
|
||||
/* 공문서 스타일 */
|
||||
.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;
|
||||
}
|
||||
|
||||
.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 id="quote-document-content" className="official-doc p-12 print:p-8">
|
||||
{/* 문서 헤더 */}
|
||||
<div className="doc-header">
|
||||
<div className="doc-title">견 적 서</div>
|
||||
<div className="doc-number">
|
||||
문서번호: {quote.id || 'Q-XXXXXX'} | 작성일자: {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>{formatDate(quote.registrationDate || '')}</td>
|
||||
<th>연락처</th>
|
||||
<td>{quote.contact || '-'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>유효기간</th>
|
||||
<td colSpan={3}>{formatDate(quote.dueDate || '')}</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>동호기업</td>
|
||||
<th>사업자등록번호</th>
|
||||
<td>139-87-00333</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>대표자</th>
|
||||
<td>이 광 호</td>
|
||||
<th>업태</th>
|
||||
<td>제조</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>종목</th>
|
||||
<td colSpan={3}>방창, 셔터, 금속성호</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>사업장주소</th>
|
||||
<td colSpan={3}>경기도 안성시 공업용지 오성길 45-22</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>전화</th>
|
||||
<td>031-983-5130</td>
|
||||
<th>팩스</th>
|
||||
<td>02-6911-6315</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>
|
||||
|
||||
{/* 제품구성 정보 */}
|
||||
{quote.items && quote.items.length > 0 && (
|
||||
<>
|
||||
<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]?.productName || '스크린셔터'}</td>
|
||||
<th>총 수량</th>
|
||||
<td>{quote.items.reduce((sum, item) => sum + (item.quantity || 0), 0)}개소</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>오픈사이즈</th>
|
||||
<td>{quote.items[0]?.openWidth}×{quote.items[0]?.openHeight}</td>
|
||||
<th>설치유형</th>
|
||||
<td>{quote.items[0]?.installType || '-'}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 품목 내역 */}
|
||||
{quoteItems.length > 0 && (
|
||||
<>
|
||||
<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>
|
||||
{quoteItems.map((item) => (
|
||||
<tr key={item.no}>
|
||||
<td style={{ textAlign: 'center' }}>{item.no}</td>
|
||||
<td>{item.itemName}</td>
|
||||
<td style={{ textAlign: 'center' }}>{item.spec || '-'}</td>
|
||||
<td style={{ textAlign: 'right' }}>{item.quantity}</td>
|
||||
<td style={{ textAlign: 'center' }}>{item.unit}</td>
|
||||
<td style={{ textAlign: 'right' }}>{formatAmount(item.unitPrice)}</td>
|
||||
<td style={{ textAlign: 'right', fontWeight: '600' }}>{formatAmount(item.totalPrice)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colSpan={6} style={{ textAlign: 'right', padding: '12px' }}>공급가액 합계</td>
|
||||
<td style={{ textAlign: 'right', padding: '12px' }}>{formatAmount(subtotal)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colSpan={6} style={{ textAlign: 'right', padding: '12px' }}>부가가치세 (10%)</td>
|
||||
<td style={{ textAlign: 'right', padding: '12px' }}>{formatAmount(vat)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colSpan={6} style={{ textAlign: 'right', padding: '12px', background: '#e0e0e0' }}>총 견적금액</td>
|
||||
<td style={{ textAlign: 'right', padding: '12px', background: '#e0e0e0', fontSize: '15px', fontWeight: '700' }}>
|
||||
{formatAmount(totalAmount)}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 비고사항 */}
|
||||
{quote.remarks && (
|
||||
<>
|
||||
<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 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' }}>
|
||||
공급자: 동호기업 (인)
|
||||
</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. 견적 유효기간은 {formatDate(quote.dueDate || '')}까지이며, 기간 경과 시 재견적이 필요합니다.</p>
|
||||
<p>3. 제작 사양 및 수량 변경 시 견적 금액이 변동될 수 있습니다.</p>
|
||||
<p>4. 현장 여건에 따라 추가 비용이 발생할 수 있습니다.</p>
|
||||
<p style={{ marginTop: '12px', textAlign: 'center', fontWeight: '600' }}>
|
||||
문의: {quote.writer || '담당자'} | {quote.contact || '031-983-5130'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
801
src/components/quotes/QuoteRegistration.tsx
Normal file
801
src/components/quotes/QuoteRegistration.tsx
Normal file
@@ -0,0 +1,801 @@
|
||||
/**
|
||||
* 견적 등록/수정 컴포넌트
|
||||
*
|
||||
* ResponsiveFormTemplate 적용
|
||||
* - 기본 정보 섹션
|
||||
* - 자동 견적 산출 섹션 (동적 항목 추가/삭제)
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Input } from "../ui/input";
|
||||
import { Textarea } from "../ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../ui/select";
|
||||
import { Button } from "../ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "../ui/card";
|
||||
import { Badge } from "../ui/badge";
|
||||
import {
|
||||
FileText,
|
||||
Calculator,
|
||||
Plus,
|
||||
Copy,
|
||||
Trash2,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
ResponsiveFormTemplate,
|
||||
FormSection,
|
||||
FormFieldGrid,
|
||||
} from "../templates/ResponsiveFormTemplate";
|
||||
import { FormField } from "../molecules/FormField";
|
||||
|
||||
// 견적 항목 타입
|
||||
export interface QuoteItem {
|
||||
id: string;
|
||||
floor: string; // 층수
|
||||
code: string; // 부호
|
||||
productCategory: string; // 제품 카테고리 (PC)
|
||||
productName: string; // 제품명
|
||||
openWidth: string; // 오픈사이즈 W0
|
||||
openHeight: string; // 오픈사이즈 H0
|
||||
guideRailType: string; // 가이드레일 설치 유형 (GT)
|
||||
motorPower: string; // 모터 전원 (MP)
|
||||
controller: string; // 연동제어기 (CT)
|
||||
quantity: number; // 수량 (QTY)
|
||||
wingSize: string; // 마구리 날개치수 (WS)
|
||||
inspectionFee: number; // 검사비 (INSP)
|
||||
}
|
||||
|
||||
// 견적 폼 데이터 타입
|
||||
export interface QuoteFormData {
|
||||
id?: string;
|
||||
registrationDate: string;
|
||||
writer: string;
|
||||
clientId: string;
|
||||
clientName: string;
|
||||
siteName: string; // 현장명 (직접 입력)
|
||||
manager: string;
|
||||
contact: string;
|
||||
dueDate: string;
|
||||
remarks: string;
|
||||
items: QuoteItem[];
|
||||
}
|
||||
|
||||
// 초기 견적 항목
|
||||
const createNewItem = (): QuoteItem => ({
|
||||
id: `item-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
floor: "",
|
||||
code: "",
|
||||
productCategory: "",
|
||||
productName: "",
|
||||
openWidth: "",
|
||||
openHeight: "",
|
||||
guideRailType: "",
|
||||
motorPower: "",
|
||||
controller: "",
|
||||
quantity: 1,
|
||||
wingSize: "50",
|
||||
inspectionFee: 50000,
|
||||
});
|
||||
|
||||
// 초기 폼 데이터
|
||||
export const INITIAL_QUOTE_FORM: QuoteFormData = {
|
||||
registrationDate: new Date().toISOString().split("T")[0],
|
||||
writer: "드미트리", // TODO: 로그인 사용자 정보로 대체
|
||||
clientId: "",
|
||||
clientName: "",
|
||||
siteName: "", // 현장명 (직접 입력)
|
||||
manager: "",
|
||||
contact: "",
|
||||
dueDate: "",
|
||||
remarks: "",
|
||||
items: [createNewItem()],
|
||||
};
|
||||
|
||||
// 샘플 발주처 데이터 (TODO: API에서 가져오기)
|
||||
const SAMPLE_CLIENTS = [
|
||||
{ id: "client-1", name: "인천건설 - 최담당" },
|
||||
{ id: "client-2", name: "ABC건설" },
|
||||
{ id: "client-3", name: "XYZ산업" },
|
||||
];
|
||||
|
||||
// 제품 카테고리 옵션
|
||||
const PRODUCT_CATEGORIES = [
|
||||
{ value: "screen", label: "스크린" },
|
||||
{ value: "steel", label: "철재" },
|
||||
{ value: "aluminum", label: "알루미늄" },
|
||||
{ value: "etc", label: "기타" },
|
||||
];
|
||||
|
||||
// 제품명 옵션 (카테고리별)
|
||||
const PRODUCTS: Record<string, { value: string; label: string }[]> = {
|
||||
screen: [
|
||||
{ value: "SCR-001", label: "스크린 A형" },
|
||||
{ value: "SCR-002", label: "스크린 B형" },
|
||||
{ value: "SCR-003", label: "스크린 C형" },
|
||||
],
|
||||
steel: [
|
||||
{ value: "STL-001", label: "철재 도어 A" },
|
||||
{ value: "STL-002", label: "철재 도어 B" },
|
||||
],
|
||||
aluminum: [
|
||||
{ value: "ALU-001", label: "알루미늄 프레임" },
|
||||
],
|
||||
etc: [
|
||||
{ value: "ETC-001", label: "기타 제품" },
|
||||
],
|
||||
};
|
||||
|
||||
// 가이드레일 설치 유형
|
||||
const GUIDE_RAIL_TYPES = [
|
||||
{ value: "wall", label: "벽부착형" },
|
||||
{ value: "ceiling", label: "천장매립형" },
|
||||
{ value: "floor", label: "바닥매립형" },
|
||||
];
|
||||
|
||||
// 모터 전원
|
||||
const MOTOR_POWERS = [
|
||||
{ value: "single", label: "단상 220V" },
|
||||
{ value: "three", label: "삼상 380V" },
|
||||
];
|
||||
|
||||
// 연동제어기
|
||||
const CONTROLLERS = [
|
||||
{ value: "basic", label: "기본 제어기" },
|
||||
{ value: "smart", label: "스마트 제어기" },
|
||||
{ value: "premium", label: "프리미엄 제어기" },
|
||||
];
|
||||
|
||||
interface QuoteRegistrationProps {
|
||||
onBack: () => void;
|
||||
onSave: (quote: QuoteFormData) => Promise<void>;
|
||||
editingQuote?: QuoteFormData | null;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function QuoteRegistration({
|
||||
onBack,
|
||||
onSave,
|
||||
editingQuote,
|
||||
isLoading = false,
|
||||
}: QuoteRegistrationProps) {
|
||||
const [formData, setFormData] = useState<QuoteFormData>(
|
||||
editingQuote || INITIAL_QUOTE_FORM
|
||||
);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [activeItemIndex, setActiveItemIndex] = useState(0);
|
||||
|
||||
// editingQuote가 변경되면 formData 업데이트
|
||||
useEffect(() => {
|
||||
if (editingQuote) {
|
||||
setFormData(editingQuote);
|
||||
}
|
||||
}, [editingQuote]);
|
||||
|
||||
// 유효성 검사
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
if (!formData.clientId) {
|
||||
newErrors.clientId = "발주처를 선택해주세요";
|
||||
}
|
||||
|
||||
// 견적 항목 검사
|
||||
formData.items.forEach((item, index) => {
|
||||
if (!item.productCategory) {
|
||||
newErrors[`item-${index}-productCategory`] = "제품 카테고리를 선택해주세요";
|
||||
}
|
||||
if (!item.productName) {
|
||||
newErrors[`item-${index}-productName`] = "제품명을 선택해주세요";
|
||||
}
|
||||
if (!item.openWidth) {
|
||||
newErrors[`item-${index}-openWidth`] = "오픈사이즈(W)를 입력해주세요";
|
||||
}
|
||||
if (!item.openHeight) {
|
||||
newErrors[`item-${index}-openHeight`] = "오픈사이즈(H)를 입력해주세요";
|
||||
}
|
||||
if (!item.guideRailType) {
|
||||
newErrors[`item-${index}-guideRailType`] = "설치 유형을 선택해주세요";
|
||||
}
|
||||
if (!item.motorPower) {
|
||||
newErrors[`item-${index}-motorPower`] = "모터 전원을 선택해주세요";
|
||||
}
|
||||
if (!item.controller) {
|
||||
newErrors[`item-${index}-controller`] = "제어기를 선택해주세요";
|
||||
}
|
||||
if (item.quantity < 1) {
|
||||
newErrors[`item-${index}-quantity`] = "수량은 1 이상이어야 합니다";
|
||||
}
|
||||
});
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!validateForm()) {
|
||||
toast.error("입력 내용을 확인해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await onSave(formData);
|
||||
toast.success(
|
||||
editingQuote ? "견적이 수정되었습니다." : "견적이 등록되었습니다."
|
||||
);
|
||||
onBack();
|
||||
} catch (error) {
|
||||
toast.error("저장 중 오류가 발생했습니다.");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFieldChange = (
|
||||
field: keyof QuoteFormData,
|
||||
value: string | QuoteItem[]
|
||||
) => {
|
||||
setFormData({ ...formData, [field]: value });
|
||||
if (errors[field]) {
|
||||
setErrors((prev) => {
|
||||
const newErrors = { ...prev };
|
||||
delete newErrors[field];
|
||||
return newErrors;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 발주처 선택
|
||||
const handleClientChange = (clientId: string) => {
|
||||
const client = SAMPLE_CLIENTS.find((c) => c.id === clientId);
|
||||
setFormData({
|
||||
...formData,
|
||||
clientId,
|
||||
clientName: client?.name || "",
|
||||
});
|
||||
};
|
||||
|
||||
// 견적 항목 변경
|
||||
const handleItemChange = (
|
||||
index: number,
|
||||
field: keyof QuoteItem,
|
||||
value: string | number
|
||||
) => {
|
||||
const newItems = [...formData.items];
|
||||
newItems[index] = { ...newItems[index], [field]: value };
|
||||
|
||||
// 제품 카테고리 변경 시 제품명 초기화
|
||||
if (field === "productCategory") {
|
||||
newItems[index].productName = "";
|
||||
}
|
||||
|
||||
setFormData({ ...formData, items: newItems });
|
||||
|
||||
// 에러 클리어
|
||||
const errorKey = `item-${index}-${field}`;
|
||||
if (errors[errorKey]) {
|
||||
setErrors((prev) => {
|
||||
const newErrors = { ...prev };
|
||||
delete newErrors[errorKey];
|
||||
return newErrors;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 견적 항목 추가
|
||||
const handleAddItem = () => {
|
||||
const newItems = [...formData.items, createNewItem()];
|
||||
setFormData({ ...formData, items: newItems });
|
||||
setActiveItemIndex(newItems.length - 1);
|
||||
};
|
||||
|
||||
// 견적 항목 복사
|
||||
const handleCopyItem = (index: number) => {
|
||||
const itemToCopy = formData.items[index];
|
||||
const newItem: QuoteItem = {
|
||||
...itemToCopy,
|
||||
id: `item-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
};
|
||||
const newItems = [...formData.items, newItem];
|
||||
setFormData({ ...formData, items: newItems });
|
||||
setActiveItemIndex(newItems.length - 1);
|
||||
toast.success("견적 항목이 복사되었습니다.");
|
||||
};
|
||||
|
||||
// 견적 항목 삭제
|
||||
const handleDeleteItem = (index: number) => {
|
||||
if (formData.items.length === 1) {
|
||||
toast.error("최소 1개의 견적 항목이 필요합니다.");
|
||||
return;
|
||||
}
|
||||
const newItems = formData.items.filter((_, i) => i !== index);
|
||||
setFormData({ ...formData, items: newItems });
|
||||
if (activeItemIndex >= newItems.length) {
|
||||
setActiveItemIndex(newItems.length - 1);
|
||||
}
|
||||
toast.success("견적 항목이 삭제되었습니다.");
|
||||
};
|
||||
|
||||
// 자동 견적 산출
|
||||
const handleAutoCalculate = () => {
|
||||
toast.info(`자동 견적 산출 (${formData.items.length}개 항목) - API 연동 필요`);
|
||||
};
|
||||
|
||||
// 샘플 데이터 생성
|
||||
const handleGenerateSample = () => {
|
||||
toast.info("완벽한 샘플 생성 - API 연동 필요");
|
||||
};
|
||||
|
||||
return (
|
||||
<ResponsiveFormTemplate
|
||||
title={editingQuote ? "견적 수정" : "견적 등록"}
|
||||
description=""
|
||||
icon={FileText}
|
||||
onSave={handleSubmit}
|
||||
onCancel={onBack}
|
||||
saveLabel="저장"
|
||||
cancelLabel="취소"
|
||||
isEditMode={!!editingQuote}
|
||||
saveLoading={isSaving || isLoading}
|
||||
saveDisabled={isSaving || isLoading}
|
||||
maxWidth="2xl"
|
||||
>
|
||||
{/* 1. 기본 정보 */}
|
||||
<FormSection
|
||||
title="기본 정보"
|
||||
description=""
|
||||
icon={FileText}
|
||||
>
|
||||
<FormFieldGrid columns={3}>
|
||||
<FormField label="등록일" htmlFor="registrationDate">
|
||||
<Input
|
||||
id="registrationDate"
|
||||
type="date"
|
||||
value={formData.registrationDate}
|
||||
disabled
|
||||
className="bg-gray-50"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="작성자" htmlFor="writer">
|
||||
<Input
|
||||
id="writer"
|
||||
value={formData.writer}
|
||||
disabled
|
||||
className="bg-gray-50"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="발주처 선택"
|
||||
required
|
||||
error={errors.clientId}
|
||||
htmlFor="clientId"
|
||||
>
|
||||
<Select
|
||||
value={formData.clientId}
|
||||
onValueChange={handleClientChange}
|
||||
>
|
||||
<SelectTrigger id="clientId">
|
||||
<SelectValue placeholder="발주처를 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SAMPLE_CLIENTS.map((client) => (
|
||||
<SelectItem key={client.id} value={client.id}>
|
||||
{client.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
</FormFieldGrid>
|
||||
|
||||
<FormFieldGrid columns={3}>
|
||||
<FormField label="현장명" htmlFor="siteName">
|
||||
<Input
|
||||
id="siteName"
|
||||
placeholder="현장명을 입력하세요"
|
||||
value={formData.siteName}
|
||||
onChange={(e) => handleFieldChange("siteName", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="발주 담당자" htmlFor="manager">
|
||||
<Input
|
||||
id="manager"
|
||||
placeholder="담당자명을 입력하세요"
|
||||
value={formData.manager}
|
||||
onChange={(e) => handleFieldChange("manager", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="연락처" htmlFor="contact">
|
||||
<Input
|
||||
id="contact"
|
||||
placeholder="010-1234-5678"
|
||||
value={formData.contact}
|
||||
onChange={(e) => handleFieldChange("contact", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
</FormFieldGrid>
|
||||
|
||||
<FormFieldGrid columns={3}>
|
||||
<FormField label="납기일" htmlFor="dueDate">
|
||||
<Input
|
||||
id="dueDate"
|
||||
type="date"
|
||||
value={formData.dueDate}
|
||||
onChange={(e) => handleFieldChange("dueDate", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<div className="col-span-2" />
|
||||
</FormFieldGrid>
|
||||
|
||||
<FormField label="비고" htmlFor="remarks">
|
||||
<Textarea
|
||||
id="remarks"
|
||||
placeholder="특이사항을 입력하세요"
|
||||
value={formData.remarks}
|
||||
onChange={(e) => handleFieldChange("remarks", e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</FormField>
|
||||
</FormSection>
|
||||
|
||||
{/* 2. 자동 견적 산출 */}
|
||||
<FormSection
|
||||
title="자동 견적 산출"
|
||||
description="입력값을 기반으로 견적을 자동으로 산출합니다"
|
||||
icon={Calculator}
|
||||
>
|
||||
{/* 견적 탭 */}
|
||||
<Card className="border-gray-200">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{formData.items.map((item, index) => (
|
||||
<Button
|
||||
key={item.id}
|
||||
variant={activeItemIndex === index ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setActiveItemIndex(index)}
|
||||
className="min-w-[70px]"
|
||||
>
|
||||
견적 {index + 1}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleCopyItem(activeItemIndex)}
|
||||
title="복사"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDeleteItem(activeItemIndex)}
|
||||
title="삭제"
|
||||
className="text-red-500 hover:text-red-600"
|
||||
disabled={formData.items.length === 1}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{formData.items[activeItemIndex] && (
|
||||
<>
|
||||
<FormFieldGrid columns={3}>
|
||||
<FormField label="층수" htmlFor={`floor-${activeItemIndex}`}>
|
||||
<Input
|
||||
id={`floor-${activeItemIndex}`}
|
||||
placeholder="예: 1층, B1, 지하1층"
|
||||
value={formData.items[activeItemIndex].floor}
|
||||
onChange={(e) =>
|
||||
handleItemChange(activeItemIndex, "floor", e.target.value)
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="부호" htmlFor={`code-${activeItemIndex}`}>
|
||||
<Input
|
||||
id={`code-${activeItemIndex}`}
|
||||
placeholder="예: A, B, C"
|
||||
value={formData.items[activeItemIndex].code}
|
||||
onChange={(e) =>
|
||||
handleItemChange(activeItemIndex, "code", e.target.value)
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="제품 카테고리 (PC)"
|
||||
required
|
||||
error={errors[`item-${activeItemIndex}-productCategory`]}
|
||||
htmlFor={`productCategory-${activeItemIndex}`}
|
||||
>
|
||||
<Select
|
||||
value={formData.items[activeItemIndex].productCategory}
|
||||
onValueChange={(value) =>
|
||||
handleItemChange(activeItemIndex, "productCategory", value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger id={`productCategory-${activeItemIndex}`}>
|
||||
<SelectValue placeholder="카테고리 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PRODUCT_CATEGORIES.map((cat) => (
|
||||
<SelectItem key={cat.value} value={cat.value}>
|
||||
{cat.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
</FormFieldGrid>
|
||||
|
||||
<FormFieldGrid columns={3}>
|
||||
<FormField
|
||||
label="제품명"
|
||||
required
|
||||
error={errors[`item-${activeItemIndex}-productName`]}
|
||||
htmlFor={`productName-${activeItemIndex}`}
|
||||
>
|
||||
<Select
|
||||
value={formData.items[activeItemIndex].productName}
|
||||
onValueChange={(value) =>
|
||||
handleItemChange(activeItemIndex, "productName", value)
|
||||
}
|
||||
disabled={!formData.items[activeItemIndex].productCategory}
|
||||
>
|
||||
<SelectTrigger id={`productName-${activeItemIndex}`}>
|
||||
<SelectValue placeholder="제품을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(PRODUCTS[formData.items[activeItemIndex].productCategory] || []).map((product) => (
|
||||
<SelectItem key={product.value} value={product.value}>
|
||||
{product.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="오픈사이즈 (W0)"
|
||||
required
|
||||
error={errors[`item-${activeItemIndex}-openWidth`]}
|
||||
htmlFor={`openWidth-${activeItemIndex}`}
|
||||
>
|
||||
<Input
|
||||
id={`openWidth-${activeItemIndex}`}
|
||||
placeholder="예: 2000"
|
||||
value={formData.items[activeItemIndex].openWidth}
|
||||
onChange={(e) =>
|
||||
handleItemChange(activeItemIndex, "openWidth", e.target.value)
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="오픈사이즈 (H0)"
|
||||
required
|
||||
error={errors[`item-${activeItemIndex}-openHeight`]}
|
||||
htmlFor={`openHeight-${activeItemIndex}`}
|
||||
>
|
||||
<Input
|
||||
id={`openHeight-${activeItemIndex}`}
|
||||
placeholder="예: 2500"
|
||||
value={formData.items[activeItemIndex].openHeight}
|
||||
onChange={(e) =>
|
||||
handleItemChange(activeItemIndex, "openHeight", e.target.value)
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
</FormFieldGrid>
|
||||
|
||||
<FormFieldGrid columns={3}>
|
||||
<FormField
|
||||
label="가이드레일 설치 유형 (GT)"
|
||||
required
|
||||
error={errors[`item-${activeItemIndex}-guideRailType`]}
|
||||
htmlFor={`guideRailType-${activeItemIndex}`}
|
||||
>
|
||||
<Select
|
||||
value={formData.items[activeItemIndex].guideRailType}
|
||||
onValueChange={(value) =>
|
||||
handleItemChange(activeItemIndex, "guideRailType", value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger id={`guideRailType-${activeItemIndex}`}>
|
||||
<SelectValue placeholder="설치 유형 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{GUIDE_RAIL_TYPES.map((type) => (
|
||||
<SelectItem key={type.value} value={type.value}>
|
||||
{type.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="모터 전원 (MP)"
|
||||
required
|
||||
error={errors[`item-${activeItemIndex}-motorPower`]}
|
||||
htmlFor={`motorPower-${activeItemIndex}`}
|
||||
>
|
||||
<Select
|
||||
value={formData.items[activeItemIndex].motorPower}
|
||||
onValueChange={(value) =>
|
||||
handleItemChange(activeItemIndex, "motorPower", value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger id={`motorPower-${activeItemIndex}`}>
|
||||
<SelectValue placeholder="전원 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MOTOR_POWERS.map((power) => (
|
||||
<SelectItem key={power.value} value={power.value}>
|
||||
{power.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="연동제어기 (CT)"
|
||||
required
|
||||
error={errors[`item-${activeItemIndex}-controller`]}
|
||||
htmlFor={`controller-${activeItemIndex}`}
|
||||
>
|
||||
<Select
|
||||
value={formData.items[activeItemIndex].controller}
|
||||
onValueChange={(value) =>
|
||||
handleItemChange(activeItemIndex, "controller", value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger id={`controller-${activeItemIndex}`}>
|
||||
<SelectValue placeholder="제어기 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CONTROLLERS.map((ctrl) => (
|
||||
<SelectItem key={ctrl.value} value={ctrl.value}>
|
||||
{ctrl.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
</FormFieldGrid>
|
||||
|
||||
<FormFieldGrid columns={3}>
|
||||
<FormField
|
||||
label="수량 (QTY)"
|
||||
required
|
||||
error={errors[`item-${activeItemIndex}-quantity`]}
|
||||
htmlFor={`quantity-${activeItemIndex}`}
|
||||
>
|
||||
<Input
|
||||
id={`quantity-${activeItemIndex}`}
|
||||
type="number"
|
||||
min="1"
|
||||
value={formData.items[activeItemIndex].quantity}
|
||||
onChange={(e) =>
|
||||
handleItemChange(activeItemIndex, "quantity", parseInt(e.target.value) || 1)
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="마구리 날개치수 (WS)"
|
||||
htmlFor={`wingSize-${activeItemIndex}`}
|
||||
>
|
||||
<Input
|
||||
id={`wingSize-${activeItemIndex}`}
|
||||
placeholder="예: 50"
|
||||
value={formData.items[activeItemIndex].wingSize}
|
||||
onChange={(e) =>
|
||||
handleItemChange(activeItemIndex, "wingSize", e.target.value)
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="검사비 (INSP)"
|
||||
htmlFor={`inspectionFee-${activeItemIndex}`}
|
||||
>
|
||||
<Input
|
||||
id={`inspectionFee-${activeItemIndex}`}
|
||||
type="number"
|
||||
placeholder="예: 50000"
|
||||
value={formData.items[activeItemIndex].inspectionFee}
|
||||
onChange={(e) =>
|
||||
handleItemChange(activeItemIndex, "inspectionFee", parseInt(e.target.value) || 0)
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
</FormFieldGrid>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 견적 추가 버튼 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={handleAddItem}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
견적 추가
|
||||
</Button>
|
||||
|
||||
{/* 자동 견적 산출 버튼 */}
|
||||
<Button
|
||||
variant="default"
|
||||
className="w-full bg-blue-600 hover:bg-blue-700"
|
||||
onClick={handleAutoCalculate}
|
||||
>
|
||||
자동 견적 산출 ({formData.items.length}개 항목)
|
||||
</Button>
|
||||
</FormSection>
|
||||
|
||||
{/* 3. 샘플 데이터 (개발용) */}
|
||||
<Card className="border-blue-200 bg-blue-50/50">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-sm font-medium">
|
||||
견적 산출 샘플 데이터 (완전판)
|
||||
</CardTitle>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
자동 견적 산출 기능을 테스트하기 위한 완벽한 샘플 데이터를 생성합니다.
|
||||
완제품 14종(스크린 5종, 철재 5종, 절곡 4종), 반제품 40종, 부자재 25종, 원자재 20종이
|
||||
생성되며, 모든 제품에 실제 BOM 구조(2~3단계 계층)와 단가 정보가 포함되어
|
||||
즉시 견적 산출이 가능합니다.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleGenerateSample}
|
||||
className="bg-white"
|
||||
>
|
||||
<Sparkles className="h-4 w-4 mr-2" />
|
||||
완벽한 샘플 생성
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="secondary">완제품 14종</Badge>
|
||||
<Badge variant="secondary">반제품 40종</Badge>
|
||||
<Badge variant="secondary">부자재 25종</Badge>
|
||||
<Badge variant="secondary">원자재 20종</Badge>
|
||||
<Badge variant="outline">BOM 2~3단계 계층 ▾</Badge>
|
||||
<Badge variant="outline">단가 정보 포함 ▾</Badge>
|
||||
<Badge variant="outline">절곡 제품 포함 ▾</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</ResponsiveFormTemplate>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user