summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-08-22 08:20:45 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-08-22 08:20:45 +0000
commitd38877eef87917087a4a217bea32ae84d6738a7d (patch)
tree8713d9239e6fb34c4ca0704129906044784b30e5
parente9b1bf22cf2f45a7db8152f7ba83af7799ed71a5 (diff)
(최겸) 인포메이션 첨부파일 뷰어 추가
-rw-r--r--components/document-viewer/pdftron-viewer-dialog.tsx207
-rw-r--r--components/information/information-button.tsx61
-rw-r--r--components/information/information-client.tsx2
-rw-r--r--config/menuConfig.ts12
-rw-r--r--db/schema/index.ts5
-rw-r--r--i18n/locales/en/menu.json2
-rw-r--r--i18n/locales/ko/menu.json2
-rw-r--r--lib/basic-contract/template/update-basicContract-sheet.tsx1
-rw-r--r--lib/techsales-rfq/service.ts2
-rw-r--r--lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx37
10 files changed, 319 insertions, 12 deletions
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<HTMLDivElement>(null)
+ const [isLoading, setIsLoading] = useState(false)
+ const [error, setError] = useState<string | null>(null)
+ const [instance, setInstance] = useState<any>(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 (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-6xl max-h-[90vh] h-[90vh] p-0">
+ <DialogHeader className="px-4 py-3 border-b">
+ <div className="flex items-center justify-between">
+ <div>
+ <DialogTitle className="text-lg font-semibold">
+ {fileName || '문서 뷰어'}
+ </DialogTitle>
+ <DialogDescription className="text-sm text-gray-600 mt-1">
+ PDF 및 Office 문서를 읽기 전용으로 보기
+ </DialogDescription>
+ </div>
+ {/* <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => onOpenChange(false)}
+ >
+ </Button> */}
+ </div>
+ </DialogHeader>
+
+ <div className="flex-1 relative">
+ {isLoading && (
+ <div className="absolute inset-0 flex items-center justify-center bg-white z-10">
+ <div className="flex flex-col items-center gap-3">
+ <Loader2 className="h-8 w-8 animate-spin text-blue-500" />
+ <span className="text-gray-600">문서를 불러오는 중...</span>
+ </div>
+ </div>
+ )}
+
+ {error && (
+ <div className="absolute inset-0 flex items-center justify-center bg-white z-10">
+ <div className="flex flex-col items-center gap-3 text-center max-w-md">
+ <AlertCircle className="h-12 w-12 text-red-500" />
+ <div>
+ <h3 className="font-semibold text-gray-900 mb-2">문서를 불러올 수 없습니다</h3>
+ <p className="text-gray-600 text-sm">{error}</p>
+ </div>
+ <Button
+ variant="outline"
+ onClick={() => onOpenChange(false)}
+ className="mt-2"
+ >
+ 닫기
+ </Button>
+ </div>
+ </div>
+ )}
+
+ <div
+ ref={viewerRef}
+ className="w-full h-full"
+ style={{ height: 'calc(90vh - 60px)' }}
+ />
+ </div>
+ </DialogContent>
+ </Dialog>
+ )
+}
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<InformationAttachment | null>(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"
>
- <div className="flex-1">
- <div className="text-sm font-medium">
+ <div
+ className="flex-1"
+ >
+ <div className="text-sm font-medium flex items-center gap-2">
{attachment.fileName}
+
</div>
{attachment.fileSize && (
<div className="text-xs text-gray-500 mt-1">
- {attachment.fileSize}
+ {prettyBytes(Number(attachment.fileSize))}
</div>
)}
</div>
+ <div className="flex gap-2">
+ {isViewerSupported(attachment.fileName) &&
<Button
size="sm"
variant="outline"
+ disabled={!isViewerSupported(attachment.fileName)}
+ onClick={() => isViewerSupported(attachment.fileName) && handleFileClick(attachment)}
+ className="flex items-center gap-1"
+ >
+ <EyeIcon className="h-3 w-3" />
+ 미리보기
+ </Button>
+ }
+ <Button
+ size="sm"
+ variant="outline"
+
onClick={() => handleDownload(attachment)}
className="flex items-center gap-1"
>
<Download className="h-3 w-3" />
다운로드
</Button>
+ </div>
</div>
))}
</div>
@@ -345,6 +390,14 @@ export function InformationButton({
onSuccess={handleEditSuccess}
/>
)}
+
+ {/* PDFTron 뷰어 다이얼로그 */}
+ <PDFTronViewerDialog
+ open={viewerDialogOpen}
+ onOpenChange={setViewerDialogOpen}
+ fileUrl={selectedFile?.filePath}
+ fileName={selectedFile?.fileName}
+ />
</>
)
} \ 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)
</div>
</TableCell>
<TableCell>
- <span className="font-mono text-sm max-w-[200px] truncate block">
+ <span className="font-mono text-sm max-w-[300px] truncate block">
{information.pagePath}
</span>
</TableCell>
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 발송 핸들러