summaryrefslogtreecommitdiff
path: root/lib/po/vendor-table
diff options
context:
space:
mode:
Diffstat (limited to 'lib/po/vendor-table')
-rw-r--r--lib/po/vendor-table/service.ts38
-rw-r--r--lib/po/vendor-table/shi-vendor-po-table.tsx1
-rw-r--r--lib/po/vendor-table/types.ts14
-rw-r--r--lib/po/vendor-table/vendor-po-actions.tsx2
-rw-r--r--lib/po/vendor-table/vendor-po-items-dialog.tsx131
-rw-r--r--lib/po/vendor-table/vendor-po-print-dialog.tsx514
-rw-r--r--lib/po/vendor-table/vendor-po-table.tsx53
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