diff options
Diffstat (limited to 'lib/po')
| -rw-r--r-- | lib/po/vendor-table/service.ts | 38 | ||||
| -rw-r--r-- | lib/po/vendor-table/shi-vendor-po-table.tsx | 1 | ||||
| -rw-r--r-- | lib/po/vendor-table/types.ts | 14 | ||||
| -rw-r--r-- | lib/po/vendor-table/vendor-po-actions.tsx | 2 | ||||
| -rw-r--r-- | lib/po/vendor-table/vendor-po-items-dialog.tsx | 131 | ||||
| -rw-r--r-- | lib/po/vendor-table/vendor-po-print-dialog.tsx | 514 | ||||
| -rw-r--r-- | lib/po/vendor-table/vendor-po-table.tsx | 53 |
7 files changed, 727 insertions, 26 deletions
diff --git a/lib/po/vendor-table/service.ts b/lib/po/vendor-table/service.ts index bc4a693b..e73a6083 100644 --- a/lib/po/vendor-table/service.ts +++ b/lib/po/vendor-table/service.ts @@ -7,6 +7,7 @@ import { contracts, contractItems, ContractStatus } from "@/db/schema/contract"; import { projects } from "@/db/schema/projects"; import { vendors } from "@/db/schema/vendors"; import { items } from "@/db/schema/items"; +import { users } from "@/db/schema/users"; import { revalidatePath } from "next/cache"; import { eq, and, or, ilike, count, desc, asc, SQL } from "drizzle-orm"; @@ -223,14 +224,19 @@ export async function getVendorPOById(id: number): Promise<VendorPO | null> { id: contracts.id, contractNo: contracts.contractNo, contractName: contracts.contractName, + contractContent: contracts.contractContent, + remarks: contracts.remarks, status: contracts.status, startDate: contracts.startDate, endDate: contracts.endDate, + deliveryDate: contracts.deliveryDate, currency: contracts.currency, totalAmount: contracts.totalAmount, totalAmountKrw: contracts.totalAmountKrw, paymentTerms: contracts.paymentTerms, deliveryTerms: contracts.deliveryTerms, + deliveryLocation: contracts.deliveryLocation, + shippmentPlace: contracts.shippmentPlace, exchangeRate: contracts.exchangeRate, poVersion: contracts.poVersion, purchaseDocType: contracts.purchaseDocType, @@ -255,10 +261,25 @@ export async function getVendorPOById(id: number): Promise<VendorPO | null> { contractVersion: contracts.contractVersion, createdAt: contracts.createdAt, updatedAt: contracts.updatedAt, + + // 프로젝트 정보 projectName: projects.name, + projectCode: projects.code, + + // 벤더 정보 + vendorName: vendors.vendorName, + vendorAddress: vendors.address, + vendorAddressDetail: vendors.addressDetail, + vendorPhone: vendors.phone, + vendorEmail: vendors.email, + + // 구매담당자 정보 (purchaseGroup으로 조회) + purchaseManagerName: users.name, }) .from(contracts) .leftJoin(projects, eq(contracts.projectId, projects.id)) + .leftJoin(vendors, eq(contracts.vendorId, vendors.id)) + .leftJoin(users, eq(contracts.purchaseGroup, users.userCode)) .where(eq(contracts.id, id)) .limit(1); @@ -274,7 +295,10 @@ export async function getVendorPOById(id: number): Promise<VendorPO | null> { contractType: row.purchaseDocType || '', details: '상세보기', projectName: row.projectName || '', + projectCode: row.projectCode || undefined, contractName: row.contractName || '', + contractContent: row.contractContent || undefined, + remarks: row.remarks || undefined, contractPeriod: row.startDate && row.endDate ? `${row.startDate} ~ ${row.endDate}` : '', contractQuantity: '1 LOT', currency: row.currency || 'KRW', @@ -282,7 +306,7 @@ export async function getVendorPOById(id: number): Promise<VendorPO | null> { tax: '10%', exchangeRate: row.exchangeRate?.toString() || '', deliveryTerms: row.deliveryTerms || '', - purchaseManager: '', + purchaseManager: row.purchaseManagerName || '', poReceiveDate: row.createdAt?.toISOString().split('T')[0] || '', contractDate: row.startDate || '', lcNo: undefined, @@ -290,6 +314,18 @@ export async function getVendorPOById(id: number): Promise<VendorPO | null> { linkedContractNo: undefined, lastModifiedDate: row.updatedAt?.toISOString().split('T')[0] || '', lastModifiedBy: '', + + // 벤더 정보 + vendorName: row.vendorName || undefined, + vendorAddress: row.vendorAddress || undefined, + vendorAddressDetail: row.vendorAddressDetail || undefined, + vendorPhone: row.vendorPhone || undefined, + vendorEmail: row.vendorEmail || undefined, + + // 구매담당자 정보 + purchaseManagerName: row.purchaseManagerName || undefined, + + // SAP 필드 poVersion: row.poVersion || undefined, purchaseDocType: row.purchaseDocType || undefined, purchaseOrg: row.purchaseOrg || undefined, diff --git a/lib/po/vendor-table/shi-vendor-po-table.tsx b/lib/po/vendor-table/shi-vendor-po-table.tsx index 6c5f62ef..6567bf5a 100644 --- a/lib/po/vendor-table/shi-vendor-po-table.tsx +++ b/lib/po/vendor-table/shi-vendor-po-table.tsx @@ -218,6 +218,7 @@ export function ShiVendorPoTable({ promises }: ShiVendorPoTableProps) { open={itemsDialogOpen} onOpenChange={setItemsDialogOpen} po={selectedPO} + viewerType="evcp" /> </> diff --git a/lib/po/vendor-table/types.ts b/lib/po/vendor-table/types.ts index e318ff39..fbed2387 100644 --- a/lib/po/vendor-table/types.ts +++ b/lib/po/vendor-table/types.ts @@ -13,7 +13,8 @@ export interface VendorPO { contractStatus: string // 계약상태 (ZPO_CNFM_STAT) contractType: string // 계약종류 (BSART) details: string // 상세 (mock 데이터용) - projectName: string // 프로젝트 + projectName: string // 프로젝트 이름 + projectCode?: string // 프로젝트 코드 (PSPID) contractName: string // 계약명/자재내역 (ZTITLE) contractPeriod: string // PO/계약기간 contractQuantity: string // PO/계약수량 @@ -70,6 +71,17 @@ export interface VendorPO { contractContent?: string // 계약서 내용 (ZMM_NOTE에서 추출) remarks?: string // 비고 (ECC에서 추가 정보) + // 벤더 정보 (발주서 출력용) + vendorName?: string // 공급자명 + vendorAddress?: string // 공급자 주소 + vendorAddressDetail?: string // 공급자 상세주소 + vendorPhone?: string // 공급자 전화번호 + vendorFax?: string // 공급자 팩스번호 + vendorEmail?: string // 공급자 이메일 + + // 구매담당자 정보 (발주서 출력용) + purchaseManagerName?: string // 구매담당자 이름 + // 상세품목 정보 (다이얼로그에서 표시) items?: VendorPOItem[] } diff --git a/lib/po/vendor-table/vendor-po-actions.tsx b/lib/po/vendor-table/vendor-po-actions.tsx index 08fe3f88..e36b745b 100644 --- a/lib/po/vendor-table/vendor-po-actions.tsx +++ b/lib/po/vendor-table/vendor-po-actions.tsx @@ -199,7 +199,7 @@ export function VendorPOActions({ row, setRowAction }: VendorPOActionsProps) { disabled={isLoading} > <PrinterIcon className="mr-2 h-4 w-4" /> - 계약서출력 + 발주서 출력 </DropdownMenuItem> <DropdownMenuItem diff --git a/lib/po/vendor-table/vendor-po-items-dialog.tsx b/lib/po/vendor-table/vendor-po-items-dialog.tsx index d88d88d1..8b984812 100644 --- a/lib/po/vendor-table/vendor-po-items-dialog.tsx +++ b/lib/po/vendor-table/vendor-po-items-dialog.tsx @@ -16,22 +16,122 @@ import { TableHeader, TableRow, } from "@/components/ui/table" +import { Button } from "@/components/ui/button" import { Skeleton } from "@/components/ui/skeleton" +import { Download, FileText } from "lucide-react" +import { toast } from "sonner" import { VendorPO, VendorPOItem } from "./types" import { getVendorPOItemsByContractNo } from "./service" import { formatNumber } from "@/lib/utils" +import { getDownloadUrlByMaterialCode, checkPosFileExists } from "@/lib/pos" +import { PosFileSelectionDialog } from "@/lib/pos/components/pos-file-selection-dialog" interface VendorPOItemsDialogProps { open: boolean onOpenChange: (open: boolean) => void po: VendorPO | null + /** + * 뷰어 타입 + * - 'evcp': EVCP 사용자 (암호화된 파일 직접 다운로드) + * - 'partners': 협력사 사용자 (복호화된 파일 다운로드) + */ + viewerType?: 'evcp' | 'partners' } -export function VendorPOItemsDialog({ open, onOpenChange, po }: VendorPOItemsDialogProps) { +export function VendorPOItemsDialog({ open, onOpenChange, po, viewerType = 'partners' }: VendorPOItemsDialogProps) { const [items, setItems] = React.useState<VendorPOItem[]>([]) const [loading, setLoading] = React.useState(false) const [error, setError] = React.useState<string | null>(null) + // POS 파일 선택 다이얼로그 상태 + const [posDialogOpen, setPosDialogOpen] = React.useState(false) + const [selectedMaterialCode, setSelectedMaterialCode] = React.useState<string>("") + const [posFiles, setPosFiles] = React.useState<Array<{ + fileName: string + dcmtmId: string + projNo: string + posNo: string + posRevNo: string + fileSer: string + }>>([]) + const [loadingPosFiles, setLoadingPosFiles] = React.useState(false) + const [downloadingFileIndex, setDownloadingFileIndex] = React.useState<number | null>(null) + + // POS 파일 목록 조회 및 다이얼로그 열기 + const handleOpenPosDialog = async (materialCode: string) => { + if (!materialCode) { + toast.error("자재코드가 없습니다") + return + } + + setLoadingPosFiles(true) + setSelectedMaterialCode(materialCode) + + try { + toast.loading(`POS 파일 목록 조회 중... (${materialCode})`, { id: `pos-check-${materialCode}` }) + + const result = await checkPosFileExists(materialCode) + + if (result.exists && result.files && result.files.length > 0) { + // 파일 정보를 상세하게 가져오기 위해 getDownloadUrlByMaterialCode 사용 + const detailResult = await getDownloadUrlByMaterialCode(materialCode) + + if (detailResult.success && detailResult.availableFiles) { + setPosFiles(detailResult.availableFiles) + setPosDialogOpen(true) + toast.success(`${result.fileCount}개의 POS 파일을 찾았습니다`, { id: `pos-check-${materialCode}` }) + } else { + toast.error('POS 파일 정보를 가져올 수 없습니다', { id: `pos-check-${materialCode}` }) + } + } else { + toast.error(result.error || 'POS 파일을 찾을 수 없습니다', { id: `pos-check-${materialCode}` }) + } + } catch (error) { + console.error("POS 파일 조회 오류:", error) + toast.error("POS 파일 조회에 실패했습니다", { id: `pos-check-${materialCode}` }) + } finally { + setLoadingPosFiles(false) + } + } + + // POS 파일 다운로드 실행 + const handleDownloadPosFile = async (fileIndex: number, fileName: string) => { + if (!selectedMaterialCode) return + + setDownloadingFileIndex(fileIndex) + + try { + toast.loading(`POS 파일 다운로드 준비 중...`, { id: `download-${fileIndex}` }) + + // viewerType에 따라 다른 엔드포인트 사용 + const endpoint = viewerType === 'partners' + ? `/api/pos/download-on-demand-partners` // 복호화 포함 + : `/api/pos/download-on-demand` // 암호화 파일 그대로 + + const downloadUrl = `${endpoint}?materialCode=${encodeURIComponent(selectedMaterialCode)}&fileIndex=${fileIndex}` + + toast.success(`POS 파일 다운로드 시작: ${fileName}`, { id: `download-${fileIndex}` }) + window.open(downloadUrl, '_blank', 'noopener,noreferrer') + + // 다운로드 시작 후 잠시 대기 후 상태 초기화 + setTimeout(() => { + setDownloadingFileIndex(null) + }, 1000) + } catch (error) { + console.error("POS 파일 다운로드 오류:", error) + toast.error("POS 파일 다운로드에 실패했습니다", { id: `download-${fileIndex}` }) + setDownloadingFileIndex(null) + } + } + + // POS 다이얼로그 닫기 + const handleClosePosDialog = () => { + setPosDialogOpen(false) + setSelectedMaterialCode("") + setPosFiles([]) + setDownloadingFileIndex(null) + } + // 상세품목 데이터 로드 React.useEffect(() => { if (!open || !po) { @@ -123,6 +223,7 @@ export function VendorPOItemsDialog({ open, onOpenChange, po }: VendorPOItemsDia <TableHead className="min-w-[120px] text-right whitespace-nowrap">기본총액(BRTWR)</TableHead> <TableHead className="min-w-[120px] text-right whitespace-nowrap">조정금액(ZPDT_EXDS_AMT)</TableHead> <TableHead className="min-w-[120px] text-right whitespace-nowrap">최종정가(NETWR)</TableHead> + <TableHead className="min-w-[100px] text-center whitespace-nowrap">POS</TableHead> <TableHead className="min-w-[100px] whitespace-nowrap">예정시작일자</TableHead> <TableHead className="min-w-[100px] whitespace-nowrap">납기일자</TableHead> <TableHead className="min-w-[100px] whitespace-nowrap">예정종료일자</TableHead> @@ -223,6 +324,24 @@ export function VendorPOItemsDialog({ open, onOpenChange, po }: VendorPOItemsDia return item.NETWR ? formatNumber(item.NETWR, decimals) : '-' })()} </TableCell> + <TableCell className="text-center"> + {item.materialNo ? ( + <Button + variant="ghost" + size="sm" + className="h-8 px-2 text-xs text-green-600 hover:text-green-800" + onClick={() => handleOpenPosDialog(item.materialNo!)} + disabled={loadingPosFiles && selectedMaterialCode === item.materialNo} + title={`POS 파일 다운로드 (자재코드: ${item.materialNo})`} + > + <FileText className="h-3 w-3 mr-1" /> + <Download className="h-3 w-3 mr-1" /> + {loadingPosFiles && selectedMaterialCode === item.materialNo ? '조회중...' : 'POS'} + </Button> + ) : ( + <span className="text-muted-foreground">-</span> + )} + </TableCell> <TableCell>{item.ZPLN_ST_DT || '-'}</TableCell> <TableCell>{item.ZPO_DLV_DT || item.deliveryDate || '-'}</TableCell> <TableCell>{item.ZPLN_ED_DT || '-'}</TableCell> @@ -277,6 +396,16 @@ export function VendorPOItemsDialog({ open, onOpenChange, po }: VendorPOItemsDia </div> </div> )} + + {/* POS 파일 선택 다이얼로그 */} + <PosFileSelectionDialog + isOpen={posDialogOpen} + onClose={handleClosePosDialog} + materialCode={selectedMaterialCode} + files={posFiles} + onDownload={handleDownloadPosFile} + downloadingIndex={downloadingFileIndex} + /> </DialogContent> </Dialog> ) diff --git a/lib/po/vendor-table/vendor-po-print-dialog.tsx b/lib/po/vendor-table/vendor-po-print-dialog.tsx new file mode 100644 index 00000000..f4e30798 --- /dev/null +++ b/lib/po/vendor-table/vendor-po-print-dialog.tsx @@ -0,0 +1,514 @@ +"use client" + +import * as React from "react" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { PrinterIcon } from "lucide-react" +import { VendorPO, VendorPOItem } from "./types" +import { formatNumber } from "@/lib/utils" +import { getVendorPOById, getVendorPOItems } from "./service" + +interface VendorPOPrintDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + po: VendorPO | null +} + +// 발주서 헤더 컴포넌트 (페이지마다 반복) +function POHeader({ po }: { po: VendorPO }) { + return ( + <div> + {/* 로고 및 타이틀 */} + <table style={{ width: '100%', borderCollapse: 'collapse', marginBottom: '10px' }}> + <tbody> + <tr> + <td style={{ width: '20.33%', textAlign: 'left', verticalAlign: 'middle' }}> + <div style={{ border: '1px solid #ccc', padding: '20px', textAlign: 'center', backgroundColor: '#f9f9f9', minHeight: '80px' }}> + {/* 구매처 로고 위치 */} + <span style={{ fontSize: '10px', color: '#999' }}>PURCHASER LOGO</span> + </div> + </td> + <td style={{ width: '55.33%', textAlign: 'center', verticalAlign: 'middle' }}> + <div style={{ fontSize: '24px', fontWeight: 'bold' }}> + 발주서 / PURCHASE ORDER + </div> + </td> + <td style={{ width: '20.33%', textAlign: 'right', verticalAlign: 'middle' }}> + <div style={{ border: '1px solid #ccc', padding: '20px', textAlign: 'center', backgroundColor: '#f9f9f9', minHeight: '80px' }}> + {/* 공급자 로고 위치 */} + <span style={{ fontSize: '10px', color: '#999' }}>SUPPLIER LOGO</span> + </div> + </td> + </tr> + </tbody> + </table> + + {/* 회사 정보 및 PO 정보 */} + <table style={{ width: '100%', borderCollapse: 'collapse', marginBottom: '10px' }}> + <tbody> + <tr> + <td style={{ width: '60%', verticalAlign: 'top', paddingRight: '10px' }}> + <table style={{ width: '100%', borderCollapse: 'collapse' }}> + <tbody> + <tr><td style={{ padding: '3px 0', fontSize: '11px' }}>거제조선소 : 경상남도 거제시 장평3로 80 (장평동)</td></tr> + <tr><td style={{ padding: '3px 0', fontSize: '11px' }}>SHIP YARD : 80, Jangpyeong 3-ro, Geoje-si, Gyeongsangnam-do, 53261, Rep. of KOREA</td></tr> + <tr><td style={{ padding: '3px 0', fontSize: '11px' }}>FAX NO : +82-55-630-5768</td></tr> + <tr><td style={{ padding: '3px 0', fontSize: '11px' }}>TEL NO : +82-55-631-4434</td></tr> + </tbody> + </table> + </td> + <td style={{ width: '40%', verticalAlign: 'top' }}> + <table style={{ width: '100%', borderCollapse: 'collapse', border: '2px solid #000' }}> + <tbody> + <tr> + <td style={{ borderBottom: '1px solid #000', borderRight: '1px solid #000', padding: '5px', textAlign: 'left', fontSize: '11px', width: '40%' }}> + PO번호 + </td> + <td style={{ borderBottom: '1px solid #000', padding: '5px', textAlign: 'left', fontSize: '11px', width: '60%' }}> + {po.contractNo} + </td> + </tr> + <tr> + <td style={{ borderBottom: '1px solid #000', borderRight: '1px solid #000', padding: '5px', textAlign: 'left', fontSize: '11px', width: '40%' }}> + REV + </td> + <td style={{ borderBottom: '1px solid #000', padding: '5px', textAlign: 'left', fontSize: '11px', width: '60%' }}> + {po.poVersion ? String(po.poVersion).padStart(2, '0') : '00'} + </td> + </tr> + <tr> + <td style={{ borderBottom: '1px solid #000', borderRight: '1px solid #000', padding: '5px', textAlign: 'left', fontSize: '11px', width: '40%' }}> + PO발행일 + </td> + <td style={{ borderBottom: '1px solid #000', padding: '5px', textAlign: 'left', fontSize: '11px', width: '60%' }}> + {po.contractDate || '-'} + </td> + </tr> + <tr> + <td style={{ borderRight: '1px solid #000', padding: '5px', textAlign: 'left', fontSize: '11px', width: '40%' }}> + 구매담당 + </td> + <td style={{ padding: '5px', textAlign: 'left', fontSize: '11px', width: '60%' }}> + {po.purchaseManagerName || po.purchaseGroup || '-'} + </td> + </tr> + </tbody> + </table> + </td> + </tr> + </tbody> + </table> + + {/* 출력 정보 */} + <div style={{ textAlign: 'right', marginBottom: '5px', fontSize: '11px' }}> + <span>OUTPUT : {new Date().toLocaleString('ko-KR')}</span> + </div> + </div> + ) +} + +export function VendorPOPrintDialog({ + open, + onOpenChange, + po, +}: VendorPOPrintDialogProps) { + const [loading, setLoading] = React.useState(false) + const [poDetails, setPoDetails] = React.useState<VendorPO | null>(null) + const [poItems, setPoItems] = React.useState<VendorPOItem[]>([]) + + // PO 상세 정보 로드 + React.useEffect(() => { + if (open && po) { + setLoading(true) + Promise.all([ + getVendorPOById(po.id), + getVendorPOItems(po.id) + ]) + .then(([details, items]) => { + setPoDetails(details) + setPoItems(items) + }) + .catch((error) => { + console.error("Failed to load PO details:", error) + }) + .finally(() => { + setLoading(false) + }) + } + }, [open, po]) + + const handlePrint = () => { + window.print() + } + + if (!po) return null + + const displayPO = poDetails || po + + // 총 수량 계산 + const totalQuantity = poItems.reduce((sum, item) => sum + (item.quantity || 0), 0) + + return ( + <> + {/* 인쇄 전용 스타일 - Tailwind와 독립적으로 작동 */} + <style dangerouslySetInnerHTML={{__html: ` + @media print { + @page { + size: A4; + margin: 10mm; + } + + /* Dialog 관련 UI 요소 모두 숨김 */ + body > div:not(:has(.po-print-container)) { + display: none !important; + } + + /* Dialog overlay/backdrop 숨김 */ + [data-radix-popper-content-wrapper], + [data-overlay], + [role="dialog"] > div:first-child { + display: none !important; + } + + /* DialogContent 스타일 초기화 */ + [role="dialog"] { + position: static !important; + width: 100% !important; + max-width: 100% !important; + height: auto !important; + max-height: none !important; + margin: 0 !important; + padding: 0 !important; + border: none !important; + box-shadow: none !important; + background: white !important; + overflow: visible !important; + transform: none !important; + } + + .po-print-container { + font-family: 'Malgun Gothic', Arial, sans-serif !important; + font-size: 11px !important; + color: #000 !important; + background: white !important; + margin: 0 !important; + padding: 0 !important; + } + .po-page-break { + page-break-after: always; + } + .po-no-break { + page-break-inside: avoid; + } + } + `}} /> + + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-[210mm] max-h-[90vh] overflow-y-auto print:p-0 print:m-0 print:max-w-full print:max-h-none print:border-0 print:shadow-none"> + <DialogHeader className="print:hidden"> + <DialogTitle>발주서 출력</DialogTitle> + </DialogHeader> + + {loading ? ( + <div className="flex items-center justify-center py-12"> + <div className="text-muted-foreground">로딩 중...</div> + </div> + ) : ( + <div className="po-print-container"> + {/* ===== 페이지 1: 품목 리스트 ===== */} + <div className="po-page-break" style={{ maxWidth: '210mm', margin: '0 auto' }}> + <POHeader po={displayPO} /> + + {/* 공급자 정보 */} + <div style={{ border: '2px solid #000', padding: '10px', marginBottom: '10px' }}> + <table style={{ width: '100%', borderCollapse: 'collapse' }}> + <tbody> + <tr> + <td style={{ width: '70%', fontSize: '11px' }}> + <div style={{ marginBottom: '3px' }}>공급자명 : {displayPO.vendorName || '-'}</div> + <div>공급자 주소 : {displayPO.vendorAddress ? `${displayPO.vendorAddress}${displayPO.vendorAddressDetail ? ' ' + displayPO.vendorAddressDetail : ''}` : '-'}</div> + </td> + <td style={{ width: '30%', textAlign: 'right', fontSize: '11px' }}> + <div>FAX NO : -</div> + <div>TEL NO : {displayPO.vendorPhone || '-'}</div> + </td> + </tr> + </tbody> + </table> + </div> + + {/* 서명 섹션 */} + <div style={{ border: '2px solid #000', marginBottom: '10px' }}> + <table style={{ width: '100%', borderCollapse: 'collapse' }}> + <tbody> + <tr> + <td style={{ width: '50%', borderRight: '1px solid #000', padding: '10px', verticalAlign: 'top' }}> + <div style={{ fontWeight: 'bold', marginBottom: '10px', fontSize: '11px' }}>VERY TRULY YOURS</div> + <div style={{ marginBottom: '5px', fontSize: '11px' }}>서명 / SIGNATURE :</div> + <div style={{ marginBottom: '5px', fontSize: '11px' }}>성명 / NAME : -</div> + <div style={{ marginBottom: '5px', fontSize: '11px' }}>직함 / TITLE : -</div> + <div style={{ fontSize: '11px' }}>회사명 / CO. : 삼성중공업(주)</div> + </td> + <td style={{ width: '50%', padding: '10px', verticalAlign: 'top' }}> + <div style={{ fontWeight: 'bold', marginBottom: '10px', fontSize: '11px' }}>ACCEPTED AND CONFIRMED BY</div> + <div style={{ marginBottom: '5px', fontSize: '11px' }}>서명 / SIGNATURE :</div> + <div style={{ marginBottom: '5px', fontSize: '11px' }}>성명 / NAME : -</div> + <div style={{ marginBottom: '5px', fontSize: '11px' }}>직함 / TITLE : -</div> + <div style={{ fontSize: '11px' }}>회사명 / CO. : -</div> + </td> + </tr> + </tbody> + </table> + </div> + + {/* 프로젝트 정보 */} + <div style={{ border: '2px solid #000', marginBottom: '10px' }}> + <table style={{ width: '100%', borderCollapse: 'collapse' }}> + <tbody> + <tr> + <td style={{ width: '33.33%', borderRight: '1px solid #000', borderBottom: '1px solid #000', padding: '8px', fontSize: '11px', fontWeight: 'bold', color: '#999' }}> + 프로젝트 / PROJECT NO + </td> + <td style={{ width: '33.33%', borderRight: '1px solid #000', borderBottom: '1px solid #000', padding: '8px', fontSize: '11px', fontWeight: 'bold', color: '#999' }}> + 대표품명 / ORDER DESCRIPTION + </td> + <td style={{ width: '33.33%', borderBottom: '1px solid #000', padding: '8px', fontSize: '11px', fontWeight: 'bold', color: '#999' }}> + 화폐단위 / CURRENCY + </td> + </tr> + <tr> + <td style={{ width: '33.33%', borderRight: '1px solid #000', padding: '8px', fontSize: '11px' }}> + {displayPO.projectCode || '-'} + </td> + <td style={{ width: '33.33%', borderRight: '1px solid #000', padding: '8px', fontSize: '11px' }}> + {displayPO.contractName || '-'} + </td> + <td style={{ width: '33.33%', padding: '8px', fontSize: '11px' }}> + {displayPO.currency || 'KRW'} + </td> + </tr> + </tbody> + </table> + </div> + + {/* 지급 및 인도 조건 */} + <div style={{ border: '2px solid #000', marginBottom: '10px' }}> + <table style={{ width: '100%', borderCollapse: 'collapse' }}> + <tbody> + <tr> + <td style={{ width: '40%', borderRight: '1px solid #000', borderBottom: '1px solid #000', padding: '8px', fontSize: '11px', fontWeight: 'bold', color: '#999' }}> + 지급조건 / PAYMENT TERMS + </td> + <td style={{ width: '30%', borderRight: '1px solid #000', borderBottom: '1px solid #000', padding: '8px', fontSize: '11px', fontWeight: 'bold', color: '#999' }}> + 인도조건 / DELIVERY TERMS + </td> + <td style={{ width: '15%', borderRight: '1px solid #000', borderBottom: '1px solid #000', padding: '8px', fontSize: '11px', fontWeight: 'bold', color: '#999' }}> + VAT 구분 + </td> + <td style={{ width: '15%', borderBottom: '1px solid #000', padding: '8px', fontSize: '11px', fontWeight: 'bold', color: '#999' }}> + 납세자번호 + </td> + </tr> + <tr> + <td style={{ width: '40%', borderRight: '1px solid #000', padding: '8px', fontSize: '10px' }}> + {displayPO.paymentTerms || '-'} + </td> + <td style={{ width: '30%', borderRight: '1px solid #000', padding: '8px', fontSize: '11px' }}> + {displayPO.deliveryTerms || '-'} + </td> + <td style={{ width: '15%', borderRight: '1px solid #000', padding: '8px', fontSize: '11px' }}> + - + </td> + <td style={{ width: '15%', padding: '8px', fontSize: '11px' }}> + - + </td> + </tr> + </tbody> + </table> + </div> + + {/* 품목 테이블 */} + <div style={{ border: '2px solid #000', marginBottom: '10px' }}> + <table style={{ width: '100%', borderCollapse: 'collapse' }}> + <thead> + <tr style={{ backgroundColor: '#f0f0f0' }}> + <th style={{ border: '1px solid #000', padding: '5px', fontSize: '9px', fontWeight: 'bold', width: '5%' }}>품번</th> + <th style={{ border: '1px solid #000', padding: '5px', fontSize: '9px', fontWeight: 'bold', width: '7%' }}>PR-NO</th> + <th style={{ border: '1px solid #000', padding: '5px', fontSize: '9px', fontWeight: 'bold', width: '8%' }}>자재코드</th> + <th style={{ border: '1px solid #000', padding: '5px', fontSize: '9px', fontWeight: 'bold', width: '22%' }}> + 품명,규격,재질<br />DESC, SPEC, MATL + </th> + <th style={{ border: '1px solid #000', padding: '5px', fontSize: '9px', fontWeight: 'bold', width: '7%' }}> + VALVE<br />FIT. NO + </th> + <th style={{ border: '1px solid #000', padding: '5px', fontSize: '9px', fontWeight: 'bold', width: '5%' }}>CERT</th> + <th style={{ border: '1px solid #000', padding: '5px', fontSize: '9px', fontWeight: 'bold', width: '5%' }}> + 단위<br />UNIT + </th> + <th style={{ border: '1px solid #000', padding: '5px', fontSize: '9px', fontWeight: 'bold', width: '7%' }}>납기일자</th> + <th style={{ border: '1px solid #000', padding: '5px', fontSize: '9px', fontWeight: 'bold', width: '6%' }}> + 수량<br />QTY + </th> + <th style={{ border: '1px solid #000', padding: '5px', fontSize: '9px', fontWeight: 'bold', width: '8%' }}> + 총중량<br />WEIGHT(KG) + </th> + <th style={{ border: '1px solid #000', padding: '5px', fontSize: '9px', fontWeight: 'bold', width: '10%' }}> + 단가<br />UNIT-PRICE + </th> + <th style={{ border: '1px solid #000', padding: '5px', fontSize: '9px', fontWeight: 'bold', width: '10%' }}> + 금액<br />AMOUNT + </th> + </tr> + </thead> + <tbody> + {poItems.length > 0 ? ( + poItems.map((item, index) => ( + <tr key={index}> + <td style={{ border: '1px solid #000', padding: '5px', fontSize: '9px', textAlign: 'center', verticalAlign: 'top' }}> + {item.itemNo || '-'} + </td> + <td style={{ border: '1px solid #000', padding: '5px', fontSize: '9px', textAlign: 'center', verticalAlign: 'top' }}> + {item.prNo || '-'} + </td> + <td style={{ border: '1px solid #000', padding: '5px', fontSize: '9px', verticalAlign: 'top' }}> + {item.materialNo || '-'} + </td> + <td style={{ border: '1px solid #000', padding: '5px', fontSize: '9px', verticalAlign: 'top' }}> + {item.itemDescription || '-'} + {item.specification && <><br />{item.specification}</>} + {item.material && <><br />재질: {item.material}</>} + </td> + <td style={{ border: '1px solid #000', padding: '5px', fontSize: '9px', textAlign: 'center', verticalAlign: 'top' }}> + {item.fittingNo || '-'} + </td> + <td style={{ border: '1px solid #000', padding: '5px', fontSize: '9px', textAlign: 'center', verticalAlign: 'top' }}> + {item.cert || '-'} + </td> + <td style={{ border: '1px solid #000', padding: '5px', fontSize: '9px', textAlign: 'center', verticalAlign: 'top' }}> + {item.ZPO_UNIT || item.quantityUnit || '-'} + </td> + <td style={{ border: '1px solid #000', padding: '5px', fontSize: '9px', textAlign: 'center', verticalAlign: 'top' }}> + {item.ZPO_DLV_DT || item.deliveryDate || '-'} + </td> + <td style={{ border: '1px solid #000', padding: '5px', fontSize: '9px', textAlign: 'right', verticalAlign: 'top' }}> + {formatNumber(item.quantity, 2)} + </td> + <td style={{ border: '1px solid #000', padding: '5px', fontSize: '9px', textAlign: 'right', verticalAlign: 'top' }}> + {item.totalWeight ? formatNumber(item.totalWeight, 2) : '-'} + </td> + <td style={{ border: '1px solid #000', padding: '5px', fontSize: '9px', textAlign: 'right', verticalAlign: 'top' }}> + {formatNumber(item.unitPrice, 2)} + </td> + <td style={{ border: '1px solid #000', padding: '5px', fontSize: '9px', textAlign: 'right', verticalAlign: 'top' }}> + {formatNumber(item.NETWR || item.contractAmount, 2)} + </td> + </tr> + )) + ) : ( + <tr> + <td colSpan={12} style={{ border: '1px solid #000', padding: '20px', textAlign: 'center', fontSize: '11px' }}> + 품목 정보가 없습니다. + </td> + </tr> + )} + </tbody> + {poItems.length > 0 && ( + <tfoot> + <tr style={{ backgroundColor: '#f0f0f0', fontWeight: 'bold' }}> + <td colSpan={8} style={{ border: '1px solid #000', padding: '5px', textAlign: 'center', fontSize: '11px' }}> + * TOTAL AMOUNT * + </td> + <td style={{ border: '1px solid #000', padding: '5px', textAlign: 'right', fontSize: '11px' }}> + {formatNumber(totalQuantity, 2)} + </td> + <td colSpan={2} style={{ border: '1px solid #000', padding: '5px' }}></td> + <td style={{ border: '1px solid #000', padding: '5px', textAlign: 'right', fontSize: '11px' }}> + {formatNumber(displayPO.totalAmount, displayPO.currency === 'KRW' || displayPO.currency === 'JPY' ? 0 : 2)} + </td> + </tr> + </tfoot> + )} + </table> + </div> + </div> + + {/* ===== 페이지 2: 특기사항 ===== */} + {displayPO.contractContent && ( + <div style={{ maxWidth: '210mm', margin: '0 auto' }}> + <POHeader po={displayPO} /> + + {/* 공급자 정보 (반복) */} + <div style={{ border: '2px solid #000', padding: '10px', marginBottom: '10px' }}> + <table style={{ width: '100%', borderCollapse: 'collapse' }}> + <tbody> + <tr> + <td style={{ width: '70%', fontSize: '11px' }}> + <div style={{ marginBottom: '3px' }}>공급자명 : {displayPO.vendorName || '-'}</div> + <div>공급자 주소 : {displayPO.vendorAddress ? `${displayPO.vendorAddress}${displayPO.vendorAddressDetail ? ' ' + displayPO.vendorAddressDetail : ''}` : '-'}</div> + </td> + <td style={{ width: '30%', textAlign: 'right', fontSize: '11px' }}> + <div>FAX NO : -</div> + <div>TEL NO : {displayPO.vendorPhone || '-'}</div> + </td> + </tr> + </tbody> + </table> + </div> + + {/* 서명 섹션 (반복) */} + <div style={{ border: '2px solid #000', marginBottom: '10px' }}> + <table style={{ width: '100%', borderCollapse: 'collapse' }}> + <tbody> + <tr> + <td style={{ width: '50%', borderRight: '1px solid #000', padding: '10px', verticalAlign: 'top' }}> + <div style={{ fontWeight: 'bold', marginBottom: '10px', fontSize: '11px' }}>VERY TRULY YOURS</div> + <div style={{ marginBottom: '5px', fontSize: '11px' }}>서명 / SIGNATURE :</div> + <div style={{ marginBottom: '5px', fontSize: '11px' }}>성명 / NAME : -</div> + <div style={{ marginBottom: '5px', fontSize: '11px' }}>직함 / TITLE : -</div> + <div style={{ fontSize: '11px' }}>회사명 / CO. : 삼성중공업(주)</div> + </td> + <td style={{ width: '50%', padding: '10px', verticalAlign: 'top' }}> + <div style={{ fontWeight: 'bold', marginBottom: '10px', fontSize: '11px' }}>ACCEPTED AND CONFIRMED BY</div> + <div style={{ marginBottom: '5px', fontSize: '11px' }}>서명 / SIGNATURE :</div> + <div style={{ marginBottom: '5px', fontSize: '11px' }}>성명 / NAME : -</div> + <div style={{ marginBottom: '5px', fontSize: '11px' }}>직함 / TITLE : -</div> + <div style={{ fontSize: '11px' }}>회사명 / CO. : -</div> + </td> + </tr> + </tbody> + </table> + </div> + + {/* 특기사항 */} + <div style={{ border: '2px solid #000', padding: '10px' }}> + <div style={{ fontWeight: 'bold', marginBottom: '10px', fontSize: '12px' }}>특기사항 / Note</div> + <div style={{ lineHeight: '1.6', fontSize: '11px', whiteSpace: 'pre-wrap' }}> + {displayPO.contractContent} + </div> + </div> + </div> + )} + </div> + )} + + {/* 액션 버튼 (인쇄 시 숨김) */} + <div className="flex justify-end gap-2 mt-4 print:hidden"> + <Button variant="outline" onClick={() => onOpenChange(false)}> + 닫기 + </Button> + <Button onClick={handlePrint} disabled={loading}> + <PrinterIcon className="mr-2 h-4 w-4" /> + 인쇄/PDF 저장 + </Button> + </div> + </DialogContent> + </Dialog> + </> + ) +} + diff --git a/lib/po/vendor-table/vendor-po-table.tsx b/lib/po/vendor-table/vendor-po-table.tsx index 99b0e5eb..7e7cb78f 100644 --- a/lib/po/vendor-table/vendor-po-table.tsx +++ b/lib/po/vendor-table/vendor-po-table.tsx @@ -17,6 +17,7 @@ import { getVendorColumns } from "./vendor-po-columns" import { VendorPO, VendorPOActionType } from "./types" import { VendorPOItemsDialog } from "./vendor-po-items-dialog" import { VendorPONoteDialog } from "./vendor-po-note-dialog" +import { VendorPOPrintDialog } from "./vendor-po-print-dialog" import { VendorPOToolbarActions } from "./vendor-po-toolbar-actions" interface VendorPoTableProps { @@ -30,7 +31,7 @@ interface VendorPoTableProps { export function VendorPoTable({ promises }: VendorPoTableProps) { const router = useRouter() const params = useParams() - const lng = params.lng as string + const lng = (params?.lng as string) || 'ko' const [data, setData] = React.useState<{ data: VendorPO[]; @@ -53,16 +54,33 @@ export function VendorPoTable({ promises }: VendorPoTableProps) { // 다이얼로그 상태 const [itemsDialogOpen, setItemsDialogOpen] = React.useState(false) const [noteDialogOpen, setNoteDialogOpen] = React.useState(false) + const [printDialogOpen, setPrintDialogOpen] = React.useState(false) const [selectedPO, setSelectedPO] = React.useState<VendorPO | null>(null) // 행 선택 처리 (1개만 선택 가능) - const handleRowSelect = (id: number, selected: boolean) => { + const handleRowSelect = React.useCallback((id: number, selected: boolean) => { if (selected) { setSelectedRows([id]) // 1개만 선택 } else { setSelectedRows([]) } - } + }, []) + + // 액션 처리 함수 + const handleAction = React.useCallback(async (poId: number, action: string) => { + try { + const result = await handleVendorPOAction(poId, action) + if (result.success) { + toast.success(result.message) + // 필요시 데이터 새로고침 + } else { + toast.error(result.message) + } + } catch (error) { + console.error("Action error:", error) + toast.error("액션 처리 중 오류가 발생했습니다.") + } + }, []) // 행 액션 처리 React.useEffect(() => { @@ -88,7 +106,7 @@ export function VendorPoTable({ promises }: VendorPoTableProps) { handleAction(po.id, "reject-contract") break case "print-contract": - handleAction(po.id, "print-contract") + setPrintDialogOpen(true) break case "item-status": setItemsDialogOpen(true) @@ -107,23 +125,7 @@ export function VendorPoTable({ promises }: VendorPoTableProps) { } setRowAction(null) - }, [rowAction]) - - // 액션 처리 함수 - const handleAction = async (poId: number, action: string) => { - try { - const result = await handleVendorPOAction(poId, action) - if (result.success) { - toast.success(result.message) - // 필요시 데이터 새로고침 - } else { - toast.error(result.message) - } - } catch (error) { - console.error("Action error:", error) - toast.error("액션 처리 중 오류가 발생했습니다.") - } - } + }, [rowAction, lng, router, handleAction]) const columns = React.useMemo( () => getVendorColumns({ @@ -131,7 +133,7 @@ export function VendorPoTable({ promises }: VendorPoTableProps) { selectedRows, onRowSelect: handleRowSelect }), - [selectedRows] + [selectedRows, handleRowSelect] ) const filterFields: DataTableFilterField<VendorPO>[] = [ @@ -247,6 +249,7 @@ export function VendorPoTable({ promises }: VendorPoTableProps) { open={itemsDialogOpen} onOpenChange={setItemsDialogOpen} po={selectedPO} + viewerType="partners" /> <VendorPONoteDialog @@ -254,6 +257,12 @@ export function VendorPoTable({ promises }: VendorPoTableProps) { onOpenChange={setNoteDialogOpen} po={selectedPO} /> + + <VendorPOPrintDialog + open={printDialogOpen} + onOpenChange={setPrintDialogOpen} + po={selectedPO} + /> </> ) }
\ No newline at end of file |
