diff options
Diffstat (limited to 'lib')
| -rw-r--r-- | lib/po/vendor-table/service.ts | 38 | ||||
| -rw-r--r-- | lib/po/vendor-table/shi-vendor-po-table.tsx | 1 | ||||
| -rw-r--r-- | lib/po/vendor-table/types.ts | 14 | ||||
| -rw-r--r-- | lib/po/vendor-table/vendor-po-actions.tsx | 2 | ||||
| -rw-r--r-- | lib/po/vendor-table/vendor-po-items-dialog.tsx | 131 | ||||
| -rw-r--r-- | lib/po/vendor-table/vendor-po-print-dialog.tsx | 514 | ||||
| -rw-r--r-- | lib/po/vendor-table/vendor-po-table.tsx | 53 | ||||
| -rw-r--r-- | lib/soap/ecc/mapper/po-mapper.ts | 9 | ||||
| -rw-r--r-- | lib/swp/table/swp-help-dialog.tsx | 4 | ||||
| -rw-r--r-- | lib/swp/table/swp-inbox-table.tsx | 459 | ||||
| -rw-r--r-- | lib/swp/table/swp-table-columns.tsx | 36 | ||||
| -rw-r--r-- | lib/swp/table/swp-table-toolbar.tsx | 18 | ||||
| -rw-r--r-- | lib/swp/table/swp-table.tsx | 24 |
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> ))} |
