- 입찰관리: 목록/상세/수정 페이지 및 목업 데이터 - 계약관리: 목록/상세/수정 페이지 구현 - 주문관리: 수주/발주 목록 및 상세 페이지 구현 - 견적 상세 폼: 섹션별 분리 및 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>
410 lines
20 KiB
TypeScript
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>
|
|
);
|
|
} |