summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
authorjoonhoekim <26rote@gmail.com>2025-11-20 19:36:01 +0900
committerjoonhoekim <26rote@gmail.com>2025-11-20 19:36:01 +0900
commit935fd22e17afc034a472bc2d159de7b9f5e5dcae (patch)
tree6beea33ab38750be17632dffca5e05e644647365 /lib
parentb75b1cd920efd61923f7b2dbc4c49987b7b0c4e1 (diff)
(김준회) PO, POS, swp
- PO: 발주서출력기능 초안 - 벤더측 POS 다운로드 기능 추가 - Contract 생성시 Status 설정 (mapper) - swp document registration table 로직 리팩터링 - swp: 입력가능 문서번호 validation 추가 (리스트 메뉴에서 Completed 된 건)
Diffstat (limited to 'lib')
-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
-rw-r--r--lib/soap/ecc/mapper/po-mapper.ts9
-rw-r--r--lib/swp/table/swp-help-dialog.tsx4
-rw-r--r--lib/swp/table/swp-inbox-table.tsx459
-rw-r--r--lib/swp/table/swp-table-columns.tsx36
-rw-r--r--lib/swp/table/swp-table-toolbar.tsx18
-rw-r--r--lib/swp/table/swp-table.tsx24
13 files changed, 1081 insertions, 222 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
diff --git a/lib/soap/ecc/mapper/po-mapper.ts b/lib/soap/ecc/mapper/po-mapper.ts
index fef85662..ec7e4fb6 100644
--- a/lib/soap/ecc/mapper/po-mapper.ts
+++ b/lib/soap/ecc/mapper/po-mapper.ts
@@ -12,6 +12,7 @@ import {
ZMM_DT,
} from '@/db/schema/ECC/ecc';
import { eq } from 'drizzle-orm';
+import { ContractStatus } from '@/db/schema/contract';
// ECC 데이터 타입 정의
export type ECCPOHeader = typeof ZMM_HD.$inferInsert & {
@@ -125,7 +126,7 @@ export async function mapECCPOHeaderToBusiness(
throw new Error(`벤더를 찾을 수 없습니다: LIFNR=${eccHeader.LIFNR}, EBELN=${eccHeader.EBELN}`);
}
- // 계약서 내용 구성 (ZMM_NOTE에서 가져옴)
+ // 계약서(발주서) 내용 구성 (ZMM_NOTE에서 가져옴)
let contractContent: string | null = null;
if (eccHeader.notes && eccHeader.notes.length > 0) {
// ZNOTE_SER 순번으로 정렬 후 텍스트 합치기
@@ -145,9 +146,9 @@ export async function mapECCPOHeaderToBusiness(
contractNo: eccHeader.EBELN || '', // EBELN - 구매오더번호
contractName: eccHeader.ZTITLE || eccHeader.EBELN || '', // ZTITLE - 발주제목
contractContent, // 계약서 내용
- // TODO: ZPO_CNFM_STAT 값을 ContractStatus enum으로 매핑하는 로직 필요
- // 현재는 ECC에서 받은 값을 그대로 사용하되, null인 경우 undefined로 변환
- status: eccHeader.ZPO_CNFM_STAT || undefined, // ZPO_CNFM_STAT - 구매오더확인상태
+ // // TODO: ZPO_CNFM_STAT 값을 ContractStatus enum으로 매핑하는 로직 필요
+ // ZPO_CNFM_STAT은 단순히 확인여부로 null or 'C'
+ status: ContractStatus.CONTRACT_TRANSFER, // ECC에서 PO 전송받은 상태
startDate: parseDate(eccHeader.ZPO_DT || null), // ZPO_DT - 발주일자
endDate: null, // TODO: ZMM_DT의 ZPLN_ED_DT(예정종료일자) 중 최대값으로 계산 필요
deliveryDate: null, // TODO: ZMM_DT의 ZPO_DLV_DT(PO납기일자) 중 최대값으로 계산 필요
diff --git a/lib/swp/table/swp-help-dialog.tsx b/lib/swp/table/swp-help-dialog.tsx
index 3aa7d6dc..7b18c100 100644
--- a/lib/swp/table/swp-help-dialog.tsx
+++ b/lib/swp/table/swp-help-dialog.tsx
@@ -37,7 +37,7 @@ export function SwpUploadHelpDialog() {
<div className="rounded-lg border p-4 space-y-3">
<div>
<Badge variant="default" className="mb-2">
- SBOX (ALL) 탭
+ DOCUMENT REGISTRATION TAB
</Badge>
<p className="text-sm text-muted-foreground">
파일을 업로드한 현황입니다. SHI가 파일 접수 여부를 응답할 예정입니다.
@@ -47,7 +47,7 @@ export function SwpUploadHelpDialog() {
<div className="border-t pt-3">
<Badge variant="default" className="mb-2">
- VDR Documents (Received) 탭
+ DOCUMENT LIST TAB
</Badge>
<p className="text-sm text-muted-foreground">
파일을 업로드한 뒤, SHI가 업로드한 파일을 수락하면 Rev, Activity No가 만들어지며, 해당 테이블에 추가됩니다.
diff --git a/lib/swp/table/swp-inbox-table.tsx b/lib/swp/table/swp-inbox-table.tsx
index 430447f4..d070f2fd 100644
--- a/lib/swp/table/swp-inbox-table.tsx
+++ b/lib/swp/table/swp-inbox-table.tsx
@@ -38,29 +38,33 @@ interface SwpInboxTableProps {
// 테이블 행 데이터 (플랫하게 펼침)
interface TableRowData {
- uploadId: string | null; // 업로드 필요 문서는 null
+ uploadId: string | null;
docNo: string;
revNo: string | null;
stage: string | null;
status: string | null;
statusNm: string | null;
actvNo: string | null;
- crter: string | null; // CRTER (그대로 표시)
- note1: string | null; // DC Note (Activity의 NOTE1 또는 buyerSystemComment)
- pkgNo: string | null; // PKG_NO
- file: SwpFileApiResponse | null; // 업로드 필요 문서는 null
+ actvSeq: string | null; // 정렬용
+ crter: string | null;
+ note1: string | null;
+ pkgNo: string | null;
+ file: SwpFileApiResponse | null;
uploadDate: string | null;
- // 각 행이 속한 그룹의 정보 (계층적 rowSpan 처리)
- isFirstInUpload: boolean;
- fileCountInUpload: number;
- isFirstInDoc: boolean;
- fileCountInDoc: number;
- isFirstInRev: boolean;
- fileCountInRev: number;
- isFirstInActivity: boolean;
- fileCountInActivity: number;
- // 업로드 필요 문서 여부
isRequiredDoc: boolean;
+
+ // Row Span 정보 (0이면 렌더링 안함)
+ spans: {
+ uploadId: number;
+ docNo: number;
+ pkgNo: number;
+ revNo: number;
+ stage: number;
+ status: number;
+ actvNo: number;
+ crter: number;
+ note1: number;
+ };
}
// Status 집계 타입
@@ -136,117 +140,47 @@ export function SwpInboxTable({
});
}, [files, requiredDocs]);
- // 데이터 그룹화 및 플랫 변환 (API 응답 + 업로드 필요 문서)
+ // 데이터 그룹화 및 플랫 변환 (Sorting logic applied)
const tableRows = useMemo(() => {
const rows: TableRowData[] = [];
// 1. API 응답 파일 처리
- // Status 필터링
- let filteredFiles = files;
- if (selectedStatus && selectedStatus !== "UPLOAD_REQUIRED") {
- filteredFiles = files.filter((file) => file.STAT === selectedStatus);
- }
-
- // 1단계: BOX_SEQ (Upload ID) 기준으로 그룹화
- const uploadGroups = new Map<string, SwpFileApiResponse[]>();
-
- if (!selectedStatus || selectedStatus !== "UPLOAD_REQUIRED") {
- filteredFiles.forEach((file) => {
- const uploadId = file.BOX_SEQ || "NO_UPLOAD_ID";
- if (!uploadGroups.has(uploadId)) {
- uploadGroups.set(uploadId, []);
- }
- uploadGroups.get(uploadId)!.push(file);
- });
- }
-
- // Upload ID별로 처리
- uploadGroups.forEach((uploadFiles, uploadId) => {
- // 2단계: Document No 기준으로 그룹화
- const docGroups = new Map<string, SwpFileApiResponse[]>();
+ files.forEach((file) => {
+ // Status 필터링
+ if (selectedStatus && file.STAT !== selectedStatus) {
+ return;
+ }
- uploadFiles.forEach((file) => {
- const docNo = file.OWN_DOC_NO;
- if (!docGroups.has(docNo)) {
- docGroups.set(docNo, []);
+ rows.push({
+ uploadId: file.BOX_SEQ || null,
+ docNo: file.OWN_DOC_NO,
+ revNo: file.REV_NO || null,
+ stage: file.STAGE || null,
+ status: file.STAT || null,
+ statusNm: file.STAT_NM || null,
+ actvNo: file.ACTV_NO || null,
+ actvSeq: file.ACTV_SEQ || null,
+ crter: file.CRTER || null,
+ note1: file.NOTE1 || null,
+ pkgNo: file.PKG_NO || null,
+ file,
+ uploadDate: file.CRTE_DTM || null,
+ isRequiredDoc: false,
+ spans: {
+ uploadId: 1,
+ docNo: 1,
+ pkgNo: 1,
+ revNo: 1,
+ stage: 1,
+ status: 1,
+ actvNo: 1,
+ crter: 1,
+ note1: 1,
}
- docGroups.get(docNo)!.push(file);
- });
-
- // 전체 Upload ID의 파일 수 계산
- const totalUploadFileCount = uploadFiles.length;
- let isFirstInUpload = true;
-
- // Document No별로 처리
- docGroups.forEach((docFiles, docNo) => {
- const totalDocFileCount = docFiles.length;
- let isFirstInDoc = true;
-
- // Document의 첫 번째 파일에서 PKG_NO 가져오기
- const firstDocFile = docFiles[0];
- const docPkgNo = firstDocFile?.PKG_NO || null;
-
- // 3단계: ACTV_SEQ 기준으로 그룹화 (최신 Rev 필터링 제거)
- const activityGroups = new Map<string, SwpFileApiResponse[]>();
-
- docFiles.forEach((file) => {
- const actvSeq = file.ACTV_SEQ || "NO_ACTIVITY";
- if (!activityGroups.has(actvSeq)) {
- activityGroups.set(actvSeq, []);
- }
- activityGroups.get(actvSeq)!.push(file);
- });
-
- // Activity별로 처리
- activityGroups.forEach((activityFiles) => {
- // 4단계: Upload Date 기준 DESC 정렬
- const sortedFiles = activityFiles.sort((a, b) =>
- (b.CRTE_DTM || "").localeCompare(a.CRTE_DTM || "")
- );
-
- const totalActivityFileCount = sortedFiles.length;
-
- // Activity의 첫 번째 파일에서 메타데이터 가져오기
- const firstActivityFile = sortedFiles[0];
- if (!firstActivityFile) return;
-
- // 5단계: 각 파일을 테이블 행으로 변환
- sortedFiles.forEach((file, idx) => {
- rows.push({
- uploadId,
- docNo,
- revNo: file.REV_NO || null,
- stage: file.STAGE || null,
- status: file.STAT || null,
- statusNm: file.STAT_NM || null,
- actvNo: file.ACTV_NO || null,
- crter: firstActivityFile.CRTER, // Activity 첫 파일의 CRTER
- note1: firstActivityFile.NOTE1 || null, // Activity 첫 파일의 DC Note
- pkgNo: docPkgNo, // Document 레벨의 PKG_NO
- file,
- uploadDate: file.CRTE_DTM,
- isFirstInUpload,
- fileCountInUpload: totalUploadFileCount,
- isFirstInDoc,
- fileCountInDoc: totalDocFileCount,
- isFirstInRev: idx === 0,
- fileCountInRev: totalActivityFileCount, // Activity 내 파일 수
- isFirstInActivity: idx === 0,
- fileCountInActivity: totalActivityFileCount,
- isRequiredDoc: false,
- });
-
- // 첫 번째 플래그들 업데이트
- if (idx === 0) {
- isFirstInUpload = false;
- isFirstInDoc = false;
- }
- });
- });
});
});
- // 2. 업로드 필요 문서 추가 (Upload Required 필터일 때만 또는 필터 없을 때)
+ // 2. 업로드 필요 문서 처리
if (!selectedStatus || selectedStatus === "UPLOAD_REQUIRED") {
requiredDocs.forEach((doc) => {
rows.push({
@@ -257,30 +191,169 @@ export function SwpInboxTable({
status: "UPLOAD_REQUIRED",
statusNm: "Upload Required",
actvNo: null,
+ actvSeq: null,
crter: null,
- note1: doc.buyerSystemComment, // DB의 comment를 DC Note에 매핑
+ note1: doc.buyerSystemComment,
pkgNo: null,
file: null,
uploadDate: null,
- isFirstInUpload: true,
- fileCountInUpload: 1,
- isFirstInDoc: true,
- fileCountInDoc: 1,
- isFirstInRev: true,
- fileCountInRev: 1,
- isFirstInActivity: true,
- fileCountInActivity: 1,
isRequiredDoc: true,
+ spans: {
+ uploadId: 1,
+ docNo: 1,
+ pkgNo: 1,
+ revNo: 1,
+ stage: 1,
+ status: 1,
+ actvNo: 1,
+ crter: 1,
+ note1: 1,
+ }
});
});
}
- // Upload Date 기준 전체 정렬 (null은 맨 뒤로)
- return rows.sort((a, b) => {
- if (!a.uploadDate) return 1;
- if (!b.uploadDate) return -1;
- return b.uploadDate.localeCompare(a.uploadDate);
+ // 3. 정렬 적용
+ // 1) BOX_SEQ (DESC) -> 2) OWN_DOC_NO (DESC) -> 3) REV_NO (DESC) -> 4) ACTV_SEQ (DESC) -> 5) CRTE_DTM (DESC)
+ rows.sort((a, b) => {
+ // 1) BOX_SEQ
+ const uploadIdA = a.uploadId || "";
+ const uploadIdB = b.uploadId || "";
+ if (uploadIdA !== uploadIdB) {
+ return uploadIdB.localeCompare(uploadIdA);
+ }
+
+ // 2) OWN_DOC_NO
+ const docNoA = a.docNo || "";
+ const docNoB = b.docNo || "";
+ if (docNoA !== docNoB) {
+ return docNoB.localeCompare(docNoA);
+ }
+
+ // 3) REV_NO
+ const revNoA = a.revNo || "";
+ const revNoB = b.revNo || "";
+ if (revNoA !== revNoB) {
+ return revNoB.localeCompare(revNoA);
+ }
+
+ // 4) ACTV_SEQ
+ const actvSeqA = a.actvSeq || "";
+ const actvSeqB = b.actvSeq || "";
+ if (actvSeqA !== actvSeqB) {
+ return actvSeqB.localeCompare(actvSeqA);
+ }
+
+ // 5) CRTE_DTM
+ const dateA = a.uploadDate || "";
+ const dateB = b.uploadDate || "";
+ return dateB.localeCompare(dateA);
});
+
+ // 4. Row Span 계산 (간단 로직: 위와 같으면 합침)
+ // 정렬된 상태에서 위에서 아래로 내려가며, 이전 행과 값이 같으면 현재 행의 span을 0으로 만들고 leader 행의 span을 증가시킴
+
+ // 각 컬럼별 리더 행의 인덱스를 추적
+ const leaders = {
+ uploadId: 0,
+ docNo: 0,
+ pkgNo: 0,
+ revNo: 0,
+ stage: 0,
+ status: 0,
+ actvNo: 0,
+ crter: 0,
+ note1: 0,
+ };
+
+ for (let i = 1; i < rows.length; i++) {
+ const prev = rows[i - 1];
+ const curr = rows[i];
+
+ // 1) Upload ID (최상위)
+ const mergeUploadId = curr.uploadId === prev.uploadId;
+ if (mergeUploadId) {
+ rows[leaders.uploadId].spans.uploadId++;
+ curr.spans.uploadId = 0;
+ } else {
+ leaders.uploadId = i;
+ }
+
+ // 2) Document No (Upload ID에 종속)
+ const mergeDocNo = mergeUploadId && (curr.docNo === prev.docNo);
+ if (mergeDocNo) {
+ rows[leaders.docNo].spans.docNo++;
+ curr.spans.docNo = 0;
+ } else {
+ leaders.docNo = i;
+ }
+
+ // 3) PKG NO (Document에 종속)
+ const mergePkgNo = mergeDocNo && (curr.pkgNo === prev.pkgNo);
+ if (mergePkgNo) {
+ rows[leaders.pkgNo].spans.pkgNo++;
+ curr.spans.pkgNo = 0;
+ } else {
+ leaders.pkgNo = i;
+ }
+
+ // 4) Rev No (Document에 종속)
+ const mergeRevNo = mergeDocNo && (curr.revNo === prev.revNo);
+ if (mergeRevNo) {
+ rows[leaders.revNo].spans.revNo++;
+ curr.spans.revNo = 0;
+ } else {
+ leaders.revNo = i;
+ }
+
+ // 5) Stage (Rev No에 종속)
+ const mergeStage = mergeRevNo && (curr.stage === prev.stage);
+ if (mergeStage) {
+ rows[leaders.stage].spans.stage++;
+ curr.spans.stage = 0;
+ } else {
+ leaders.stage = i;
+ }
+
+ // 6) Activity (Rev No에 종속 - 보통 Activity는 Rev 안에 있음)
+ const mergeActvNo = mergeRevNo && (curr.actvSeq === prev.actvSeq);
+ if (mergeActvNo) {
+ rows[leaders.actvNo].spans.actvNo++;
+ curr.spans.actvNo = 0;
+ } else {
+ leaders.actvNo = i;
+ }
+
+ // 7) Status (Activity 내 파일들 - Activity에 종속)
+ // Activity가 같고 Status 값도 같으면 합침
+ const mergeStatus = mergeActvNo && (curr.status === prev.status);
+ if (mergeStatus) {
+ rows[leaders.status].spans.status++;
+ curr.spans.status = 0;
+ } else {
+ leaders.status = i;
+ }
+
+ // 8) CRTER (Activity에 종속)
+ const mergeCrter = mergeActvNo && (curr.crter === prev.crter);
+ if (mergeCrter) {
+ rows[leaders.crter].spans.crter++;
+ curr.spans.crter = 0;
+ } else {
+ leaders.crter = i;
+ }
+
+ // 9) DC Note (Activity에 종속)
+ const mergeNote1 = mergeActvNo && (curr.note1 === prev.note1);
+ if (mergeNote1) {
+ rows[leaders.note1].spans.note1++;
+ curr.spans.note1 = 0;
+ } else {
+ leaders.note1 = i;
+ }
+ }
+
+ return rows;
}, [files, requiredDocs, selectedStatus]);
// 선택 가능한 파일들 (Standby 상태만)
@@ -464,27 +537,27 @@ export function SwpInboxTable({
</div>
) : (
<div className="rounded-md border overflow-x-auto">
- <Table>
+ <Table className="min-w-[1700px]">
<TableHeader>
<TableRow>
- <TableHead className="w-[50px]">
+ <TableHead className="w-[50px] min-w-[50px]">
<Checkbox
checked={selectableFiles.length > 0 && selectedFiles.size === selectableFiles.length}
onCheckedChange={handleSelectAll}
disabled={selectableFiles.length === 0}
/>
</TableHead>
- <TableHead className="w-[100px]">Upload ID</TableHead>
- <TableHead className="w-[200px]">Document No</TableHead>
- <TableHead className="w-[120px]">PKG NO</TableHead>
- <TableHead className="w-[80px]">Rev No</TableHead>
- <TableHead className="w-[80px]">Stage</TableHead>
- <TableHead className="w-[120px]">Status</TableHead>
- <TableHead className="w-[100px]">Activity</TableHead>
- <TableHead className="w-[120px]">Upload ID (User)</TableHead>
- <TableHead className="w-[150px]">DC Note</TableHead>
- <TableHead className="w-[400px]">Attachment File</TableHead>
- <TableHead className="w-[180px]">Upload Date</TableHead>
+ <TableHead className="w-[100px] min-w-[100px]">Upload ID</TableHead>
+ <TableHead className="w-[200px] min-w-[200px]">Document No</TableHead>
+ <TableHead className="w-[120px] min-w-[120px]">PKG NO</TableHead>
+ <TableHead className="w-[80px] min-w-[80px]">Rev No</TableHead>
+ <TableHead className="w-[80px] min-w-[80px]">Stage</TableHead>
+ <TableHead className="w-[120px] min-w-[120px]">Status</TableHead>
+ <TableHead className="w-[100px] min-w-[100px]">Activity</TableHead>
+ <TableHead className="w-[120px] min-w-[120px]">Upload ID (User)</TableHead>
+ <TableHead className="w-[150px] min-w-[150px]">DC Note</TableHead>
+ <TableHead className="w-[800px] min-w-[400px]">Attachment File</TableHead>
+ <TableHead className="w-[180px] min-w-[180px]">Upload Date</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -500,7 +573,7 @@ export function SwpInboxTable({
onClick={() => handleRowClick(row.docNo)}
>
{/* Select Checkbox */}
- <TableCell onClick={(e) => e.stopPropagation()}>
+ <TableCell className="w-[50px] min-w-[50px]" onClick={(e) => e.stopPropagation()}>
{canSelect ? (
<Checkbox
checked={!!isSelected}
@@ -509,67 +582,99 @@ export function SwpInboxTable({
) : null}
</TableCell>
- {/* Upload ID - Upload의 첫 파일에만 표시 */}
- {row.isFirstInUpload ? (
- <TableCell rowSpan={row.fileCountInUpload} className="font-mono text-sm align-top" style={{ verticalAlign: "top" }}>
+ {/* Upload ID */}
+ {row.spans.uploadId > 0 ? (
+ <TableCell
+ rowSpan={row.spans.uploadId}
+ className="w-[100px] min-w-[100px] font-mono text-sm align-top"
+ style={{ verticalAlign: "top" }}
+ >
{row.uploadId || <span className="text-muted-foreground">-</span>}
</TableCell>
) : null}
- {/* Document No - Document의 첫 파일에만 표시 */}
- {row.isFirstInDoc ? (
- <TableCell rowSpan={row.fileCountInDoc} className="font-mono text-xs align-top" style={{ verticalAlign: "top" }}>
+ {/* Document No */}
+ {row.spans.docNo > 0 ? (
+ <TableCell
+ rowSpan={row.spans.docNo}
+ className="w-[200px] min-w-[200px] font-mono text-xs align-top"
+ style={{ verticalAlign: "top" }}
+ >
{row.docNo}
</TableCell>
) : null}
- {/* PKG NO - Document의 첫 파일에만 표시 */}
- {row.isFirstInDoc ? (
- <TableCell rowSpan={row.fileCountInDoc} className="font-mono text-sm align-top" style={{ verticalAlign: "top" }}>
+ {/* PKG NO */}
+ {row.spans.pkgNo > 0 ? (
+ <TableCell
+ rowSpan={row.spans.pkgNo}
+ className="w-[120px] min-w-[120px] font-mono text-sm align-top"
+ style={{ verticalAlign: "top" }}
+ >
{row.pkgNo || <span className="text-muted-foreground">-</span>}
</TableCell>
) : null}
- {/* Rev No - Rev의 첫 파일에만 표시 */}
- {row.isFirstInRev ? (
- <TableCell rowSpan={row.fileCountInRev} className="align-top" style={{ verticalAlign: "top" }}>
+ {/* Rev No */}
+ {row.spans.revNo > 0 ? (
+ <TableCell
+ rowSpan={row.spans.revNo}
+ className="w-[80px] min-w-[80px] align-top"
+ style={{ verticalAlign: "top" }}
+ >
{row.revNo || <span className="text-muted-foreground">-</span>}
</TableCell>
) : null}
- {/* Stage - Rev의 첫 파일에만 표시 */}
- {row.isFirstInRev ? (
- <TableCell rowSpan={row.fileCountInRev} className="align-top text-sm" style={{ verticalAlign: "top" }}>
+ {/* Stage */}
+ {row.spans.stage > 0 ? (
+ <TableCell
+ rowSpan={row.spans.stage}
+ className="w-[80px] min-w-[80px] align-top text-sm"
+ style={{ verticalAlign: "top" }}
+ >
{row.stage || <span className="text-muted-foreground">-</span>}
</TableCell>
) : null}
- {/* Status - Rev의 첫 파일에만 표시 */}
- {row.isFirstInRev ? (
- <TableCell rowSpan={row.fileCountInRev} className="align-top" style={{ verticalAlign: "top" }}>
+ {/* Status */}
+ {row.spans.status > 0 ? (
+ <TableCell
+ rowSpan={row.spans.status}
+ className="w-[120px] min-w-[120px] align-top"
+ style={{ verticalAlign: "top" }}
+ >
{getStatusBadge(row.status, row.statusNm)}
</TableCell>
) : null}
- {/* Activity - Activity의 첫 파일에만 표시 */}
- {row.isFirstInActivity ? (
- <TableCell rowSpan={row.fileCountInActivity} className="font-mono text-xs align-top" style={{ verticalAlign: "top" }}>
+ {/* Activity */}
+ {row.spans.actvNo > 0 ? (
+ <TableCell
+ rowSpan={row.spans.actvNo}
+ className="w-[100px] min-w-[100px] font-mono text-xs align-top"
+ style={{ verticalAlign: "top" }}
+ >
{row.actvNo || <span className="text-muted-foreground">-</span>}
</TableCell>
) : null}
- {/* CRTER (Upload ID User) - Activity의 첫 파일에만 표시 */}
- {row.isFirstInActivity ? (
- <TableCell rowSpan={row.fileCountInActivity} className="text-sm font-mono align-top" style={{ verticalAlign: "top" }}>
+ {/* CRTER (Upload ID User) */}
+ {row.spans.crter > 0 ? (
+ <TableCell
+ rowSpan={row.spans.crter}
+ className="w-[120px] min-w-[120px] text-sm font-mono align-top"
+ style={{ verticalAlign: "top" }}
+ >
{row.crter || <span className="text-muted-foreground">-</span>}
</TableCell>
) : null}
- {/* DC Note - Activity의 첫 파일에만 표시 */}
- {row.isFirstInActivity ? (
+ {/* DC Note */}
+ {row.spans.note1 > 0 ? (
<TableCell
- rowSpan={row.fileCountInActivity}
- className="text-xs max-w-[150px] align-top"
+ rowSpan={row.spans.note1}
+ className="w-[150px] min-w-[150px] text-xs align-top"
style={{ verticalAlign: "top" }}
onClick={(e) => {
if (row.note1) {
@@ -589,8 +694,8 @@ export function SwpInboxTable({
</TableCell>
) : null}
- {/* Attachment File - 각 파일마다 표시 (줄바꿈 허용) */}
- <TableCell className="max-w-[400px]">
+ {/* Attachment File - 병합하지 않음 */}
+ <TableCell className="w-[400px] min-w-[400px]">
{row.file ? (
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-mono break-words" style={{ wordBreak: "break-all" }}>
@@ -613,8 +718,8 @@ export function SwpInboxTable({
)}
</TableCell>
- {/* Upload Date - 각 파일마다 표시 */}
- <TableCell className="text-xs">
+ {/* Upload Date - 병합하지 않음 */}
+ <TableCell className="w-[180px] min-w-[180px] text-xs">
{row.uploadDate ? formatSwpDate(row.uploadDate) : <span className="text-muted-foreground">-</span>}
</TableCell>
</TableRow>
diff --git a/lib/swp/table/swp-table-columns.tsx b/lib/swp/table/swp-table-columns.tsx
index 14a8e002..91c811c3 100644
--- a/lib/swp/table/swp-table-columns.tsx
+++ b/lib/swp/table/swp-table-columns.tsx
@@ -28,7 +28,9 @@ export const swpDocumentColumns: ColumnDef<DocumentListItem>[] = [
</Badge>
);
},
- size: 120,
+ size: 150,
+ minSize: 150,
+ maxSize: 150,
},
{
accessorKey: "OWN_DOC_NO",
@@ -37,6 +39,8 @@ export const swpDocumentColumns: ColumnDef<DocumentListItem>[] = [
<div className="font-mono text-sm">{row.original.OWN_DOC_NO || "-"}</div>
),
size: 250,
+ minSize: 250,
+ maxSize: 250,
},
{
accessorKey: "DOC_NO",
@@ -45,16 +49,20 @@ export const swpDocumentColumns: ColumnDef<DocumentListItem>[] = [
<div className="font-mono text-sm">{row.original.DOC_NO}</div>
),
size: 250,
+ minSize: 250,
+ maxSize: 250,
},
{
accessorKey: "DOC_TITLE",
header: "문서제목",
cell: ({ row }) => (
- <div className="max-w-md truncate" title={row.original.DOC_TITLE}>
+ <div className="truncate" title={row.original.DOC_TITLE}>
{row.original.DOC_TITLE}
</div>
),
size: 300,
+ minSize: 300,
+ maxSize: 300,
},
{
accessorKey: "PROJ_NO",
@@ -63,19 +71,23 @@ export const swpDocumentColumns: ColumnDef<DocumentListItem>[] = [
<div>
<div className="font-medium">{row.original.PROJ_NO}</div>
{row.original.PROJ_NM && (
- <div className="text-xs text-muted-foreground max-w-[150px] truncate">
+ <div className="text-xs text-muted-foreground truncate">
{row.original.PROJ_NM}
</div>
)}
</div>
),
size: 150,
+ minSize: 150,
+ maxSize: 150,
},
{
accessorKey: "PKG_NO",
header: "패키지",
cell: ({ row }) => row.original.PKG_NO || "-",
size: 100,
+ minSize: 100,
+ maxSize: 100,
},
{
accessorKey: "VNDR_CD",
@@ -86,13 +98,15 @@ export const swpDocumentColumns: ColumnDef<DocumentListItem>[] = [
<div className="text-xs text-muted-foreground">{row.original.VNDR_CD}</div>
)}
{row.original.CPY_NM && (
- <div className="text-sm truncate max-w-[120px]" title={row.original.CPY_NM}>
+ <div className="text-sm truncate" title={row.original.CPY_NM}>
{row.original.CPY_NM}
</div>
)}
</div>
),
size: 120,
+ minSize: 120,
+ maxSize: 120,
},
{
accessorKey: "STAGE",
@@ -112,13 +126,17 @@ export const swpDocumentColumns: ColumnDef<DocumentListItem>[] = [
</Badge>
);
},
- size: 80,
+ size: 100,
+ minSize: 100,
+ maxSize: 100,
},
{
accessorKey: "LTST_REV_NO",
header: "최신 REV",
cell: ({ row }) => row.original.LTST_REV_NO || "-",
- size: 80,
+ size: 100,
+ minSize: 100,
+ maxSize: 100,
},
{
id: "stats",
@@ -136,6 +154,8 @@ export const swpDocumentColumns: ColumnDef<DocumentListItem>[] = [
</div>
),
size: 100,
+ minSize: 100,
+ maxSize: 100,
},
{
id: "actions",
@@ -221,6 +241,8 @@ export const swpDocumentColumns: ColumnDef<DocumentListItem>[] = [
</Button>
);
},
- size: 80,
+ size: 180,
+ minSize: 180,
+ maxSize: 180,
},
];
diff --git a/lib/swp/table/swp-table-toolbar.tsx b/lib/swp/table/swp-table-toolbar.tsx
index 08eda3ef..276eca14 100644
--- a/lib/swp/table/swp-table-toolbar.tsx
+++ b/lib/swp/table/swp-table-toolbar.tsx
@@ -44,6 +44,7 @@ interface SwpTableToolbarProps {
onFilesProcessed?: () => void;
documents?: DocumentListItem[]; // 업로드 권한 검증 + DOC_CLS (Document Class) 확인용 문서 목록
userId?: string; // 파일 취소 시 필요
+ requiredDocs?: Array<{ vendorDocNumber: string; title: string; buyerSystemComment: string | null }>; // DB Completed 상태 업로드 필요 문서
}
export function SwpTableToolbar({
@@ -60,6 +61,7 @@ export function SwpTableToolbar({
onFilesProcessed,
documents = [],
userId,
+ requiredDocs = [],
}: SwpTableToolbarProps) {
const [isUploading, startUpload] = useTransition();
const [localFilters, setLocalFilters] = useState(filters);
@@ -93,9 +95,11 @@ export function SwpTableToolbar({
/**
* 업로드 가능한 문서번호 목록 추출 (OWN_DOC_NO 기준)
* SWP API의 OWN_DOC_NO가 EVCP DB의 vendorDocNumber와 매핑되는지 확인
+ * + DB Completed 상태 문서(requiredDocs)의 vendorDocNumber도 포함
*/
const availableDocNos = useMemo(() => {
- return documents
+ // 1. documents의 OWN_DOC_NO (EVCP DB에 등록된 것만)
+ const fromDocuments = documents
.map(doc => doc.OWN_DOC_NO)
.filter((ownDocNo): ownDocNo is string => {
// OWN_DOC_NO가 있고, EVCP DB에 등록된 문서인지 확인
@@ -103,7 +107,17 @@ export function SwpTableToolbar({
ownDocNo !== undefined &&
vendorDocNumberToDocClassMap[ownDocNo] !== undefined;
});
- }, [documents, vendorDocNumberToDocClassMap]);
+
+ // 2. requiredDocs의 vendorDocNumber (DB Completed 상태)
+ const fromRequiredDocs = requiredDocs
+ .map(doc => doc.vendorDocNumber)
+ .filter((vendorDocNumber): vendorDocNumber is string => {
+ return vendorDocNumber !== null && vendorDocNumber !== undefined;
+ });
+
+ // 3. 중복 제거하여 병합
+ return Array.from(new Set([...fromDocuments, ...fromRequiredDocs]));
+ }, [documents, requiredDocs, vendorDocNumberToDocClassMap]);
/**
* 벤더 모드 여부 (벤더 코드가 있으면 벤더 모드)
diff --git a/lib/swp/table/swp-table.tsx b/lib/swp/table/swp-table.tsx
index b6c3558b..6f810415 100644
--- a/lib/swp/table/swp-table.tsx
+++ b/lib/swp/table/swp-table.tsx
@@ -75,6 +75,8 @@ export function SwpTable({
data: filteredDocuments,
columns: swpDocumentColumns,
getCoreRowModel: getCoreRowModel(),
+ enableColumnResizing: false,
+ columnResizeMode: 'onChange',
});
// 문서 클릭 핸들러 - Dialog 열기
@@ -109,13 +111,20 @@ export function SwpTable({
</div>
{/* 테이블 */}
- <div className="rounded-md border">
- <Table>
+ <div className="rounded-md border overflow-x-auto">
+ <Table className="min-w-[1700px]">
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
- <TableHead key={header.id}>
+ <TableHead
+ key={header.id}
+ style={{
+ width: header.column.getSize(),
+ minWidth: header.column.getSize(),
+ maxWidth: header.column.getSize(),
+ }}
+ >
{header.isPlaceholder
? null
: flexRender(
@@ -137,7 +146,14 @@ export function SwpTable({
onClick={() => handleDocumentClick(row.original)}
>
{row.getVisibleCells().map((cell) => (
- <TableCell key={cell.id}>
+ <TableCell
+ key={cell.id}
+ style={{
+ width: cell.column.getSize(),
+ minWidth: cell.column.getSize(),
+ maxWidth: cell.column.getSize(),
+ }}
+ >
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}