From d38877eef87917087a4a217bea32ae84d6738a7d Mon Sep 17 00:00:00 2001 From: dujinkim Date: Fri, 22 Aug 2025 08:20:45 +0000 Subject: (최겸) 인포메이션 첨부파일 뷰어 추가 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../document-viewer/pdftron-viewer-dialog.tsx | 207 +++++++++++++++++++++ components/information/information-button.tsx | 61 +++++- components/information/information-client.tsx | 2 +- config/menuConfig.ts | 12 ++ db/schema/index.ts | 5 +- i18n/locales/en/menu.json | 2 + i18n/locales/ko/menu.json | 2 + .../template/update-basicContract-sheet.tsx | 1 + lib/techsales-rfq/service.ts | 2 +- .../table/detail-table/rfq-detail-table.tsx | 37 +++- 10 files changed, 319 insertions(+), 12 deletions(-) create mode 100644 components/document-viewer/pdftron-viewer-dialog.tsx diff --git a/components/document-viewer/pdftron-viewer-dialog.tsx b/components/document-viewer/pdftron-viewer-dialog.tsx new file mode 100644 index 00000000..58d2a91f --- /dev/null +++ b/components/document-viewer/pdftron-viewer-dialog.tsx @@ -0,0 +1,207 @@ +"use client" + +import * as React from "react" +import { useState, useRef, useEffect } from "react" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { X, Loader2, AlertCircle } from "lucide-react" + +interface PDFTronViewerDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + fileUrl?: string + fileName?: string +} + + + +export function PDFTronViewerDialog({ + open, + onOpenChange, + fileUrl, + fileName +}: PDFTronViewerDialogProps) { + const viewerRef = useRef(null) + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(null) + const [instance, setInstance] = useState(null) + + // PDFTron WebViewer 초기화 + const initializeViewer = async () => { + if (!viewerRef.current || !fileUrl) return + + setIsLoading(true) + setError(null) + + try { + // @pdftron/webviewer 패키지에서 동적으로 import + const { default: WebViewer } = await import("@pdftron/webviewer") + + const viewerInstance = await WebViewer({ + path: '/pdftronWeb', + licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY, + fullAPI: false, + enableFilePicker: false, + enableRedaction: false, + enableMeasurement: false, + enableAnnotations: false, + disabledElements: [ + 'header', + 'toolsHeader', + 'searchButton', + 'menuButton', + 'viewControlsButton', + 'selectToolButton', + 'freeHandToolButton', + 'textSelectButton', + 'panToolButton', + 'annotationCommentButton', + 'leftPanel', + 'leftPanelButton', + 'notesPanelButton', + 'searchPanel', + 'thumbnailControl', + 'outlineControl', + 'contextMenuPopup', + 'toolsOverlay', + 'signatureModal', + 'redactionModal', + 'linkModal', + 'filterModal', + 'passwordModal', + 'progressModal', + 'errorModal', + 'toolbarGroup-Annotate', + 'toolbarGroup-Shapes', + 'toolbarGroup-Edit', + 'toolbarGroup-Insert', + 'toolbarGroup-Forms', + 'toolbarGroup-View', + 'downloadButton', + 'printButton', + 'saveAsButton', + 'textToolButton', + 'stickyToolButton', + 'calloutToolButton', + 'rubberStampToolButton', + 'stampToolButton', + 'freeTextToolButton', + 'toolsButton' + ] + }, viewerRef.current) + + setInstance(viewerInstance) + + // 문서 로드 + if (fileUrl) { + const { documentViewer } = viewerInstance.Core + + // 문서 로드 완료 후 읽기 전용 설정 + documentViewer.addEventListener('documentLoaded', () => { + setIsLoading(false) + }) + + // 문서 로드 + viewerInstance.UI.loadDocument(fileUrl) + } else { + setIsLoading(false) + } + + } catch (err) { + console.error('PDFTron 뷰어 초기화 실패:', err) + setError('문서를 불러올 수 없습니다. PDFTron이 설치되어 있는지 확인해주세요.') + setIsLoading(false) + } + } + + // 다이얼로그가 열릴 때 뷰어 초기화 + useEffect(() => { + if (open && fileUrl) { + // 약간의 지연을 두어 DOM이 완전히 렌더링된 후 초기화 + const timer = setTimeout(initializeViewer, 100) + return () => clearTimeout(timer) + } + }, [open, fileUrl]) + + // 다이얼로그가 닫힐 때 정리 + useEffect(() => { + if (!open && instance) { + try { + // WebViewer 인스턴스 정리 + if (viewerRef.current) { + viewerRef.current.innerHTML = '' + } + setInstance(null) + } catch (err) { + console.error('뷰어 정리 실패:', err) + } + } + }, [open, instance]) + + return ( + + + +
+
+ + {fileName || '문서 뷰어'} + + + PDF 및 Office 문서를 읽기 전용으로 보기 + +
+ {/* */} +
+
+ +
+ {isLoading && ( +
+
+ + 문서를 불러오는 중... +
+
+ )} + + {error && ( +
+
+ +
+

문서를 불러올 수 없습니다

+

{error}

+
+ +
+
+ )} + +
+
+ +
+ ) +} diff --git a/components/information/information-button.tsx b/components/information/information-button.tsx index 69cbb106..015894c1 100644 --- a/components/information/information-button.tsx +++ b/components/information/information-button.tsx @@ -11,15 +11,17 @@ import { DialogTitle, DialogTrigger, } from "@/components/ui/dialog" -import { Info, Download, Edit, Loader2 } from "lucide-react" +import { Info, Download, Edit, Loader2,Eye, EyeIcon } from "lucide-react" import { getPageInformationDirect, getEditPermissionDirect } from "@/lib/information/service" import { getPageNotices } from "@/lib/notice/service" import { UpdateInformationDialog } from "@/lib/information/table/update-information-dialog" import { NoticeViewDialog } from "@/components/notice/notice-view-dialog" +import { PDFTronViewerDialog } from "@/components/document-viewer/pdftron-viewer-dialog" import type { PageInformation, InformationAttachment } from "@/db/schema/information" import type { Notice } from "@/db/schema/notice" import { useSession } from "next-auth/react" import { formatDate } from "@/lib/utils" +import prettyBytes from "pretty-bytes" // downloadFile은 동적으로 import interface InformationButtonProps { @@ -51,6 +53,8 @@ export function InformationButton({ const [dataLoaded, setDataLoaded] = useState(false) const [isLoading, setIsLoading] = useState(false) const [retryCount, setRetryCount] = useState(0) + const [viewerDialogOpen, setViewerDialogOpen] = useState(false) + const [selectedFile, setSelectedFile] = useState(null) // 데이터 로드 함수 const loadData = React.useCallback(async () => { @@ -151,6 +155,29 @@ export function InformationButton({ setIsNoticeViewDialogOpen(true) } + // 파일 확장자 확인 함수 + const getFileExtension = (fileName: string): string => { + return fileName.split('.').pop()?.toLowerCase() || '' + } + + // 뷰어 지원 파일 형식 확인 + const isViewerSupported = (fileName: string): boolean => { + const extension = getFileExtension(fileName) + return ['pdf', 'docx', 'doc'].includes(extension) + } + + // 파일 클릭 핸들러 (뷰어 또는 다운로드) + const handleFileClick = async (attachment: InformationAttachment) => { + if (isViewerSupported(attachment.fileName)) { + // PDF/DOCX 파일은 뷰어로 열기 + setSelectedFile(attachment) + setViewerDialogOpen(true) + } else { + // 기타 파일은 다운로드 + await handleDownload(attachment) + } + } + // 파일 다운로드 핸들러 const handleDownload = async (attachment: InformationAttachment) => { try { @@ -294,25 +321,43 @@ export function InformationButton({ key={attachment.id} className="flex items-center justify-between p-3 bg-white rounded border" > -
-
+
+
{attachment.fileName} +
{attachment.fileSize && (
- {attachment.fileSize} + {prettyBytes(Number(attachment.fileSize))}
)}
+
+ {isViewerSupported(attachment.fileName) && + } + +
))}
@@ -345,6 +390,14 @@ export function InformationButton({ onSuccess={handleEditSuccess} /> )} + + {/* PDFTron 뷰어 다이얼로그 */} + ) } \ No newline at end of file diff --git a/components/information/information-client.tsx b/components/information/information-client.tsx index 50bc6a39..e2d273e5 100644 --- a/components/information/information-client.tsx +++ b/components/information/information-client.tsx @@ -370,7 +370,7 @@ export function InformationClient({ initialData = [] }: InformationClientProps) - + {information.pagePath} diff --git a/config/menuConfig.ts b/config/menuConfig.ts index 9f94a0d8..45b3f475 100644 --- a/config/menuConfig.ts +++ b/config/menuConfig.ts @@ -144,6 +144,12 @@ export const mainNav: MenuSection[] = [ descriptionKey: "menu.master_data.esg_checklist_desc", groupKey: "groups.procurement_info" }, + { + titleKey: "menu.master_data.compliance_survey", + href: "/evcp/compliance", + descriptionKey: "menu.master_data.compliance_survey_desc", + groupKey: "groups.procurement_info" + }, ], }, { @@ -517,6 +523,12 @@ export const procurementNav: MenuSection[] = [ descriptionKey: "menu.master_data.esg_checklist_desc", groupKey: "groups.procurement_info" }, + { + titleKey: "menu.master_data.compliance_survey", + href: "/evcp/compliance", + descriptionKey: "menu.master_data.compliance_survey_desc", + groupKey: "groups.procurement_info" + }, ], }, { diff --git a/db/schema/index.ts b/db/schema/index.ts index b800d615..7637d247 100644 --- a/db/schema/index.ts +++ b/db/schema/index.ts @@ -60,7 +60,4 @@ export * from './knox/titles'; // 직급 export * from './knox/approvals'; // Knox 결재 - eVCP 에서 상신한 결재를 저장 // === Risks 스키마 === -export * from './risks/risks'; - -// === Compliance 스키마 === -export * from './compliance'; \ No newline at end of file +export * from './risks/risks'; \ No newline at end of file diff --git a/i18n/locales/en/menu.json b/i18n/locales/en/menu.json index ba7cea2a..1668f912 100644 --- a/i18n/locales/en/menu.json +++ b/i18n/locales/en/menu.json @@ -68,6 +68,8 @@ "vendor_checklist_desc": "Vendor evaluation data items management", "esg_checklist": "ESG Self-Assessment Items Management", "esg_checklist_desc": "ESG self-assessment items management", + "compliance_survey": "Compliance Survey Management", + "compliance_survey_desc": "compliance survey templates management", "gtc":"General Terms and Conditions" }, "engineering_management": { diff --git a/i18n/locales/ko/menu.json b/i18n/locales/ko/menu.json index 7ac80a1b..d46943aa 100644 --- a/i18n/locales/ko/menu.json +++ b/i18n/locales/ko/menu.json @@ -67,6 +67,8 @@ "vendor_checklist_desc": "협력업체 평가자료 문항 관리", "esg_checklist": "ESG 자가진단평가서 항목 관리", "esg_checklist_desc": "ESG 자가진단평가서 항목 관리", + "compliance_survey": "준법 설문조사 관리", + "compliance_survey_desc": "준법 설문조사 템플릿 관리", "gtc":"General Terms and Conditions" }, "engineering_management": { diff --git a/lib/basic-contract/template/update-basicContract-sheet.tsx b/lib/basic-contract/template/update-basicContract-sheet.tsx index 0236fda5..90bb81a4 100644 --- a/lib/basic-contract/template/update-basicContract-sheet.tsx +++ b/lib/basic-contract/template/update-basicContract-sheet.tsx @@ -102,6 +102,7 @@ export function UpdateTemplateSheet({ template, onSuccess, ...props }: UpdateTem // FormData 객체 생성하여 파일과 데이터를 함께 전송 const formData = new FormData(); + formData.append("templateName", template.templateName); // 기존 템플릿 이름 추가 formData.append("legalReviewRequired", input.legalReviewRequired.toString()); if (input.file) { diff --git a/lib/techsales-rfq/service.ts b/lib/techsales-rfq/service.ts index 5ec02f63..3736bf76 100644 --- a/lib/techsales-rfq/service.ts +++ b/lib/techsales-rfq/service.ts @@ -1758,7 +1758,7 @@ export async function processTechSalesRfqAttachments(params: { // 1. 삭제할 첨부파일 처리 if (deleteAttachmentIds.length > 0) { const attachmentsToDelete = await tx.query.techSalesAttachments.findMany({ - where: sql`${techSalesAttachments.id} IN (${deleteAttachmentIds.join(',')})` + where: inArray(techSalesAttachments.id, deleteAttachmentIds) }); for (const attachment of attachmentsToDelete) { diff --git a/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx b/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx index 249a2c74..6ef0f221 100644 --- a/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx +++ b/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx @@ -177,8 +177,41 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps return; } - // contact selection dialog 열기 - setContactSelectionDialogOpen(true); + // 선택된 벤더들의 담당자 존재 여부 확인 + try { + const vendorIds = selectedRows.map(row => row.vendorId).filter(Boolean) as number[]; + + if (vendorIds.length === 0) { + toast.error("유효한 벤더가 선택되지 않았습니다."); + return; + } + + // 벤더별 담당자 조회 + const { getTechVendorsContacts } = await import("@/lib/techsales-rfq/service"); + const contactsResult = await getTechVendorsContacts(vendorIds); + + if (contactsResult.error) { + toast.error("벤더 담당자 정보를 불러오는 중 오류가 발생했습니다."); + return; + } + + // 담당자가 없는 벤더 확인 + const vendorsWithoutContacts = vendorIds.filter(vendorId => { + const vendorContacts = contactsResult.data[vendorId]; + return !vendorContacts || vendorContacts.contacts.length === 0; + }); + + if (vendorsWithoutContacts.length > 0) { + toast.error("담당자가 지정되지 않은 협력업체가 있습니다."); + return; + } + + // contact selection dialog 열기 + setContactSelectionDialogOpen(true); + } catch (error) { + console.error("벤더 담당자 확인 오류:", error); + toast.error("벤더 담당자 정보를 확인하는 중 오류가 발생했습니다."); + } }, [selectedRows, selectedRfqId]); // contact 기반 RFQ 발송 핸들러 -- cgit v1.2.3