Files
sam-react-prod/src/components/business/juil/estimates/modals/EstimateDocumentModal.tsx
byeongcheolryu 386cd30bc0 feat(WEB): 입찰/계약/주문관리 기능 추가 및 견적 상세 리팩토링
- 입찰관리: 목록/상세/수정 페이지 및 목업 데이터
- 계약관리: 목록/상세/수정 페이지 구현
- 주문관리: 수주/발주 목록 및 상세 페이지 구현
- 견적 상세 폼: 섹션별 분리 및 hooks/utils 리팩토링
- 품목관리, 카테고리관리, 단가관리 기능 추가
- 현장설명회/협력업체 폼 개선
- 프린트 유틸리티 공통화 (print-utils.ts)
- 문서 모달 공통 컴포넌트 정리
- IntegratedListTemplateV2, StatCards 개선

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 18:59:04 +09:00

410 lines
20 KiB
TypeScript

'use client';
import { useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { Printer, Pencil, Send, X as XIcon } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
VisuallyHidden,
DialogTitle,
} from '@/components/ui/dialog';
import { printArea } from '@/lib/print-utils';
import type { EstimateDetailFormData } from '../types';
// 금액 포맷팅
function formatAmount(amount: number): string {
return new Intl.NumberFormat('ko-KR').format(amount);
}
// 금액을 한글로 변환
function amountToKorean(amount: number): string {
const units = ['', '만', '억', '조'];
const smallUnits = ['', '십', '백', '천'];
const digits = ['', '일', '이', '삼', '사', '오', '육', '칠', '팔', '구'];
if (amount === 0) return '영';
let result = '';
let unitIndex = 0;
while (amount > 0) {
const segment = amount % 10000;
if (segment > 0) {
let segmentStr = '';
let segmentNum = segment;
for (let i = 0; i < 4 && segmentNum > 0; i++) {
const digit = segmentNum % 10;
if (digit > 0) {
segmentStr = digits[digit] + smallUnits[i] + segmentStr;
}
segmentNum = Math.floor(segmentNum / 10);
}
result = segmentStr + units[unitIndex] + result;
}
amount = Math.floor(amount / 10000);
unitIndex++;
}
return '(금)' + result;
}
interface EstimateDocumentModalProps {
isOpen: boolean;
onClose: () => void;
formData: EstimateDetailFormData;
estimateId?: string;
}
export function EstimateDocumentModal({
isOpen,
onClose,
formData,
estimateId,
}: EstimateDocumentModalProps) {
const router = useRouter();
// 인쇄
const handlePrint = useCallback(() => {
printArea({ title: '견적서 인쇄' });
}, []);
// 수정 페이지로 이동
const handleEdit = useCallback(() => {
if (estimateId) {
onClose();
router.push(`/ko/juil/project/bidding/estimates/${estimateId}/edit`);
}
}, [estimateId, onClose, router]);
// 견적서 문서 데이터
const documentData = {
documentNo: formData.estimateCode || 'ABC123',
createdDate: formData.siteBriefing.briefingDate || '2025년 11월 11일',
recipient: formData.siteBriefing.partnerName || '',
companyName: formData.siteBriefing.companyName || '(주) 주일기업',
projectName: formData.bidInfo.projectName || '',
address: '주소',
amount: formData.summaryItems.reduce((sum, item) => sum + item.totalCost, 0),
date: formData.bidInfo.bidDate || '2025년 12월 12일',
contact: {
hp: '010-3679-2188',
tel: '(02) 849-5130',
fax: '(02) 6911-6315',
},
note: '하기와 같이 보내합니다.',
};
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-[95vw] md:max-w-[800px] lg:max-w-[900px] h-[95vh] p-0 flex flex-col gap-0 overflow-hidden [&>button]:hidden">
<VisuallyHidden>
<DialogTitle> </DialogTitle>
</VisuallyHidden>
{/* 헤더 영역 - 고정 (인쇄 시 숨김) */}
<div className="print-hidden flex-shrink-0 flex items-center justify-between px-6 py-4 border-b bg-white">
<h2 className="text-lg font-semibold"> </h2>
<Button
variant="ghost"
size="icon"
onClick={onClose}
className="h-8 w-8"
>
<XIcon className="h-4 w-4" />
</Button>
</div>
{/* 버튼 영역 - 고정 (인쇄 시 숨김) */}
<div className="print-hidden flex-shrink-0 flex items-center gap-2 px-6 py-3 bg-muted/30 border-b">
<Button variant="outline" size="sm" onClick={handleEdit} disabled={!estimateId}>
<Pencil className="h-4 w-4 mr-1" />
</Button>
<Button variant="default" size="sm" className="bg-blue-600 hover:bg-blue-700">
<Send className="h-4 w-4 mr-1" />
</Button>
<Button variant="outline" size="sm" onClick={handlePrint}>
<Printer className="h-4 w-4 mr-1" />
</Button>
</div>
{/* 문서 영역 - 스크롤 (인쇄 시 이 영역만 출력) */}
<div className="print-area flex-1 overflow-y-auto bg-gray-100 p-6">
<div className="max-w-[210mm] mx-auto bg-white shadow-lg rounded-lg p-8">
{/* 상단: 제목 + 결재란 */}
<div className="flex justify-between items-start mb-4">
{/* 제목 영역 */}
<div className="flex-1">
<h1 className="text-3xl font-bold text-center tracking-[0.3em]"> </h1>
{/* 문서번호 및 작성일자 */}
<div className="text-sm mt-4 text-center">
<span className="mr-4">: {documentData.documentNo}</span>
<span className="mx-2">|</span>
<span className="ml-4">: {documentData.createdDate}</span>
</div>
</div>
{/* 결재란 (상단 우측) - 3열 3행 */}
<table className="text-xs border-collapse border border-gray-400 ml-4">
<tbody>
<tr>
<td className="border border-gray-400 w-10"></td>
<td className="border border-gray-400 px-4 py-1 text-center whitespace-nowrap"></td>
<td className="border border-gray-400 px-4 py-1 text-center whitespace-nowrap"></td>
</tr>
<tr>
<td className="border border-gray-400 px-2 py-3 text-center whitespace-nowrap"></td>
<td className="border border-gray-400 px-4 py-3 text-center whitespace-nowrap"></td>
<td className="border border-gray-400 px-4 py-3 text-center whitespace-nowrap"></td>
</tr>
<tr>
<td className="border border-gray-400"></td>
<td className="border border-gray-400 px-4 py-1 text-center whitespace-nowrap"></td>
<td className="border border-gray-400 px-4 py-1 text-center whitespace-nowrap"></td>
</tr>
</tbody>
</table>
</div>
{/* 기본 정보 테이블 */}
<table className="w-full border-collapse mb-6 text-sm">
<tbody>
<tr>
<td className="border border-gray-400 px-3 py-2 bg-gray-100 w-20 text-center"></td>
<td className="border border-gray-400 px-3 py-2 w-1/4"></td>
<td className="border border-gray-400 px-3 py-2 bg-gray-100 w-20 text-center"></td>
<td className="border border-gray-400 px-3 py-2 w-1/4">{documentData.companyName}</td>
</tr>
<tr>
<td className="border border-gray-400 px-3 py-2 bg-gray-100 text-center"></td>
<td className="border border-gray-400 px-3 py-2">{documentData.projectName || '현장명'}</td>
<td className="border border-gray-400 px-3 py-2 bg-gray-100 text-center"></td>
<td className="border border-gray-400 px-3 py-2">{documentData.address || '주소명'}</td>
</tr>
<tr>
<td className="border border-gray-400 px-3 py-2 bg-gray-100 text-center"></td>
<td className="border border-gray-400 px-3 py-2">
{amountToKorean(documentData.amount)}
</td>
<td className="border border-gray-400 px-3 py-2 bg-gray-100 text-center"></td>
<td className="border border-gray-400 px-3 py-2">{documentData.date}</td>
</tr>
<tr>
<td className="border border-gray-400 px-3 py-2 bg-gray-100 text-center"></td>
<td className="border border-gray-400 px-3 py-2"></td>
<td className="border border-gray-400 px-3 py-2 bg-gray-100 text-center"></td>
<td className="border border-gray-400 px-3 py-2">
<div className="space-y-0.5 text-xs">
<div>H . P : {documentData.contact.hp}</div>
<div>T E L : {documentData.contact.tel}</div>
<div>F A X : {documentData.contact.fax}</div>
</div>
</td>
</tr>
</tbody>
</table>
{/* 안내 문구 */}
<p className="text-sm mb-6"> .</p>
{/* 견적 요약 테이블 */}
<div className="mb-6">
<div className="mb-2">
<span className="text-sm font-medium"> </span>
</div>
<table className="w-full border-collapse text-sm">
<thead>
<tr className="bg-gray-100">
<th className="border border-gray-400 px-3 py-2"> </th>
<th className="border border-gray-400 px-3 py-2 w-16"></th>
<th className="border border-gray-400 px-3 py-2 w-16"></th>
<th className="border border-gray-400 px-3 py-2 w-24"> </th>
<th className="border border-gray-400 px-3 py-2 w-24"> </th>
<th className="border border-gray-400 px-3 py-2 w-24"> </th>
<th className="border border-gray-400 px-3 py-2 w-20"> </th>
</tr>
</thead>
<tbody>
{formData.summaryItems.length === 0 ? (
<tr>
<td colSpan={7} className="border border-gray-400 px-3 py-4 text-center text-gray-500">
.
</td>
</tr>
) : (
formData.summaryItems.map((item) => (
<tr key={item.id}>
<td className="border border-gray-400 px-3 py-2">{item.name}</td>
<td className="border border-gray-400 px-3 py-2 text-center">
{item.quantity}
</td>
<td className="border border-gray-400 px-3 py-2 text-center">
{item.unit}
</td>
<td className="border border-gray-400 px-3 py-2 text-right">
{formatAmount(item.materialCost)}
</td>
<td className="border border-gray-400 px-3 py-2 text-right">
{formatAmount(item.laborCost)}
</td>
<td className="border border-gray-400 px-3 py-2 text-right font-medium">
{formatAmount(item.totalCost)}
</td>
<td className="border border-gray-400 px-3 py-2">{item.remarks}</td>
</tr>
))
)}
{/* 합계 행 */}
<tr className="font-medium">
<td className="border border-gray-400 px-3 py-2 text-center" colSpan={3}>
</td>
<td className="border border-gray-400 px-3 py-2 text-right">
{formatAmount(
formData.summaryItems.reduce((sum, item) => sum + item.materialCost, 0)
)}
</td>
<td className="border border-gray-400 px-3 py-2 text-right">
{formatAmount(
formData.summaryItems.reduce((sum, item) => sum + item.laborCost, 0)
)}
</td>
<td className="border border-gray-400 px-3 py-2 text-right">
{formatAmount(
formData.summaryItems.reduce((sum, item) => sum + item.totalCost, 0)
)}
</td>
<td className="border border-gray-400 px-3 py-2"></td>
</tr>
{/* 특기사항 행 */}
<tr>
<td colSpan={7} className="border border-gray-400 px-3 py-2 text-sm">
* 특기사항 : 부가세 /
</td>
</tr>
</tbody>
</table>
</div>
{/* 견적 상세 테이블 */}
<div className="mb-6">
<div className="mb-2">
<span className="text-sm font-medium"> </span>
</div>
<table className="w-full border-collapse text-xs">
<thead>
<tr className="bg-gray-100">
<th className="border border-gray-400 px-2 py-1 w-8" rowSpan={2}>NO</th>
<th className="border border-gray-400 px-2 py-1" rowSpan={2}></th>
<th className="border border-gray-400 px-2 py-1" rowSpan={2}></th>
<th className="border border-gray-400 px-2 py-1" colSpan={2}> (mm)</th>
<th className="border border-gray-400 px-2 py-1" rowSpan={2}></th>
<th className="border border-gray-400 px-2 py-1" rowSpan={2}></th>
<th className="border border-gray-400 px-2 py-1" colSpan={2}> </th>
<th className="border border-gray-400 px-2 py-1" colSpan={2}> </th>
<th className="border border-gray-400 px-2 py-1" colSpan={2}> </th>
</tr>
<tr className="bg-gray-100">
<th className="border border-gray-400 px-2 py-1">(W)</th>
<th className="border border-gray-400 px-2 py-1">(H)</th>
<th className="border border-gray-400 px-2 py-1"></th>
<th className="border border-gray-400 px-2 py-1"></th>
<th className="border border-gray-400 px-2 py-1"></th>
<th className="border border-gray-400 px-2 py-1"></th>
<th className="border border-gray-400 px-2 py-1"></th>
<th className="border border-gray-400 px-2 py-1"></th>
</tr>
</thead>
<tbody>
{formData.detailItems.length === 0 ? (
<tr>
<td colSpan={13} className="border border-gray-400 px-3 py-4 text-center text-gray-500">
.
</td>
</tr>
) : (
formData.detailItems.map((item, index) => (
<tr key={item.id}>
<td className="border border-gray-400 px-2 py-1 text-center">
{index + 1}
</td>
<td className="border border-gray-400 px-2 py-1">{item.name}</td>
<td className="border border-gray-400 px-2 py-1">{item.material}</td>
<td className="border border-gray-400 px-2 py-1 text-right">
{formatAmount(item.width)}
</td>
<td className="border border-gray-400 px-2 py-1 text-right">
{formatAmount(item.height)}
</td>
<td className="border border-gray-400 px-2 py-1 text-center">
{item.quantity}
</td>
<td className="border border-gray-400 px-2 py-1 text-center">SET</td>
<td className="border border-gray-400 px-2 py-1 text-right">
{formatAmount(item.unitPrice)}
</td>
<td className="border border-gray-400 px-2 py-1 text-right">
{formatAmount(item.materialCost)}
</td>
<td className="border border-gray-400 px-2 py-1 text-right">
{formatAmount(item.laborCost)}
</td>
<td className="border border-gray-400 px-2 py-1 text-right">
{formatAmount(item.laborCost * item.quantity)}
</td>
<td className="border border-gray-400 px-2 py-1 text-right">
{formatAmount(item.totalPrice)}
</td>
<td className="border border-gray-400 px-2 py-1 text-right">
{formatAmount(item.totalCost)}
</td>
</tr>
))
)}
{/* 합계 행 */}
<tr className="font-medium">
<td className="border border-gray-400 px-2 py-1 text-center" colSpan={5}>
</td>
<td className="border border-gray-400 px-2 py-1 text-center">
{formData.detailItems.reduce((sum, item) => sum + item.quantity, 0)}
</td>
<td className="border border-gray-400 px-2 py-1 text-center">SET</td>
<td className="border border-gray-400 px-2 py-1"></td>
<td className="border border-gray-400 px-2 py-1 text-right">
{formatAmount(
formData.detailItems.reduce((sum, item) => sum + item.materialCost, 0)
)}
</td>
<td className="border border-gray-400 px-2 py-1"></td>
<td className="border border-gray-400 px-2 py-1 text-right">
{formatAmount(
formData.detailItems.reduce((sum, item) => sum + item.laborCost * item.quantity, 0)
)}
</td>
<td className="border border-gray-400 px-2 py-1"></td>
<td className="border border-gray-400 px-2 py-1 text-right">
{formatAmount(
formData.detailItems.reduce((sum, item) => sum + item.totalCost, 0)
)}
</td>
</tr>
{/* 비고 행 */}
<tr>
<td colSpan={13} className="border border-gray-400 px-2 py-1 text-sm">
* :
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}