summaryrefslogtreecommitdiff
path: root/lib/gtc-contract
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-07-29 11:48:59 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-07-29 11:48:59 +0000
commit10f90dc68dec42e9a64e081cc0dce6a484447290 (patch)
tree5bc8bb30e03b09a602e7d414d943d0e7f24b1a0f /lib/gtc-contract
parent792fb0c21136eededecf52b5b4aa1a252bdc4bfb (diff)
(대표님, 박서영, 최겸) document-list-only, gtc, vendorDocu, docu-list-rule
Diffstat (limited to 'lib/gtc-contract')
-rw-r--r--lib/gtc-contract/gtc-clauses/table/clause-preview-viewer.tsx681
-rw-r--r--lib/gtc-contract/gtc-clauses/table/excel-import.tsx340
-rw-r--r--lib/gtc-contract/gtc-clauses/table/gtc-clauses-table-toolbar-actions.tsx200
-rw-r--r--lib/gtc-contract/gtc-clauses/table/import-excel-dialog.tsx381
-rw-r--r--lib/gtc-contract/gtc-clauses/table/preview-document-dialog.tsx115
-rw-r--r--lib/gtc-contract/service.ts520
-rw-r--r--lib/gtc-contract/status/clone-gtc-document-dialog.tsx383
-rw-r--r--lib/gtc-contract/status/create-gtc-document-dialog.tsx98
-rw-r--r--lib/gtc-contract/status/gtc-contract-table.tsx13
-rw-r--r--lib/gtc-contract/status/gtc-documents-table-columns.tsx35
-rw-r--r--lib/gtc-contract/validations.ts33
11 files changed, 2434 insertions, 365 deletions
diff --git a/lib/gtc-contract/gtc-clauses/table/clause-preview-viewer.tsx b/lib/gtc-contract/gtc-clauses/table/clause-preview-viewer.tsx
index 30e369b4..f979f0ea 100644
--- a/lib/gtc-contract/gtc-clauses/table/clause-preview-viewer.tsx
+++ b/lib/gtc-contract/gtc-clauses/table/clause-preview-viewer.tsx
@@ -18,6 +18,8 @@ interface ClausePreviewViewerProps {
document: any
instance: WebViewerInstance | null
setInstance: Dispatch<SetStateAction<WebViewerInstance | null>>
+ onSuccess?: () => void
+ onError?: () => void
}
export function ClausePreviewViewer({
@@ -25,140 +27,263 @@ export function ClausePreviewViewer({
document,
instance,
setInstance,
+ onSuccess,
+ onError,
}: ClausePreviewViewerProps) {
const [fileLoading, setFileLoading] = useState<boolean>(true)
+ const [loadingStage, setLoadingStage] = useState<string>("뷰어 준비 중...")
const viewer = useRef<HTMLDivElement>(null)
const initialized = useRef(false)
const isCancelled = useRef(false)
- // WebViewer 초기화
+ // WebViewer 초기화 (단계별)
useEffect(() => {
if (!initialized.current && viewer.current) {
initialized.current = true
isCancelled.current = false
- requestAnimationFrame(() => {
- if (viewer.current) {
- import("@pdftron/webviewer").then(({ default: WebViewer }) => {
- if (isCancelled.current) {
- console.log("📛 WebViewer 초기화 취소됨")
- return
- }
-
- const viewerElement = viewer.current
- if (!viewerElement) return
-
- WebViewer(
- {
- path: "/pdftronWeb",
- licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY,
- fullAPI: true,
- enableOfficeEditing: true,
- l: "ko",
- // 미리보기 모드로 설정
- enableReadOnlyMode: false,
- },
- viewerElement
- ).then(async (instance: WebViewerInstance) => {
- setInstance(instance)
-
- try {
- const { disableElements, enableElements, setToolbarGroup } = instance.UI
-
- // 미리보기에 필요한 도구만 활성화
- enableElements([
- "toolbarGroup-View",
- "zoomInButton",
- "zoomOutButton",
- "fitButton",
- "rotateCounterClockwiseButton",
- "rotateClockwiseButton",
- ])
-
- // 편집 도구는 비활성화
- disableElements([
- "toolbarGroup-Edit",
- "toolbarGroup-Insert",
- "toolbarGroup-Annotate",
- "toolbarGroup-Shapes",
- "toolbarGroup-Forms",
- ])
-
- setToolbarGroup("toolbarGroup-View")
-
- // 조항 데이터로 문서 생성
- await generateDocumentFromClauses(instance, clauses, document)
-
- } catch (uiError) {
- console.warn("⚠️ UI 설정 중 오류:", uiError)
- } finally {
- setFileLoading(false)
- }
- }).catch((error) => {
- console.error("❌ WebViewer 초기화 실패:", error)
- setFileLoading(false)
- toast.error("뷰어 초기화에 실패했습니다.")
- })
- })
- }
- })
+ initializeViewerStepByStep()
}
return () => {
if (instance) {
- instance.UI.dispose()
+ try {
+ instance.UI.dispose()
+ } catch (error) {
+ console.warn("뷰어 정리 중 오류:", error)
+ }
}
- isCancelled.current = true
+ isCancelled.current = true;
+ setTimeout(() => cleanupHtmlStyle(), 500);
}
}, [])
- // 조항 데이터로 워드 문서 생성
+ const initializeViewerStepByStep = async () => {
+ try {
+ setLoadingStage("라이브러리 로딩 중...")
+
+ // 1단계: 라이브러리 동적 import (지연 추가)
+ await new Promise(resolve => setTimeout(resolve, 300))
+ const { default: WebViewer } = await import("@pdftron/webviewer")
+
+ if (isCancelled.current || !viewer.current) {
+ console.log("📛 WebViewer 초기화 취소됨")
+ return
+ }
+
+ setLoadingStage("뷰어 초기화 중...")
+
+ // 2단계: WebViewer 인스턴스 생성
+ const webviewerInstance = await WebViewer(
+ {
+ path: "/pdftronWeb",
+ licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY,
+ fullAPI: true,
+ enableOfficeEditing: true,
+ l: "ko",
+ enableReadOnlyMode: false,
+ },
+ viewer.current
+ )
+
+ if (isCancelled.current) {
+ console.log("📛 WebViewer 초기화 취소됨")
+ return
+ }
+
+ setInstance(webviewerInstance)
+ setLoadingStage("UI 설정 중...")
+
+ // 3단계: UI 설정 (약간의 지연 후)
+ await new Promise(resolve => setTimeout(resolve, 500))
+ await configureViewerUI(webviewerInstance)
+
+ setLoadingStage("문서 생성 중...")
+
+ // 4단계: 문서 생성 (충분한 지연 후)
+ await new Promise(resolve => setTimeout(resolve, 800))
+ await generateDocumentFromClauses(webviewerInstance, clauses, document)
+
+ } catch (error) {
+ console.error("❌ WebViewer 단계별 초기화 실패:", error)
+ setFileLoading(false)
+ onError?.() // 초기화 실패 콜백 호출
+ toast.error(`뷰어 초기화 실패: ${error instanceof Error ? error.message : '알 수 없는 오류'}`)
+ }
+ }
+
+ const configureViewerUI = async (webviewerInstance: WebViewerInstance) => {
+ try {
+ const { disableElements, enableElements, setToolbarGroup } = webviewerInstance.UI
+
+ // 미리보기에 필요한 도구만 활성화
+ enableElements([
+ "toolbarGroup-View",
+ "zoomInButton",
+ "zoomOutButton",
+ "fitButton",
+ "rotateCounterClockwiseButton",
+ "rotateClockwiseButton",
+ ])
+
+ // 편집 도구는 비활성화
+ disableElements([
+ "toolbarGroup-Edit",
+ "toolbarGroup-Insert",
+ "toolbarGroup-Annotate",
+ "toolbarGroup-Shapes",
+ "toolbarGroup-Forms",
+ ])
+
+ setToolbarGroup("toolbarGroup-View")
+
+ console.log("✅ UI 설정 완료")
+ } catch (uiError) {
+ console.warn("⚠️ UI 설정 중 오류:", uiError)
+ // UI 설정 실패해도 계속 진행
+ }
+ }
+
+ // 문서 생성 함수 (재시도 로직 포함)
const generateDocumentFromClauses = async (
- instance: WebViewerInstance,
- clauses: GtcClauseTreeView[],
- document: any
+ webviewerInstance: WebViewerInstance,
+ clauses: GtcClauseTreeView[],
+ document: any,
+ retryCount = 0
) => {
+ const MAX_RETRIES = 3
+
try {
console.log("📄 조항 기반 DOCX 문서 생성 시작:", clauses.length)
-
+
// 활성화된 조항만 필터링하고 정렬
const activeClauses = clauses
.filter(clause => clause.isActive !== false)
.sort((a, b) => {
- // sortOrder 또는 itemNumber로 정렬
if (a.sortOrder && b.sortOrder) {
return parseFloat(a.sortOrder) - parseFloat(b.sortOrder)
}
return a.itemNumber.localeCompare(b.itemNumber, undefined, { numeric: true })
})
- // ✅ DOCX 문서 생성
- const docxBlob = await generateDocxDocument(activeClauses, document)
-
- // ✅ DOCX 파일로 변환
- const docxFile = new File([docxBlob], `${document?.title || 'GTC계약서'}_미리보기.docx`, {
- type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
+ if (activeClauses.length === 0) {
+ throw new Error("활성화된 조항이 없습니다.")
+ }
+
+ setLoadingStage(`문서 생성 중... (${activeClauses.length}개 조항 처리)`)
+
+ // DOCX 문서 생성 (재시도 로직 포함)
+ const docxBlob = await generateDocxDocumentWithRetry(activeClauses, document)
+
+ // 파일 생성
+ const docxFile = new File([docxBlob], `${document?.title || 'GTC계약서'}_미리보기.docx`, {
+ type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
})
+
+ setLoadingStage("문서 로딩 중...")
+
+ // WebViewer가 완전히 준비된 상태인지 확인
+ await waitForViewerReady(webviewerInstance)
+
+ // DOCX 문서 로드 (재시도 포함)
+ await loadDocumentWithRetry(webviewerInstance, docxFile)
+
+ console.log("✅ DOCX 기반 문서 생성 완료")
+ toast.success("Word 문서 미리보기가 생성되었습니다.")
+ setFileLoading(false)
+ onSuccess?.() // 성공 콜백 호출
+
+ } catch (err) {
+ console.error(`❌ DOCX 문서 생성 중 오류 (시도 ${retryCount + 1}/${MAX_RETRIES + 1}):`, err)
- // ✅ PDFTron에서 DOCX 문서로 로드
- await instance.UI.loadDocument(docxFile, {
- filename: `${document?.title || 'GTC계약서'}_미리보기.docx`,
- enableOfficeEditing: true, // DOCX 편집 모드 활성화
+ if (retryCount < MAX_RETRIES) {
+ console.log(`🔄 ${(retryCount + 1) * 1000}ms 후 재시도...`)
+ setLoadingStage(`재시도 중... (${retryCount + 1}/${MAX_RETRIES})`)
+
+ await new Promise(resolve => setTimeout(resolve, (retryCount + 1) * 1000))
+
+ if (!isCancelled.current) {
+ return generateDocumentFromClauses(webviewerInstance, clauses, document, retryCount + 1)
+ }
+ } else {
+ setFileLoading(false)
+ onError?.() // 실패 콜백 호출
+ toast.error(`문서 생성 실패: ${err instanceof Error ? err.message : '알 수 없는 오류'}`)
+ }
+ }
+ }
+
+ // WebViewer 준비 상태 확인
+ const waitForViewerReady = async (webviewerInstance: WebViewerInstance, timeout = 5000) => {
+ const startTime = Date.now()
+
+ while (Date.now() - startTime < timeout) {
+ try {
+ // UI가 준비되었는지 확인
+ if (webviewerInstance.UI && webviewerInstance.Core) {
+ console.log("✅ WebViewer 준비 완료")
+ return
+ }
+ } catch (error) {
+ // 아직 준비되지 않음
+ }
+
+ await new Promise(resolve => setTimeout(resolve, 100))
+ }
+
+ throw new Error("WebViewer 준비 시간 초과")
+ }
+
+ // 문서 로드 재시도 함수
+ const loadDocumentWithRetry = async (
+ webviewerInstance: WebViewerInstance,
+ file: File,
+ retryCount = 0
+ ) => {
+ const MAX_LOAD_RETRIES = 2
+
+ try {
+ await webviewerInstance.UI.loadDocument(file, {
+ filename: file.name,
+ enableOfficeEditing: true,
})
+ console.log("✅ 문서 로드 성공")
+ } catch (error) {
+ console.error(`문서 로드 실패 (시도 ${retryCount + 1}):`, error)
- console.log("✅ DOCX 기반 문서 생성 완료")
- toast.success("Word 문서 미리보기가 생성되었습니다.")
+ if (retryCount < MAX_LOAD_RETRIES) {
+ await new Promise(resolve => setTimeout(resolve, 1000))
+ return loadDocumentWithRetry(webviewerInstance, file, retryCount + 1)
+ } else {
+ throw new Error(`문서 로드 실패: ${error instanceof Error ? error.message : '알 수 없는 오류'}`)
+ }
+ }
+ }
+
+ // DOCX 생성 재시도 함수
+ const generateDocxDocumentWithRetry = async (
+ clauses: GtcClauseTreeView[],
+ document: any,
+ retryCount = 0
+ ): Promise<Blob> => {
+ try {
+ return await generateDocxDocument(clauses, document)
+ } catch (error) {
+ console.error(`DOCX 생성 실패 (시도 ${retryCount + 1}):`, error)
- } catch (err) {
- console.error("❌ DOCX 문서 생성 중 오류:", err)
- toast.error(`문서 생성 실패: ${err instanceof Error ? err.message : '알 수 없는 오류'}`)
+ if (retryCount < 2) {
+ await new Promise(resolve => setTimeout(resolve, 500))
+ return generateDocxDocumentWithRetry(clauses, document, retryCount + 1)
+ } else {
+ throw error
+ }
}
}
return (
<div className="relative w-full h-full overflow-hidden">
- <div
- ref={viewer}
+ <div
+ ref={viewer}
className="w-full h-full"
style={{
position: 'relative',
@@ -169,10 +294,13 @@ export function ClausePreviewViewer({
{fileLoading && (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-white bg-opacity-90 z-10">
<Loader2 className="h-8 w-8 text-blue-500 animate-spin mb-4" />
- <p className="text-sm text-muted-foreground">문서 생성 중...</p>
+ <p className="text-sm text-muted-foreground">{loadingStage}</p>
<p className="text-xs text-muted-foreground mt-1">
{clauses.filter(c => c.isActive !== false).length}개 조항 처리 중
</p>
+ <div className="mt-3 text-xs text-gray-400">
+ 초기화에 시간이 걸릴 수 있습니다...
+ </div>
</div>
)}
</div>
@@ -180,82 +308,81 @@ export function ClausePreviewViewer({
)
}
+// ===== 유틸리티 함수들 =====
-
-// ===== data URL 판별 및 디코딩 유틸 =====
+// data URL 판별 및 디코딩 유틸
function isDataUrl(url: string) {
- return /^data:/.test(url);
- }
-
- function dataUrlToUint8Array(dataUrl: string): { bytes: Uint8Array; mime: string } {
- // 형식: data:<mime>;base64,<payload>
- const match = dataUrl.match(/^data:([^;]+);base64,(.*)$/);
- if (!match) {
- // base64가 아닌 data URL도 가능하지만, 여기서는 base64만 지원
- throw new Error("지원하지 않는 data URL 형식입니다.");
- }
- const mime = match[1];
- const base64 = match[2];
- const binary = atob(base64);
- const len = binary.length;
- const bytes = new Uint8Array(len);
- for (let i = 0; i < len; i++) bytes[i] = binary.charCodeAt(i);
- return { bytes, mime };
+ return /^data:/.test(url);
+}
+
+function dataUrlToUint8Array(dataUrl: string): { bytes: Uint8Array; mime: string } {
+ // 형식: data:<mime>;base64,<payload>
+ const match = dataUrl.match(/^data:([^;]+);base64,(.*)$/);
+ if (!match) {
+ // base64가 아닌 data URL도 가능하지만, 여기서는 base64만 지원
+ throw new Error("지원하지 않는 data URL 형식입니다.");
}
-
- // ===== helper: 이미지 불러오기 + 크기 계산 (data:, http:, / 경로 모두 지원) =====
- async function fetchImageData(url: string, maxWidthPx = 500) {
- let blob: Blob;
- let bytes: Uint8Array;
-
- if (isDataUrl(url)) {
- // data URL → Uint8Array, Blob
- const { bytes: arr, mime } = dataUrlToUint8Array(url);
- bytes = arr;
- blob = new Blob([bytes], { type: mime });
- } else {
- // http(s) 또는 상대 경로
- const res = await fetch(url, { cache: "no-store" });
- if (!res.ok) throw new Error(`이미지 다운로드 실패 (${res.status})`);
- blob = await res.blob();
- const arrayBuffer = await blob.arrayBuffer();
- bytes = new Uint8Array(arrayBuffer);
- }
-
- // 원본 크기 파악 (공통)
- const dims = await new Promise<{ width: number; height: number }>((resolve) => {
- const img = new Image();
- const objectUrl = URL.createObjectURL(blob);
- img.onload = () => {
- const width = img.naturalWidth || 800;
- const height = img.naturalHeight || 600;
- URL.revokeObjectURL(objectUrl);
- resolve({ width, height });
- };
- img.onerror = () => {
- URL.revokeObjectURL(objectUrl);
- resolve({ width: 800, height: 600 }); // 실패 시 기본값
- };
- img.src = objectUrl;
- });
-
- // 비율 유지 축소
- const scale = Math.min(1, maxWidthPx / (dims.width || maxWidthPx));
- const width = Math.round((dims.width || maxWidthPx) * scale);
- const height = Math.round((dims.height || Math.round(maxWidthPx * 0.6)) * scale);
-
- return { data: bytes, width, height };
+ const mime = match[1];
+ const base64 = match[2];
+ const binary = atob(base64);
+ const len = binary.length;
+ const bytes = new Uint8Array(len);
+ for (let i = 0; i < len; i++) bytes[i] = binary.charCodeAt(i);
+ return { bytes, mime };
+}
+
+// 이미지 불러오기 + 크기 계산 (data:, http:, / 경로 모두 지원)
+async function fetchImageData(url: string, maxWidthPx = 500) {
+ let blob: Blob;
+ let bytes: Uint8Array;
+
+ if (isDataUrl(url)) {
+ // data URL → Uint8Array, Blob
+ const { bytes: arr, mime } = dataUrlToUint8Array(url);
+ bytes = arr;
+ blob = new Blob([bytes], { type: mime });
+ } else {
+ // http(s) 또는 상대 경로
+ const res = await fetch(url, { cache: "no-store" });
+ if (!res.ok) throw new Error(`이미지 다운로드 실패 (${res.status})`);
+ blob = await res.blob();
+ const arrayBuffer = await blob.arrayBuffer();
+ bytes = new Uint8Array(arrayBuffer);
}
+ // 원본 크기 파악 (공통)
+ const dims = await new Promise<{ width: number; height: number }>((resolve) => {
+ const img = new Image();
+ const objectUrl = URL.createObjectURL(blob);
+ img.onload = () => {
+ const width = img.naturalWidth || 800;
+ const height = img.naturalHeight || 600;
+ URL.revokeObjectURL(objectUrl);
+ resolve({ width, height });
+ };
+ img.onerror = () => {
+ URL.revokeObjectURL(objectUrl);
+ resolve({ width: 800, height: 600 }); // 실패 시 기본값
+ };
+ img.src = objectUrl;
+ });
+
+ // 비율 유지 축소
+ const scale = Math.min(1, maxWidthPx / (dims.width || maxWidthPx));
+ const width = Math.round((dims.width || maxWidthPx) * scale);
+ const height = Math.round((dims.height || Math.round(maxWidthPx * 0.6)) * scale);
+
+ return { data: bytes, width, height };
+}
+
// DOCX 문서 생성 (docx 라이브러리 사용)
async function generateDocxDocument(
- clauses: GtcClauseTreeView[],
- document: any
- ): Promise<Blob> {
- const { Document, Packer, Paragraph, TextRun, AlignmentType, ImageRun } = await import("docx");
-
-
-function textToParagraphs(text: string, indentLeft: number) {
+ clauses: GtcClauseTreeView[],
+ document: any
+): Promise<Blob> {
+ const { Document, Packer, Paragraph, TextRun, AlignmentType, ImageRun } = await import("docx");
+
+ function textToParagraphs(text: string, indentLeft: number) {
const lines = text.split("\n");
return [
new Paragraph({
@@ -269,11 +396,10 @@ function textToParagraphs(text: string, indentLeft: number) {
}),
];
}
-
- const IMG_TOKEN = /!\[([^\]]+)\]/g; // 예: ![image1753698566087]
+ const IMG_TOKEN = /!\[([^\]]+)\]/g; // 예: ![image1753698566087]
-async function pushContentWithInlineImages(
+ async function pushContentWithInlineImages(
content: string,
indentLeft: number,
children: any[],
@@ -284,135 +410,135 @@ async function pushContentWithInlineImages(
const start = match.index ?? 0;
const end = start + match[0].length;
const imageId = match[1];
-
+
// 앞부분 텍스트
if (start > lastIndex) {
const txt = content.slice(lastIndex, start);
children.push(...textToParagraphs(txt, indentLeft));
}
-
+
// 이미지 삽입
const imgMeta = imageMap.get(imageId);
if (imgMeta?.url) {
- const { data, width, height } = await fetchImageData(imgMeta.url, 520);
- children.push(
- new Paragraph({
- children: [
- new ImageRun({
- data,
- transformation: { width, height },
- }),
- ],
- indent: { left: indentLeft },
- })
- );
- // 사용된 이미지 표시(뒤에서 중복 추가 방지)
- imageMap.delete(imageId);
- }
- // 매칭 실패 시: 아무것도 넣지 않음(토큰 제거)
-
- lastIndex = end;
- }
-
- // 남은 꼬리 텍스트
- if (lastIndex < content.length) {
- const tail = content.slice(lastIndex);
- children.push(...textToParagraphs(tail, indentLeft));
- }
- }
-
-
- const documentTitle = document?.title || "GTC 계약서";
- const currentDate = new Date().toLocaleDateString("ko-KR");
-
- // depth 추정/정렬
- const structuredClauses = organizeClausesByHierarchy(clauses);
-
- const children: any[] = [
- new Paragraph({
- alignment: AlignmentType.CENTER,
- children: [new TextRun({ text: documentTitle, bold: true, size: 32 })],
- }),
- new Paragraph({
- alignment: AlignmentType.CENTER,
- children: [new TextRun({ text: `생성일: ${currentDate}`, size: 20, color: "666666" })],
- }),
- new Paragraph({ text: "" }),
- new Paragraph({ text: "" }),
- ];
-
- for (const clause of structuredClauses) {
- const depth = Math.min(clause.estimatedDepth || 0, 3);
- const indentLeft = depth * 400; // 번호/제목
- const indentContent = indentLeft + 200; // 본문/이미지
-
- // 번호 + 제목
- children.push(
- new Paragraph({
- children: [
- new TextRun({ text: `${clause.itemNumber}${clause.subtitle ? "." : ""}`, bold: true, color: "2563eb" }),
- ...(clause.subtitle
- ? [new TextRun({ text: " " }), new TextRun({ text: clause.subtitle, bold: true })]
- : []),
- ],
- indent: { left: indentLeft },
- })
- );
-
- const imageMap = new Map(
- Array.isArray((clause as any).images)
- ? (clause as any).images.map((im: any) => [String(im.id), im])
- : []
- );
-
- // 내용
- const hasContent = clause.content && clause.content.trim();
- if (hasContent) {
- await pushContentWithInlineImages(clause.content!, indentContent, children, imageMap);
- }
-
- // else {
- // children.push(
- // new Paragraph({
- // // children: [new TextRun({ text: "(상세 내용 없음)", italics: true, color: "6b7280", size: 20 })],
- // indent: { left: indentContent },
- // })
- // );
- // }
-
- // 본문에 등장하지 않은 잔여 이미지(선택: 뒤에 추가)
-
- for (const [, imgMeta] of imageMap) {
try {
const { data, width, height } = await fetchImageData(imgMeta.url, 520);
children.push(
new Paragraph({
- children: [new ImageRun({ data, transformation: { width, height } })],
- indent: { left: indentContent },
+ children: [
+ new ImageRun({
+ data,
+ transformation: { width, height },
+ }),
+ ],
+ indent: { left: indentLeft },
})
);
- } catch (e) {
+ // 사용된 이미지 표시(뒤에서 중복 추가 방지)
+ imageMap.delete(imageId);
+ } catch (imgError) {
+ console.warn("이미지 로드 실패:", imgMeta, imgError);
+ // 이미지 로드 실패시 텍스트로 대체
children.push(
new Paragraph({
- children: [new TextRun({ text: `이미지 로드 실패: ${imgMeta.fileName || imgMeta.url}`, color: "b91c1c", size: 20 })],
- indent: { left: indentContent },
+ children: [new TextRun({ text: `[이미지 로드 실패: ${imgMeta.fileName || imageId}]`, color: "999999" })],
+ indent: { left: indentLeft },
})
);
- console.warn("이미지 로드 실패(잔여):", imgMeta, e);
}
}
-
- // 조항 간 간격
- children.push(new Paragraph({ text: "" }));
+ // 매칭 실패 시: 아무것도 넣지 않음(토큰 제거)
+
+ lastIndex = end;
+ }
+
+ // 남은 꼬리 텍스트
+ if (lastIndex < content.length) {
+ const tail = content.slice(lastIndex);
+ children.push(...textToParagraphs(tail, indentLeft));
+ }
+ }
+
+ const documentTitle = document?.title || "GTC 계약서";
+ const currentDate = new Date().toLocaleDateString("ko-KR");
+
+ // depth 추정/정렬
+ const structuredClauses = organizeClausesByHierarchy(clauses);
+
+ const children: any[] = [
+ new Paragraph({
+ alignment: AlignmentType.CENTER,
+ children: [new TextRun({ text: documentTitle, bold: true, size: 32 })],
+ }),
+ new Paragraph({
+ alignment: AlignmentType.CENTER,
+ children: [new TextRun({ text: `생성일: ${currentDate}`, size: 20, color: "666666" })],
+ }),
+ new Paragraph({ text: "" }),
+ new Paragraph({ text: "" }),
+ ];
+
+ for (const clause of structuredClauses) {
+ const depth = Math.min(clause.estimatedDepth || 0, 3);
+ const indentLeft = depth * 400; // 번호/제목
+ const indentContent = indentLeft + 200; // 본문/이미지
+
+ // 번호 + 제목
+ children.push(
+ new Paragraph({
+ children: [
+ new TextRun({ text: `${clause.itemNumber}${clause.subtitle ? "." : ""}`, bold: true, color: "2563eb" }),
+ ...(clause.subtitle
+ ? [new TextRun({ text: " " }), new TextRun({ text: clause.subtitle, bold: true })]
+ : []),
+ ],
+ indent: { left: indentLeft },
+ })
+ );
+
+ const imageMap = new Map(
+ Array.isArray((clause as any).images)
+ ? (clause as any).images.map((im: any) => [String(im.id), im])
+ : []
+ );
+
+ // 내용
+ const hasContent = clause.content && clause.content.trim();
+ if (hasContent) {
+ await pushContentWithInlineImages(clause.content!, indentContent, children, imageMap);
}
-
- const doc = new Document({
- sections: [{ properties: {}, children }],
- });
-
- return await Packer.toBlob(doc);
+
+ // 본문에 등장하지 않은 잔여 이미지(선택: 뒤에 추가)
+ for (const [, imgMeta] of imageMap) {
+ try {
+ const { data, width, height } = await fetchImageData(imgMeta.url, 520);
+ children.push(
+ new Paragraph({
+ children: [new ImageRun({ data, transformation: { width, height } })],
+ indent: { left: indentContent },
+ })
+ );
+ } catch (e) {
+ children.push(
+ new Paragraph({
+ children: [new TextRun({ text: `이미지 로드 실패: ${imgMeta.fileName || imgMeta.url}`, color: "b91c1c", size: 20 })],
+ indent: { left: indentContent },
+ })
+ );
+ console.warn("이미지 로드 실패(잔여):", imgMeta, e);
+ }
+ }
+
+ // 조항 간 간격
+ children.push(new Paragraph({ text: "" }));
}
+ const doc = new Document({
+ sections: [{ properties: {}, children }],
+ });
+
+ return await Packer.toBlob(doc);
+}
+
// 조항들을 계층구조로 정리
function organizeClausesByHierarchy(clauses: GtcClauseTreeView[]) {
// depth가 없는 경우 itemNumber로 depth 추정
@@ -421,9 +547,9 @@ function organizeClausesByHierarchy(clauses: GtcClauseTreeView[]) {
estimatedDepth: clause.depth ?? estimateDepthFromItemNumber(clause.itemNumber)
})).sort((a, b) => {
// itemNumber 기준 자연 정렬
- return a.itemNumber.localeCompare(b.itemNumber, undefined, {
- numeric: true,
- sensitivity: 'base'
+ return a.itemNumber.localeCompare(b.itemNumber, undefined, {
+ numeric: true,
+ sensitivity: 'base'
})
})
}
@@ -433,3 +559,12 @@ function estimateDepthFromItemNumber(itemNumber: string): number {
const parts = itemNumber.split('.')
return Math.max(0, parts.length - 1)
}
+
+// WebViewer 정리 함수
+const cleanupHtmlStyle = () => {
+ // iframe 스타일 정리 (WebViewer가 추가한 스타일)
+ const elements = document.querySelectorAll('.Document_container');
+ elements.forEach((elem) => {
+ elem.remove();
+ });
+}; \ No newline at end of file
diff --git a/lib/gtc-contract/gtc-clauses/table/excel-import.tsx b/lib/gtc-contract/gtc-clauses/table/excel-import.tsx
new file mode 100644
index 00000000..d8f435f7
--- /dev/null
+++ b/lib/gtc-contract/gtc-clauses/table/excel-import.tsx
@@ -0,0 +1,340 @@
+import { ExcelColumnDef } from "@/lib/export"
+import ExcelJS from "exceljs"
+
+/**
+ * Excel 템플릿 다운로드 함수
+ */
+export async function downloadExcelTemplate(
+ columns: ExcelColumnDef[],
+ {
+ filename = "template",
+ includeExampleData = true,
+ useGroupHeader = true,
+ }: {
+ filename?: string
+ includeExampleData?: boolean
+ useGroupHeader?: boolean
+ } = {}
+): Promise<void> {
+ let sheetData: any[][]
+
+ if (useGroupHeader) {
+ // 2줄 헤더 생성
+ const row1: string[] = []
+ const row2: string[] = []
+
+ columns.forEach((col) => {
+ row1.push(col.group ?? "")
+ row2.push(col.header)
+ })
+
+ sheetData = [row1, row2]
+
+ // 예시 데이터 추가
+ if (includeExampleData) {
+ // 빈 행 3개 추가 (사용자가 데이터 입력할 공간)
+ for (let i = 0; i < 3; i++) {
+ const exampleRow = columns.map((col) => {
+ // 컬럼 타입에 따른 예시 데이터
+ if (col.id === "itemNumber") return i === 0 ? `1.${i + 1}` : i === 1 ? "2.1" : ""
+ if (col.id === "subtitle") return i === 0 ? "예시 조항 소제목" : i === 1 ? "하위 조항 예시" : ""
+ if (col.id === "content") return i === 0 ? "조항의 상세 내용을 입력합니다." : i === 1 ? "하위 조항의 내용" : ""
+ if (col.id === "category") return i === 0 ? "일반조항" : i === 1 ? "특별조항" : ""
+ if (col.id === "sortOrder") return i === 0 ? "10" : i === 1 ? "20" : ""
+ if (col.id === "parentId") return i === 1 ? "1" : ""
+ if (col.id === "isActive") return i === 0 ? "활성" : i === 1 ? "활성" : ""
+ if (col.id === "editReason") return i === 0 ? "신규 작성" : ""
+ return ""
+ })
+ sheetData.push(exampleRow)
+ }
+ }
+ } else {
+ // 1줄 헤더
+ const headerRow = columns.map((col) => col.header)
+ sheetData = [headerRow]
+
+ if (includeExampleData) {
+ // 예시 데이터 행 추가
+ const exampleRow = columns.map(() => "")
+ sheetData.push(exampleRow)
+ }
+ }
+
+ // ExcelJS로 워크북 생성
+ const workbook = new ExcelJS.Workbook()
+ const worksheet = workbook.addWorksheet("GTC조항템플릿")
+
+ // 데이터 추가
+ sheetData.forEach((arr, idx) => {
+ const row = worksheet.addRow(arr)
+
+ // 헤더 스타일 적용
+ if (useGroupHeader) {
+ if (idx < 2) {
+ row.font = { bold: true }
+ row.alignment = { horizontal: "center" }
+ row.eachCell((cell) => {
+ cell.fill = {
+ type: "pattern",
+ pattern: "solid",
+ fgColor: { argb: "FFE6F3FF" }, // 연한 파란색
+ }
+ cell.border = {
+ top: { style: "thin" },
+ left: { style: "thin" },
+ bottom: { style: "thin" },
+ right: { style: "thin" },
+ }
+ })
+ }
+ } else {
+ if (idx === 0) {
+ row.font = { bold: true }
+ row.alignment = { horizontal: "center" }
+ row.eachCell((cell) => {
+ cell.fill = {
+ type: "pattern",
+ pattern: "solid",
+ fgColor: { argb: "FFE6F3FF" },
+ }
+ })
+ }
+ }
+
+ // 예시 데이터 행 스타일
+ if (includeExampleData && idx === (useGroupHeader ? 2 : 1)) {
+ row.eachCell((cell) => {
+ cell.fill = {
+ type: "pattern",
+ pattern: "solid",
+ fgColor: { argb: "FFFFEAA7" }, // 연한 노란색
+ }
+ cell.font = { italic: true, color: { argb: "FF666666" } }
+ })
+ }
+ })
+
+ // 그룹 헤더 병합
+ if (useGroupHeader) {
+ const groupRowIndex = 1
+ const groupRow = worksheet.getRow(groupRowIndex)
+
+ let start = 1
+ let prevValue = groupRow.getCell(start).value
+
+ for (let c = 2; c <= columns.length; c++) {
+ const cellVal = groupRow.getCell(c).value
+ if (cellVal !== prevValue) {
+ if (prevValue && prevValue.toString().trim() !== "") {
+ worksheet.mergeCells(groupRowIndex, start, groupRowIndex, c - 1)
+ }
+ start = c
+ prevValue = cellVal
+ }
+ }
+
+ if (prevValue && prevValue.toString().trim() !== "") {
+ worksheet.mergeCells(groupRowIndex, start, groupRowIndex, columns.length)
+ }
+ }
+
+ // 컬럼 너비 자동 조정
+ columns.forEach((col, idx) => {
+ let width = Math.max(col.header.length + 5, 15)
+
+ // 특정 컬럼은 더 넓게
+ if (col.id === "content" || col.id === "subtitle") {
+ width = 30
+ } else if (col.id === "itemNumber") {
+ width = 15
+ } else if (col.id === "editReason") {
+ width = 20
+ }
+
+ worksheet.getColumn(idx + 1).width = width
+ })
+
+ // 사용 안내 시트 추가
+ const instructionSheet = workbook.addWorksheet("사용안내")
+ const instructions = [
+ ["GTC 조항 Excel 가져오기 사용 안내"],
+ [""],
+ ["1. 기본 규칙"],
+ [" - 첫 번째 시트(GTC조항템플릿)에 데이터를 입력하세요"],
+ [" - 헤더 행은 수정하지 마세요"],
+ [" - 예시 데이터(노란색 행)는 삭제하고 실제 데이터를 입력하세요"],
+ [""],
+ ["2. 필수 입력 항목"],
+ [" - 채번: 필수 입력 (예: 1.1, 2.3.1)"],
+ [" - 소제목: 필수 입력"],
+ [""],
+ ["3. 선택 입력 항목"],
+ [" - 상세항목: 조항의 구체적인 내용"],
+ [" - 분류: 조항의 카테고리 (예: 일반조항, 특별조항)"],
+ [" - 순서: 숫자 (기본값: 10, 20, 30...)"],
+ [" - 상위 조항 ID: 계층 구조를 만들 때 사용"],
+ [" - 활성 상태: '활성' 또는 '비활성' (기본값: 활성)"],
+ [" - 편집 사유: 작성/수정 이유"],
+ [""],
+ ["4. 자동 처리 항목"],
+ [" - ID, 생성일, 수정일: 시스템에서 자동 생성"],
+ [" - 계층 깊이: 상위 조항 ID를 기반으로 자동 계산"],
+ [" - 전체 경로: 시스템에서 자동 생성"],
+ [""],
+ ["5. 채번 규칙"],
+ [" - 같은 부모 하에서 채번은 유일해야 합니다"],
+ [" - 예: 상위 조항이 같으면 1.1, 1.2는 가능하지만 1.1이 중복되면 오류"],
+ [""],
+ ["6. 계층 구조 만들기"],
+ [" - 상위 조항 ID: 기존 조항의 ID를 입력"],
+ [" - 예: ID가 5인 조항 하위에 조항을 만들려면 상위 조항 ID에 5 입력"],
+ [" - 최상위 조항은 상위 조항 ID를 비워두세요"],
+ [""],
+ ["7. 주의사항"],
+ [" - 순서는 숫자로 입력하세요 (소수점 가능: 10, 15.5, 20)"],
+ [" - 상위 조항 ID는 반드시 존재하는 조항의 ID여야 합니다"],
+ [" - 파일 저장 시 .xlsx 형식으로 저장하세요"],
+ ]
+
+ instructions.forEach((instruction, idx) => {
+ const row = instructionSheet.addRow(instruction)
+ if (idx === 0) {
+ row.font = { bold: true, size: 14 }
+ row.alignment = { horizontal: "center" }
+ } else if (instruction[0]?.match(/^\d+\./)) {
+ row.font = { bold: true }
+ }
+ })
+
+ instructionSheet.getColumn(1).width = 80
+
+ // 파일 다운로드
+ const buffer = await workbook.xlsx.writeBuffer()
+ const blob = new Blob([buffer], {
+ type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ })
+ const url = URL.createObjectURL(blob)
+ const link = document.createElement("a")
+ link.href = url
+ link.download = `${filename}.xlsx`
+ link.click()
+ URL.revokeObjectURL(url)
+}
+
+/**
+ * Excel 파일에서 데이터 파싱
+ */
+export async function parseExcelFile<TData>(
+ file: File,
+ columns: ExcelColumnDef[],
+ {
+ hasGroupHeader = true,
+ sheetName = "GTC조항템플릿",
+ }: {
+ hasGroupHeader?: boolean
+ sheetName?: string
+ } = {}
+): Promise<{
+ data: Partial<TData>[]
+ errors: string[]
+}> {
+ const errors: string[] = []
+ const data: Partial<TData>[] = []
+
+ try {
+ const arrayBuffer = await file.arrayBuffer()
+ const workbook = new ExcelJS.Workbook()
+ await workbook.xlsx.load(arrayBuffer)
+
+ const worksheet = workbook.getWorksheet(sheetName) || workbook.worksheets[0]
+
+ if (!worksheet) {
+ errors.push("워크시트를 찾을 수 없습니다.")
+ return { data, errors }
+ }
+
+ // 헤더 행 인덱스 결정
+ const headerRowIndex = hasGroupHeader ? 2 : 1
+ const dataStartRowIndex = headerRowIndex + 1
+
+ // 헤더 검증
+ const headerRow = worksheet.getRow(headerRowIndex)
+ const expectedHeaders = columns.map(col => col.header)
+
+ for (let i = 0; i < expectedHeaders.length; i++) {
+ const cellValue = headerRow.getCell(i + 1).value?.toString() || ""
+ if (cellValue !== expectedHeaders[i]) {
+ errors.push(`헤더가 일치하지 않습니다. 예상: "${expectedHeaders[i]}", 실제: "${cellValue}"`)
+ }
+ }
+
+ if (errors.length > 0) {
+ return { data, errors }
+ }
+
+ // 데이터 파싱
+ let rowIndex = dataStartRowIndex
+ while (rowIndex <= worksheet.actualRowCount) {
+ const row = worksheet.getRow(rowIndex)
+
+ // 빈 행 체크 (모든 셀이 비어있으면 스킵)
+ const isEmpty = columns.every((col, colIndex) => {
+ const cellValue = row.getCell(colIndex + 1).value
+ return !cellValue || cellValue.toString().trim() === ""
+ })
+
+ if (isEmpty) {
+ rowIndex++
+ continue
+ }
+
+ const rowData: Partial<TData> = {}
+ let hasError = false
+
+ columns.forEach((col, colIndex) => {
+ const cellValue = row.getCell(colIndex + 1).value
+ let processedValue: any = cellValue
+
+ // 데이터 타입별 처리
+ if (cellValue !== null && cellValue !== undefined) {
+ const strValue = cellValue.toString().trim()
+
+ // 특별한 처리가 필요한 컬럼들
+ if (col.id === "isActive") {
+ processedValue = strValue === "활성"
+ } else if (col.id === "sortOrder") {
+ const numValue = parseFloat(strValue)
+ processedValue = isNaN(numValue) ? null : numValue
+ } else if (col.id === "parentId") {
+ const numValue = parseInt(strValue)
+ processedValue = isNaN(numValue) ? null : numValue
+ } else {
+ processedValue = strValue
+ }
+ }
+
+ // 필수 필드 검증
+ if ((col.id === "itemNumber" || col.id === "subtitle") && (!processedValue || processedValue === "")) {
+ errors.push(`${rowIndex}행: ${col.header}은(는) 필수 입력 항목입니다.`)
+ hasError = true
+ }
+
+ if (processedValue !== null && processedValue !== undefined && processedValue !== "") {
+ (rowData as any)[col.id] = processedValue
+ }
+ })
+
+ if (!hasError) {
+ data.push(rowData)
+ }
+
+ rowIndex++
+ }
+
+ } catch (error) {
+ errors.push(`파일 파싱 중 오류가 발생했습니다: ${error instanceof Error ? error.message : "알 수 없는 오류"}`)
+ }
+
+ return { data, errors }
+} \ No newline at end of file
diff --git a/lib/gtc-contract/gtc-clauses/table/gtc-clauses-table-toolbar-actions.tsx b/lib/gtc-contract/gtc-clauses/table/gtc-clauses-table-toolbar-actions.tsx
index 2a7452ef..ea516f49 100644
--- a/lib/gtc-contract/gtc-clauses/table/gtc-clauses-table-toolbar-actions.tsx
+++ b/lib/gtc-contract/gtc-clauses/table/gtc-clauses-table-toolbar-actions.tsx
@@ -32,42 +32,170 @@ import { type GtcClauseTreeView } from "@/db/schema/gtc"
import { CreateGtcClauseDialog } from "./create-gtc-clause-dialog"
import { PreviewDocumentDialog } from "./preview-document-dialog"
import { DeleteGtcClausesDialog } from "./delete-gtc-clauses-dialog"
+import { exportTableToExcel } from "@/lib/export"
+import { exportFullDataToExcel, type ExcelColumnDef } from "@/lib/export"
+import { getAllGtcClausesForExport, importGtcClausesFromExcel } from "../../service"
+import { ImportExcelDialog } from "./import-excel-dialog"
+import { toast } from "@/hooks/use-toast"
interface GtcClausesTableToolbarActionsProps {
table: Table<GtcClauseTreeView>
documentId: number
document: any
+ currentUserId?: number // 현재 사용자 ID 추가
}
+// GTC 조항을 위한 Excel 컬럼 정의 (실용적으로 간소화)
+const gtcClauseExcelColumns: ExcelColumnDef[] = [
+ {
+ id: "itemNumber",
+ header: "채번",
+ accessor: "itemNumber",
+ group: "필수 정보"
+ },
+ {
+ id: "subtitle",
+ header: "소제목",
+ accessor: "subtitle",
+ group: "필수 정보"
+ },
+ {
+ id: "content",
+ header: "상세항목",
+ accessor: "content",
+ group: "기본 정보"
+ },
+ {
+ id: "category",
+ header: "분류",
+ accessor: "category",
+ group: "기본 정보"
+ },
+ {
+ id: "sortOrder",
+ header: "순서",
+ accessor: "sortOrder",
+ group: "순서"
+ },
+ {
+ id: "parentId",
+ header: "상위 조항 ID",
+ accessor: "parentId",
+ group: "계층 구조"
+ },
+ {
+ id: "isActive",
+ header: "활성 상태",
+ accessor: (row) => row.isActive ? "활성" : "비활성",
+ group: "상태"
+ },
+ {
+ id: "editReason",
+ header: "편집 사유",
+ accessor: "editReason",
+ group: "추가 정보"
+ }
+]
+
export function GtcClausesTableToolbarActions({
table,
documentId,
document,
+ currentUserId = 1, // 기본값 설정 (실제로는 auth에서 가져와야 함)
}: GtcClausesTableToolbarActionsProps) {
const [showCreateDialog, setShowCreateDialog] = React.useState(false)
const [showReorderDialog, setShowReorderDialog] = React.useState(false)
const [showBulkUpdateDialog, setShowBulkUpdateDialog] = React.useState(false)
const [showGenerateVariablesDialog, setShowGenerateVariablesDialog] = React.useState(false)
- const [showPreviewDialog, setShowPreviewDialog] = React.useState(false) // ✅ 미리보기 다이얼로그 상태
+ const [showPreviewDialog, setShowPreviewDialog] = React.useState(false)
+ const [isExporting, setIsExporting] = React.useState(false)
const selectedRows = table.getSelectedRowModel().rows
const selectedCount = selectedRows.length
- // ✅ 테이블의 모든 데이터 가져오기
+ // 테이블의 모든 데이터 가져오기 (현재 페이지만)
const allClauses = table.getRowModel().rows.map(row => row.original)
- const handleExportToExcel = () => {
- // Excel 내보내기 로직
- console.log("Export to Excel")
+ // 현재 페이지 데이터만 Excel로 내보내기
+ const handleExportCurrentPageToExcel = () => {
+ exportTableToExcel(table, {
+ filename: `gtc-clauses-page-${new Date().toISOString().split('T')[0]}`,
+ excludeColumns: ["select", "actions"],
+ })
+ }
+
+ // 전체 데이터를 Excel로 내보내기
+ const handleExportAllToExcel = async () => {
+ try {
+ setIsExporting(true)
+
+ // 서버에서 전체 데이터 가져오기
+ const allData = await getAllGtcClausesForExport(documentId)
+
+ // 전체 데이터를 Excel로 내보내기
+ await exportFullDataToExcel(
+ allData,
+ gtcClauseExcelColumns,
+ {
+ filename: `gtc-clauses-all-${new Date().toISOString().split('T')[0]}`,
+ useGroupHeader: true
+ }
+ )
+
+ toast({
+ title: "내보내기 완료",
+ description: `총 ${allData.length}개의 조항이 Excel 파일로 내보내졌습니다.`,
+ })
+ } catch (error) {
+ console.error("Excel export failed:", error)
+ toast({
+ title: "내보내기 실패",
+ description: "Excel 파일 내보내기 중 오류가 발생했습니다.",
+ variant: "destructive"
+ })
+ } finally {
+ setIsExporting(false)
+ }
}
- const handleImportFromExcel = () => {
- // Excel 가져오기 로직
- console.log("Import from Excel")
+ // Excel 데이터 가져오기 처리
+ const handleImportExcelData = async (data: Partial<GtcClauseTreeView>[]) => {
+ try {
+ const result = await importGtcClausesFromExcel(documentId, data, currentUserId)
+
+ if (result.success) {
+ toast({
+ title: "가져오기 성공",
+ description: `${result.importedCount}개의 조항이 성공적으로 가져와졌습니다.`,
+ })
+
+ // 테이블 새로고침
+ handleRefreshTable()
+ } else {
+ const errorMessage = result.errors.length > 0
+ ? `오류: ${result.errors.slice(0, 3).join(', ')}${result.errors.length > 3 ? '...' : ''}`
+ : "알 수 없는 오류가 발생했습니다."
+
+ toast({
+ title: "가져오기 실패",
+ description: errorMessage,
+ variant: "destructive"
+ })
+
+ // 오류가 있어도 일부는 성공했을 수 있음
+ if (result.importedCount > 0) {
+ handleRefreshTable()
+ }
+
+ throw new Error("Import failed with errors")
+ }
+ } catch (error) {
+ console.error("Excel import failed:", error)
+ throw error // ImportExcelDialog에서 처리하도록 다시 throw
+ }
}
const handlePreviewDocument = () => {
- // ✅ 미리보기 다이얼로그 열기
setShowPreviewDialog(true)
}
@@ -108,9 +236,8 @@ export function GtcClausesTableToolbarActions({
{selectedCount > 0 && (
<>
<DeleteGtcClausesDialog
- gtcClauses={allClauses}
- onSuccess={() => table.toggleAllRowsSelected(false)}
-
+ gtcClauses={allClauses}
+ onSuccess={() => table.toggleAllRowsSelected(false)}
/>
</>
)}
@@ -118,33 +245,39 @@ export function GtcClausesTableToolbarActions({
{/* 관리 도구 드롭다운 */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
- <Button variant="outline" size="sm">
+ <Button variant="outline" size="sm" disabled={isExporting}>
<Settings2 className="mr-2 h-4 w-4" />
관리 도구
</Button>
</DropdownMenuTrigger>
- <DropdownMenuContent align="end" className="w-56">
- {/* <DropdownMenuItem onClick={handleReorderClauses}>
- <ArrowUpDown className="mr-2 h-4 w-4" />
- 조항 순서 변경
+ <DropdownMenuContent align="end" className="w-64">
+ <DropdownMenuItem onClick={handleExportCurrentPageToExcel}>
+ <Download className="mr-2 h-4 w-4" />
+ 현재 페이지 Excel로 내보내기
</DropdownMenuItem>
- <DropdownMenuItem onClick={handleGenerateVariables}>
- <Wand2 className="mr-2 h-4 w-4" />
- PDFTron 변수명 일괄 생성
- </DropdownMenuItem> */}
-
- <DropdownMenuSeparator />
-
- <DropdownMenuItem onClick={handleExportToExcel}>
+ <DropdownMenuItem
+ onClick={handleExportAllToExcel}
+ disabled={isExporting}
+ >
<Download className="mr-2 h-4 w-4" />
- Excel로 내보내기
+ {isExporting ? "내보내는 중..." : "전체 데이터 Excel로 내보내기"}
</DropdownMenuItem>
- <DropdownMenuItem onClick={handleImportFromExcel}>
- <Upload className="mr-2 h-4 w-4" />
- Excel에서 가져오기
- </DropdownMenuItem>
+ <DropdownMenuSeparator />
+
+ <ImportExcelDialog
+ documentId={documentId}
+ columns={gtcClauseExcelColumns}
+ onSuccess={handleRefreshTable}
+ onImport={handleImportExcelData}
+ trigger={
+ <DropdownMenuItem onSelect={(e) => e.preventDefault()}>
+ <Upload className="mr-2 h-4 w-4" />
+ Excel에서 가져오기
+ </DropdownMenuItem>
+ }
+ />
<DropdownMenuSeparator />
@@ -152,11 +285,6 @@ export function GtcClausesTableToolbarActions({
<Eye className="mr-2 h-4 w-4" />
문서 미리보기
</DropdownMenuItem>
-
- {/* <DropdownMenuItem onClick={handleGenerateDocument}>
- <FileText className="mr-2 h-4 w-4" />
- 최종 문서 생성
- </DropdownMenuItem> */}
</DropdownMenuContent>
</DropdownMenu>
@@ -180,7 +308,7 @@ export function GtcClausesTableToolbarActions({
)}
</div>
- {/* ✅ 미리보기 다이얼로그 */}
+ {/* 미리보기 다이얼로그 */}
<PreviewDocumentDialog
open={showPreviewDialog}
onOpenChange={setShowPreviewDialog}
diff --git a/lib/gtc-contract/gtc-clauses/table/import-excel-dialog.tsx b/lib/gtc-contract/gtc-clauses/table/import-excel-dialog.tsx
new file mode 100644
index 00000000..f37566fc
--- /dev/null
+++ b/lib/gtc-contract/gtc-clauses/table/import-excel-dialog.tsx
@@ -0,0 +1,381 @@
+"use client"
+
+import * as React from "react"
+import { Upload, Download, FileText, AlertCircle, CheckCircle2, X } from "lucide-react"
+
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table"
+import { Badge } from "@/components/ui/badge"
+import { Alert, AlertDescription } from "@/components/ui/alert"
+import { ScrollArea } from "@/components/ui/scroll-area"
+import { Separator } from "@/components/ui/separator"
+
+import { type ExcelColumnDef } from "@/lib/export"
+import { downloadExcelTemplate, parseExcelFile } from "./excel-import"
+import { type GtcClauseTreeView } from "@/db/schema/gtc"
+import { toast } from "@/hooks/use-toast"
+
+interface ImportExcelDialogProps {
+ documentId: number
+ columns: ExcelColumnDef[]
+ onSuccess?: () => void
+ onImport?: (data: Partial<GtcClauseTreeView>[]) => Promise<void>
+ trigger?: React.ReactNode
+}
+
+type ImportStep = "upload" | "preview" | "importing" | "complete"
+
+export function ImportExcelDialog({
+ documentId,
+ columns,
+ onSuccess,
+ onImport,
+ trigger,
+}: ImportExcelDialogProps) {
+ const [open, setOpen] = React.useState(false)
+ const [step, setStep] = React.useState<ImportStep>("upload")
+ const [selectedFile, setSelectedFile] = React.useState<File | null>(null)
+ const [parsedData, setParsedData] = React.useState<Partial<GtcClauseTreeView>[]>([])
+ const [errors, setErrors] = React.useState<string[]>([])
+ const [isProcessing, setIsProcessing] = React.useState(false)
+ const fileInputRef = React.useRef<HTMLInputElement>(null)
+
+ // 다이얼로그 열기/닫기 시 상태 초기화
+ const handleOpenChange = (isOpen: boolean) => {
+ setOpen(isOpen)
+ if (!isOpen) {
+ // 다이얼로그 닫을 때 상태 초기화
+ setStep("upload")
+ setSelectedFile(null)
+ setParsedData([])
+ setErrors([])
+ setIsProcessing(false)
+ if (fileInputRef.current) {
+ fileInputRef.current.value = ""
+ }
+ }
+ }
+
+ // 템플릿 다운로드
+ const handleDownloadTemplate = async () => {
+ try {
+ await downloadExcelTemplate(columns, {
+ filename: `gtc-clauses-template-${new Date().toISOString().split('T')[0]}`,
+ includeExampleData: true,
+ useGroupHeader: true,
+ })
+
+ toast({
+ title: "템플릿 다운로드 완료",
+ description: "Excel 템플릿이 다운로드되었습니다. 템플릿에 데이터를 입력한 후 업로드해주세요.",
+ })
+ } catch (error) {
+ toast({
+ title: "템플릿 다운로드 실패",
+ description: "템플릿 다운로드 중 오류가 발생했습니다.",
+ variant: "destructive",
+ })
+ }
+ }
+
+ // 파일 선택
+ const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
+ const file = event.target.files?.[0]
+ if (file) {
+ if (file.type !== "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" &&
+ file.type !== "application/vnd.ms-excel") {
+ toast({
+ title: "잘못된 파일 형식",
+ description: "Excel 파일(.xlsx, .xls)만 업로드할 수 있습니다.",
+ variant: "destructive",
+ })
+ return
+ }
+ setSelectedFile(file)
+ }
+ }
+
+ // 파일 파싱
+ const handleParseFile = async () => {
+ if (!selectedFile) return
+
+ setIsProcessing(true)
+ try {
+ const result = await parseExcelFile<GtcClauseTreeView>(
+ selectedFile,
+ columns,
+ {
+ hasGroupHeader: true,
+ sheetName: "GTC조항템플릿",
+ }
+ )
+
+ setParsedData(result.data)
+ setErrors(result.errors)
+
+ if (result.errors.length > 0) {
+ toast({
+ title: "파싱 완료 (오류 있음)",
+ description: `${result.data.length}개의 행을 파싱했지만 ${result.errors.length}개의 오류가 있습니다.`,
+ variant: "destructive",
+ })
+ } else {
+ toast({
+ title: "파싱 완료",
+ description: `${result.data.length}개의 행이 성공적으로 파싱되었습니다.`,
+ })
+ }
+
+ setStep("preview")
+ } catch (error) {
+ toast({
+ title: "파싱 실패",
+ description: "파일 파싱 중 오류가 발생했습니다.",
+ variant: "destructive",
+ })
+ } finally {
+ setIsProcessing(false)
+ }
+ }
+
+ // 데이터 가져오기 실행
+ const handleImportData = async () => {
+ if (parsedData.length === 0 || !onImport) return
+
+ setStep("importing")
+ try {
+ await onImport(parsedData)
+ setStep("complete")
+
+ toast({
+ title: "가져오기 완료",
+ description: `${parsedData.length}개의 조항이 성공적으로 가져와졌습니다.`,
+ })
+
+ // 성공 콜백 호출 후 잠시 후 다이얼로그 닫기
+ setTimeout(() => {
+ onSuccess?.()
+ setOpen(false)
+ }, 2000)
+ } catch (error) {
+ toast({
+ title: "가져오기 실패",
+ description: "데이터 가져오기 중 오류가 발생했습니다.",
+ variant: "destructive",
+ })
+ setStep("preview")
+ }
+ }
+
+ const renderUploadStep = () => (
+ <div className="space-y-6">
+ <div className="text-center">
+ <FileText className="mx-auto h-12 w-12 text-muted-foreground" />
+ <h3 className="mt-4 text-lg font-semibold">Excel 파일로 조항 가져오기</h3>
+ <p className="mt-2 text-sm text-muted-foreground">
+ 먼저 템플릿을 다운로드하여 데이터를 입력한 후 업로드해주세요.
+ </p>
+ </div>
+
+ <div className="space-y-4">
+ <div>
+ <Button
+ onClick={handleDownloadTemplate}
+ variant="outline"
+ className="w-full"
+ >
+ <Download className="mr-2 h-4 w-4" />
+ Excel 템플릿 다운로드
+ </Button>
+ <p className="mt-2 text-xs text-muted-foreground">
+ 템플릿에는 입력 가이드와 예시 데이터가 포함되어 있습니다.
+ </p>
+ </div>
+
+ <Separator />
+
+ <div>
+ <input
+ ref={fileInputRef}
+ type="file"
+ accept=".xlsx,.xls"
+ onChange={handleFileSelect}
+ className="hidden"
+ />
+ <Button
+ onClick={() => fileInputRef.current?.click()}
+ variant={selectedFile ? "secondary" : "outline"}
+ className="w-full"
+ >
+ <Upload className="mr-2 h-4 w-4" />
+ {selectedFile ? selectedFile.name : "Excel 파일 선택"}
+ </Button>
+ </div>
+
+ {selectedFile && (
+ <Button
+ onClick={handleParseFile}
+ disabled={isProcessing}
+ className="w-full"
+ >
+ {isProcessing ? "파싱 중..." : "파일 분석하기"}
+ </Button>
+ )}
+ </div>
+ </div>
+ )
+
+ const renderPreviewStep = () => (
+ <div className="space-y-4">
+ <div className="flex items-center justify-between">
+ <h3 className="text-lg font-semibold">데이터 미리보기</h3>
+ <div className="flex items-center gap-2">
+ <Badge variant="secondary">
+ {parsedData.length}개 행
+ </Badge>
+ {errors.length > 0 && (
+ <Badge variant="destructive">
+ {errors.length}개 오류
+ </Badge>
+ )}
+ </div>
+ </div>
+
+ {errors.length > 0 && (
+ <Alert variant="destructive">
+ <AlertCircle className="h-4 w-4" />
+ <AlertDescription>
+ <div className="space-y-1">
+ <div className="font-medium">다음 오류들을 확인해주세요:</div>
+ <ul className="list-disc list-inside space-y-1 text-sm">
+ {errors.slice(0, 5).map((error, index) => (
+ <li key={index}>{error}</li>
+ ))}
+ {errors.length > 5 && (
+ <li>... 및 {errors.length - 5}개 추가 오류</li>
+ )}
+ </ul>
+ </div>
+ </AlertDescription>
+ </Alert>
+ )}
+
+ <ScrollArea className="h-[300px] border rounded-md">
+ <Table>
+ <TableHeader>
+ <TableRow>
+ <TableHead className="w-12">#</TableHead>
+ <TableHead>채번</TableHead>
+ <TableHead>소제목</TableHead>
+ <TableHead>상세항목</TableHead>
+ <TableHead>분류</TableHead>
+ <TableHead>상태</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {parsedData.map((item, index) => (
+ <TableRow key={index}>
+ <TableCell>{index + 1}</TableCell>
+ <TableCell className="font-mono">
+ {item.itemNumber || "-"}
+ </TableCell>
+ <TableCell className="max-w-[200px] truncate">
+ {item.subtitle || "-"}
+ </TableCell>
+ <TableCell className="max-w-[300px] truncate">
+ {item.content || "-"}
+ </TableCell>
+ <TableCell>{item.category || "-"}</TableCell>
+ <TableCell>
+ <Badge variant={item.isActive ? "default" : "secondary"}>
+ {item.isActive ? "활성" : "비활성"}
+ </Badge>
+ </TableCell>
+ </TableRow>
+ ))}
+ </TableBody>
+ </Table>
+ </ScrollArea>
+
+ <div className="flex gap-2">
+ <Button
+ variant="outline"
+ onClick={() => setStep("upload")}
+ className="flex-1"
+ >
+ 다시 선택
+ </Button>
+ <Button
+ onClick={handleImportData}
+ disabled={parsedData.length === 0 || errors.length > 0}
+ className="flex-1"
+ >
+ {errors.length > 0 ? "오류 수정 후 가져오기" : `${parsedData.length}개 조항 가져오기`}
+ </Button>
+ </div>
+ </div>
+ )
+
+ const renderImportingStep = () => (
+ <div className="text-center py-8">
+ <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto"></div>
+ <h3 className="mt-4 text-lg font-semibold">가져오는 중...</h3>
+ <p className="mt-2 text-sm text-muted-foreground">
+ {parsedData.length}개의 조항을 데이터베이스에 저장하고 있습니다.
+ </p>
+ </div>
+ )
+
+ const renderCompleteStep = () => (
+ <div className="text-center py-8">
+ <CheckCircle2 className="mx-auto h-12 w-12 text-green-500" />
+ <h3 className="mt-4 text-lg font-semibold">가져오기 완료!</h3>
+ <p className="mt-2 text-sm text-muted-foreground">
+ {parsedData.length}개의 조항이 성공적으로 가져와졌습니다.
+ </p>
+ </div>
+ )
+
+ return (
+ <Dialog open={open} onOpenChange={handleOpenChange}>
+ <DialogTrigger asChild>
+ {trigger || (
+ <Button variant="outline" size="sm">
+ <Upload className="mr-2 h-4 w-4" />
+ Excel에서 가져오기
+ </Button>
+ )}
+ </DialogTrigger>
+ <DialogContent className="max-w-4xl max-h-[80vh] overflow-hidden">
+ <DialogHeader>
+ <DialogTitle>Excel에서 조항 가져오기</DialogTitle>
+ <DialogDescription>
+ Excel 파일을 사용하여 여러 조항을 한 번에 가져올 수 있습니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="mt-4">
+ {step === "upload" && renderUploadStep()}
+ {step === "preview" && renderPreviewStep()}
+ {step === "importing" && renderImportingStep()}
+ {step === "complete" && renderCompleteStep()}
+ </div>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/gtc-contract/gtc-clauses/table/preview-document-dialog.tsx b/lib/gtc-contract/gtc-clauses/table/preview-document-dialog.tsx
index 29ab1b5a..3639c0f3 100644
--- a/lib/gtc-contract/gtc-clauses/table/preview-document-dialog.tsx
+++ b/lib/gtc-contract/gtc-clauses/table/preview-document-dialog.tsx
@@ -12,7 +12,8 @@ import {
Loader2,
FileText,
RefreshCw,
- Settings
+ Settings,
+ AlertCircle
} from "lucide-react"
import { toast } from "sonner"
@@ -35,6 +36,7 @@ export function PreviewDocumentDialog({
const [isGenerating, setIsGenerating] = React.useState(false)
const [documentGenerated, setDocumentGenerated] = React.useState(false)
const [viewerInstance, setViewerInstance] = React.useState<any>(null)
+ const [hasError, setHasError] = React.useState(false)
// 조항 통계 계산
const stats = React.useMemo(() => {
@@ -52,38 +54,84 @@ export function PreviewDocumentDialog({
const handleGeneratePreview = async () => {
setIsGenerating(true)
+ setHasError(false)
+ setDocumentGenerated(false)
+
try {
- // 잠시 후 문서 생성 완료로 설정 (실제로는 뷰어에서 처리)
- setTimeout(() => {
+ // 실제로는 ClausePreviewViewer에서 문서 생성을 처리하므로
+ // 여기서는 상태만 관리
+ console.log("🚀 문서 미리보기 생성 시작")
+
+ // ClausePreviewViewer가 완전히 로드될 때까지 기다림
+ await new Promise(resolve => setTimeout(resolve, 2000))
+
+ if (!hasError) {
setDocumentGenerated(true)
- setIsGenerating(false)
toast.success("문서 미리보기가 생성되었습니다.")
- }, 1500)
+ }
} catch (error) {
- setIsGenerating(false)
+ console.error("문서 생성 중 오류:", error)
+ setHasError(true)
toast.error("문서 생성 중 오류가 발생했습니다.")
+ } finally {
+ setIsGenerating(false)
}
}
const handleExportDocument = () => {
if (viewerInstance) {
- // PDFTron의 다운로드 기능 실행
- viewerInstance.UI.downloadPdf({
- filename: `${document?.title || 'GTC계약서'}_미리보기.pdf`
- })
- toast.success("문서가 다운로드됩니다.")
+ try {
+ // PDFTron의 다운로드 기능 실행
+ viewerInstance.UI.downloadPdf({
+ filename: `${document?.title || 'GTC계약서'}_미리보기.pdf`
+ })
+ toast.success("PDF 다운로드가 시작됩니다.")
+ } catch (error) {
+ console.error("다운로드 오류:", error)
+ toast.error("다운로드 중 오류가 발생했습니다.")
+ }
+ } else {
+ toast.error("뷰어가 준비되지 않았습니다.")
}
}
const handleRegenerateDocument = () => {
+ console.log("🔄 문서 재생성 시작")
setDocumentGenerated(false)
+ setHasError(false)
handleGeneratePreview()
}
+ const handleViewerSuccess = React.useCallback(() => {
+ setDocumentGenerated(true)
+ setIsGenerating(false)
+ setHasError(false)
+ }, [])
+
+ const handleViewerError = React.useCallback(() => {
+ setHasError(true)
+ setIsGenerating(false)
+ setDocumentGenerated(false)
+ }, [])
+
+ // 다이얼로그가 열릴 때 자동으로 미리보기 생성
+ React.useEffect(() => {
+ if (props.open && !documentGenerated && !isGenerating && !hasError) {
+ const timer = setTimeout(() => {
+ handleGeneratePreview()
+ }, 300) // 다이얼로그 애니메이션 후 시작
+
+ return () => clearTimeout(timer)
+ }
+ }, [props.open, documentGenerated, isGenerating, hasError])
+
+ // 다이얼로그가 닫힐 때 상태 초기화
React.useEffect(() => {
- // 다이얼로그가 열릴 때 자동으로 미리보기 생성
- if (props.open && !documentGenerated && !isGenerating) {
- handleGeneratePreview()
+ if (!props.open) {
+ setDocumentGenerated(false)
+ setIsGenerating(false)
+ setHasError(false)
+ setViewerInstance(null)
}
}, [props.open])
@@ -107,9 +155,15 @@ export function PreviewDocumentDialog({
<FileText className="h-4 w-4" />
<span className="font-medium">{document?.title || 'GTC 계약서'}</span>
<Badge variant="outline">{stats.total}개 조항</Badge>
+ {hasError && (
+ <Badge variant="destructive" className="gap-1">
+ <AlertCircle className="h-3 w-3" />
+ 오류 발생
+ </Badge>
+ )}
</div>
<div className="flex items-center gap-2">
- {documentGenerated && (
+ {documentGenerated && !hasError && (
<>
<Button
variant="outline"
@@ -117,19 +171,31 @@ export function PreviewDocumentDialog({
onClick={handleRegenerateDocument}
disabled={isGenerating}
>
- <RefreshCw className="mr-2 h-3 w-3" />
+ <RefreshCw className={`mr-2 h-3 w-3 ${isGenerating ? 'animate-spin' : ''}`} />
재생성
</Button>
<Button
variant="outline"
size="sm"
onClick={handleExportDocument}
+ disabled={!viewerInstance}
>
<Download className="mr-2 h-3 w-3" />
PDF 다운로드
</Button>
</>
)}
+ {hasError && (
+ <Button
+ variant="default"
+ size="sm"
+ onClick={handleRegenerateDocument}
+ disabled={isGenerating}
+ >
+ <RefreshCw className={`mr-2 h-3 w-3 ${isGenerating ? 'animate-spin' : ''}`} />
+ 다시 시도
+ </Button>
+ )}
</div>
</div>
@@ -164,6 +230,21 @@ export function PreviewDocumentDialog({
<p className="text-sm text-muted-foreground">
{stats.total}개의 조항을 배치하고 있습니다.
</p>
+ <p className="text-xs text-gray-400 mt-2">
+ 초기화에 시간이 걸릴 수 있습니다...
+ </p>
+ </div>
+ ) : hasError ? (
+ <div className="absolute inset-0 flex flex-col items-center justify-center bg-muted/10">
+ <AlertCircle className="h-12 w-12 text-destructive mb-4" />
+ <p className="text-lg font-medium mb-2 text-destructive">문서 생성 실패</p>
+ <p className="text-sm text-muted-foreground mb-4 text-center max-w-md">
+ 문서 생성 중 오류가 발생했습니다. 네트워크 연결이나 파일 권한을 확인해주세요.
+ </p>
+ <Button onClick={handleRegenerateDocument} disabled={isGenerating}>
+ <RefreshCw className="mr-2 h-4 w-4" />
+ 다시 시도
+ </Button>
</div>
) : documentGenerated ? (
<ClausePreviewViewer
@@ -171,6 +252,8 @@ export function PreviewDocumentDialog({
document={document}
instance={viewerInstance}
setInstance={setViewerInstance}
+ onSuccess={handleViewerSuccess}
+ onError={handleViewerError}
/>
) : (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-muted/10">
diff --git a/lib/gtc-contract/service.ts b/lib/gtc-contract/service.ts
index 308c52bf..4d11ad0a 100644
--- a/lib/gtc-contract/service.ts
+++ b/lib/gtc-contract/service.ts
@@ -1,13 +1,13 @@
'use server'
import { revalidateTag, unstable_cache } from "next/cache"
-import { and, desc, asc, eq, or, ilike, count, max , inArray} from "drizzle-orm"
+import { and, desc, asc, eq, or, ilike, count, max , inArray, isNotNull, notInArray} from "drizzle-orm"
import db from "@/db/db"
-import { gtcDocuments, gtcDocumentsView, type GtcDocument, type GtcDocumentWithRelations } from "@/db/schema/gtc"
+import { GtcClauseTreeView, gtcClauses, gtcDocuments, gtcDocumentsView, type GtcDocument, type GtcDocumentWithRelations } from "@/db/schema/gtc"
import { projects } from "@/db/schema/projects"
import { users } from "@/db/schema/users"
import { filterColumns } from "@/lib/filter-columns"
-import type { GetGtcDocumentsSchema, CreateGtcDocumentSchema, UpdateGtcDocumentSchema, CreateNewRevisionSchema } from "./validations"
+import type { GetGtcDocumentsSchema, CreateGtcDocumentSchema, UpdateGtcDocumentSchema, CreateNewRevisionSchema, CloneGtcDocumentSchema } from "./validations"
/**
* 프로젝트 존재 여부 확인
@@ -330,4 +330,518 @@ export async function getGtcDocumentById(id: number) {
tags: [`gtc-document-${id}`, "gtc-documents"],
}
)()
+}
+
+// 복제 함수
+export async function cloneGtcDocument(
+ data: CloneGtcDocumentSchema & { createdById: number }
+): Promise<{ data?: GtcDocument; error?: string }> {
+ try {
+ return await db.transaction(async (tx) => {
+ // 1. 원본 문서 조회
+ const [sourceDocument] = await tx
+ .select()
+ .from(gtcDocuments)
+ .where(eq(gtcDocuments.id, data.sourceDocumentId))
+
+ if (!sourceDocument) {
+ throw new Error("원본 문서를 찾을 수 없습니다.")
+ }
+
+ // 2. 새로운 리비전 번호 계산
+ const nextRevision = await getNextRevision(data.type, data.projectId || undefined)
+
+ // 3. 새 문서 생성
+ const [newDocument] = await tx
+ .insert(gtcDocuments)
+ .values({
+ type: data.type,
+ projectId: data.projectId,
+ title: data.title || sourceDocument.title,
+ revision: nextRevision,
+ fileName: sourceDocument.fileName, // 파일 정보도 복사
+ filePath: sourceDocument.filePath,
+ fileSize: sourceDocument.fileSize,
+ createdById: data.createdById,
+ updatedById: data.createdById,
+ editReason: data.editReason || `${sourceDocument.title || 'GTC 문서'} v${sourceDocument.revision}에서 복제`,
+ isActive: true,
+ })
+ .returning()
+
+ // 4. 원본 문서의 모든 clauses 조회
+ const sourceClauses = await tx
+ .select()
+ .from(gtcClauses)
+ .where(eq(gtcClauses.documentId, data.sourceDocumentId))
+ .orderBy(gtcClauses.sortOrder)
+
+ // 5. clauses 복제 (ID 매핑을 위한 Map 생성)
+ if (sourceClauses.length > 0) {
+ const clauseIdMapping = new Map<number, number>()
+
+ // 첫 번째 pass: 모든 clauses를 복사하고 ID 매핑 생성
+ for (const sourceClause of sourceClauses) {
+ const [newClause] = await tx
+ .insert(gtcClauses)
+ .values({
+ documentId: newDocument.id,
+ parentId: null, // 첫 번째 pass에서는 null로 설정
+ itemNumber: sourceClause.itemNumber,
+ category: sourceClause.category,
+ subtitle: sourceClause.subtitle,
+ content: sourceClause.content,
+ sortOrder: sourceClause.sortOrder,
+ depth: sourceClause.depth,
+ fullPath: sourceClause.fullPath,
+ images: sourceClause.images,
+ isActive: sourceClause.isActive,
+ createdById: data.createdById,
+ updatedById: data.createdById,
+ editReason: data.editReason || "문서 복제",
+ })
+ .returning()
+
+ clauseIdMapping.set(sourceClause.id, newClause.id)
+ }
+
+ // 두 번째 pass: parentId 관계 설정
+ for (const sourceClause of sourceClauses) {
+ if (sourceClause.parentId) {
+ const newParentId = clauseIdMapping.get(sourceClause.parentId)
+ const newClauseId = clauseIdMapping.get(sourceClause.id)
+
+ if (newParentId && newClauseId) {
+ await tx
+ .update(gtcClauses)
+ .set({ parentId: newParentId })
+ .where(eq(gtcClauses.id, newClauseId))
+ }
+ }
+ }
+ }
+
+ revalidateTag("gtc-documents")
+ revalidateTag(`gtc-clauses-${newDocument.id}`)
+
+ return { data: newDocument }
+ })
+ } catch (error) {
+ console.error("Error cloning GTC document:", error)
+ return {
+ error: error instanceof Error
+ ? error.message
+ : "문서 복제 중 오류가 발생했습니다."
+ }
+ }
+}
+
+
+// 새 함수: GTC 문서가 없는 프로젝트만 조회
+export async function getAvailableProjectsForGtc(): Promise<ProjectForFilter[]> {
+ // 이미 GTC 문서가 있는 프로젝트 ID들 조회
+ const projectsWithGtc = await db
+ .selectDistinct({
+ projectId: gtcDocuments.projectId
+ })
+ .from(gtcDocuments)
+ .where(isNotNull(gtcDocuments.projectId))
+
+ const usedProjectIds = projectsWithGtc
+ .map(row => row.projectId)
+ .filter((id): id is number => id !== null)
+
+ // GTC 문서가 없는 프로젝트들만 반환
+ if (usedProjectIds.length === 0) {
+ // 사용된 프로젝트가 없으면 모든 프로젝트 반환
+ return await getProjectsForSelect()
+ }
+
+ return await db
+ .select({
+ id: projects.id,
+ code: projects.code,
+ name: projects.name,
+ })
+ .from(projects)
+ .where(notInArray(projects.id, usedProjectIds))
+ .orderBy(projects.name)
+}
+
+// 복제시 사용할 함수: 특정 프로젝트는 제외하고 조회
+export async function getAvailableProjectsForGtcExcluding(excludeProjectId?: number): Promise<ProjectForFilter[]> {
+ // 이미 GTC 문서가 있는 프로젝트 ID들 조회
+ const projectsWithGtc = await db
+ .selectDistinct({
+ projectId: gtcDocuments.projectId
+ })
+ .from(gtcDocuments)
+ .where(isNotNull(gtcDocuments.projectId))
+
+ let usedProjectIds = projectsWithGtc
+ .map(row => row.projectId)
+ .filter((id): id is number => id !== null)
+
+ // 제외할 프로젝트 ID가 있다면 사용된 ID 목록에서 제거 (복제시 원본 프로젝트는 선택 가능)
+ if (excludeProjectId) {
+ usedProjectIds = usedProjectIds.filter(id => id !== excludeProjectId)
+ }
+
+ // GTC 문서가 없는 프로젝트들만 반환
+ if (usedProjectIds.length === 0) {
+ return await getProjectsForSelect()
+ }
+
+ return await db
+ .select({
+ id: projects.id,
+ code: projects.code,
+ name: projects.name,
+ })
+ .from(projects)
+ .where(notInArray(projects.id, usedProjectIds))
+ .orderBy(projects.name)
+}
+
+export async function hasStandardGtcDocument(): Promise<boolean> {
+ const result = await db
+ .select({ id: gtcDocuments.id })
+ .from(gtcDocuments)
+ .where(eq(gtcDocuments.type, "standard"))
+ .limit(1)
+
+ return result.length > 0
+}
+
+export async function getAllGtcClausesForExport(documentId: number): Promise<GtcClauseTreeView[]> {
+ try {
+ // 실제 데이터베이스 쿼리 로직을 여기에 구현
+ // 예시: 문서 ID에 해당하는 모든 조항을 트리 뷰 형태로 가져오기
+ const clauses = await db
+ .select()
+ .from(gtcClauses)
+ .where(eq(gtcClauses.documentId, documentId))
+ // 여기에 필요한 JOIN, ORDER BY 등을 추가
+ .orderBy(gtcClauses.sortOrder)
+
+ // GtcClauseTreeView 형태로 변환하여 반환
+ return clauses.map((clause) => ({
+ ...clause,
+ // 필요한 추가 필드들을 여기에 매핑
+ })) as GtcClauseTreeView[]
+ } catch (error) {
+ console.error("Failed to fetch GTC clauses for export:", error)
+ throw new Error("Failed to fetch GTC clauses for export")
+ }
+}
+
+
+interface ImportGtcClauseData {
+ itemNumber: string
+ subtitle: string
+ content?: string
+ category?: string
+ sortOrder?: number
+ parentId?: number | null
+ depth?: number
+ fullPath?: string
+ images?: any[]
+ isActive?: boolean
+ editReason?: string
+}
+
+interface ImportResult {
+ success: boolean
+ importedCount: number
+ errors: string[]
+ duplicates: string[]
+}
+
+/**
+ * Excel에서 가져온 GTC 조항들을 데이터베이스에 저장
+ */
+export async function importGtcClausesFromExcel(
+ documentId: number,
+ data: Partial<GtcClauseTreeView>[],
+ userId: number = 1 // TODO: 실제 사용자 ID로 교체
+): Promise<ImportResult> {
+ const result: ImportResult = {
+ success: false,
+ importedCount: 0,
+ errors: [],
+ duplicates: []
+ }
+
+ try {
+ // 데이터 검증 및 변환
+ const validData: ImportGtcClauseData[] = []
+
+ for (let i = 0; i < data.length; i++) {
+ const item = data[i]
+ const rowNumber = i + 1
+
+ // 필수 필드 검증
+ if (!item.itemNumber || typeof item.itemNumber !== 'string' || item.itemNumber.trim() === '') {
+ result.errors.push(`${rowNumber}행: 채번은 필수 항목입니다.`)
+ continue
+ }
+
+ if (!item.subtitle || typeof item.subtitle !== 'string' || item.subtitle.trim() === '') {
+ result.errors.push(`${rowNumber}행: 소제목은 필수 항목입니다.`)
+ continue
+ }
+
+ // 중복 채번 체크 (같은 문서 내에서, 같은 부모 하에서)
+ const existingClause = await db
+ .select({ id: gtcClauses.id, itemNumber: gtcClauses.itemNumber })
+ .from(gtcClauses)
+ .where(
+ and(
+ eq(gtcClauses.documentId, documentId),
+ eq(gtcClauses.parentId, item.parentId || null),
+ eq(gtcClauses.itemNumber, item.itemNumber.trim())
+ )
+ )
+ .limit(1)
+
+ if (existingClause.length > 0) {
+ result.duplicates.push(`${rowNumber}행: 채번 "${item.itemNumber}"는 이미 존재합니다.`)
+ continue
+ }
+
+ // 상위 조항 ID 검증 (제공된 경우)
+ if (item.parentId && typeof item.parentId === 'number') {
+ const parentExists = await db
+ .select({ id: gtcClauses.id })
+ .from(gtcClauses)
+ .where(
+ and(
+ eq(gtcClauses.documentId, documentId),
+ eq(gtcClauses.id, item.parentId)
+ )
+ )
+ .limit(1)
+
+ if (parentExists.length === 0) {
+ result.errors.push(`${rowNumber}행: 상위 조항 ID ${item.parentId}를 찾을 수 없습니다.`)
+ continue
+ }
+ }
+
+ // sortOrder를 decimal로 변환
+ let sortOrder = 0
+ if (item.sortOrder !== undefined) {
+ if (typeof item.sortOrder === 'number') {
+ sortOrder = item.sortOrder
+ } else if (typeof item.sortOrder === 'string') {
+ const parsed = parseFloat(item.sortOrder)
+ if (!isNaN(parsed)) {
+ sortOrder = parsed
+ }
+ }
+ } else {
+ // 기본값: (현재 인덱스 + 1) * 10
+ sortOrder = (validData.length + 1) * 10
+ }
+
+ // depth 계산 (parentId가 있으면 부모의 depth + 1, 아니면 0)
+ let depth = 0
+ if (item.parentId) {
+ const parentClause = await db
+ .select({ depth: gtcClauses.depth })
+ .from(gtcClauses)
+ .where(eq(gtcClauses.id, item.parentId))
+ .limit(1)
+
+ if (parentClause.length > 0) {
+ depth = (parentClause[0].depth || 0) + 1
+ }
+ }
+
+ // 유효한 데이터 추가
+ validData.push({
+ itemNumber: item.itemNumber.trim(),
+ subtitle: item.subtitle.trim(),
+ content: item.content?.toString().trim() || null,
+ category: item.category?.toString().trim() || null,
+ sortOrder: sortOrder,
+ parentId: item.parentId || null,
+ isActive: typeof item.isActive === 'boolean' ? item.isActive : true,
+ editReason: item.editReason?.toString().trim() || null,
+ })
+ }
+
+ // 오류가 있거나 중복이 있으면 가져오기 중단
+ if (result.errors.length > 0 || result.duplicates.length > 0) {
+ return result
+ }
+
+ // 트랜잭션으로 데이터 저장
+ await db.transaction(async (tx) => {
+ for (const clauseData of validData) {
+ try {
+ // depth 재계산 (저장 시점에서)
+ let finalDepth = 0
+ if (clauseData.parentId) {
+ const parentClause = await tx
+ .select({ depth: gtcClauses.depth })
+ .from(gtcClauses)
+ .where(eq(gtcClauses.id, clauseData.parentId))
+ .limit(1)
+
+ if (parentClause.length > 0) {
+ finalDepth = (parentClause[0].depth || 0) + 1
+ }
+ }
+
+ await tx.insert(gtcClauses).values({
+ documentId,
+ parentId: clauseData.parentId,
+ itemNumber: clauseData.itemNumber,
+ category: clauseData.category,
+ subtitle: clauseData.subtitle,
+ content: clauseData.content,
+ sortOrder:clauseData.sortOrder? clauseData.sortOrder.toString() :"0", // decimal로 저장
+ depth: finalDepth,
+ fullPath: null, // 추후 별도 로직에서 생성
+ images: null, // Excel 가져오기에서는 이미지 제외
+ isActive: clauseData.isActive,
+ createdById: userId,
+ updatedById: userId,
+ editReason: clauseData.editReason,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ })
+
+ result.importedCount++
+ } catch (insertError) {
+ result.errors.push(`"${clauseData.subtitle}" 저장 중 오류: ${insertError instanceof Error ? insertError.message : '알 수 없는 오류'}`)
+ }
+ }
+ })
+
+ result.success = result.importedCount > 0 && result.errors.length === 0
+
+ return result
+
+ } catch (error) {
+ result.errors.push(`가져오기 처리 중 오류가 발생했습니다: ${error instanceof Error ? error.message : '알 수 없는 오류'}`)
+ return result
+ }
+}
+
+/**
+ * Excel 가져오기 전 데이터 유효성 검사
+ */
+export async function validateGtcClausesImport(
+ documentId: number,
+ data: Partial<GtcClauseTreeView>[]
+): Promise<{
+ valid: boolean
+ errors: string[]
+ warnings: string[]
+ summary: {
+ totalRows: number
+ validRows: number
+ duplicateCount: number
+ errorCount: number
+ }
+}> {
+ const errors: string[] = []
+ const warnings: string[] = []
+ let validRows = 0
+ let duplicateCount = 0
+
+ try {
+ // 기존 채번들 가져오기 (중복 체크용)
+ const existingItems = await db
+ .select({
+ itemNumber: gtcClauses.itemNumber,
+ parentId: gtcClauses.parentId
+ })
+ .from(gtcClauses)
+ .where(eq(gtcClauses.documentId, documentId))
+
+ // 채번-부모ID 조합으로 중복 체크 세트 생성
+ const existingItemSet = new Set(
+ existingItems.map(item => `${item.parentId || 'null'}:${item.itemNumber.toLowerCase()}`)
+ )
+
+ // 같은 파일 내에서의 중복 체크를 위한 세트
+ const currentFileItems = new Set<string>()
+
+ for (let i = 0; i < data.length; i++) {
+ const item = data[i]
+ const rowNumber = i + 1
+ let hasError = false
+
+ // 필수 필드 검증
+ if (!item.itemNumber || typeof item.itemNumber !== 'string' || item.itemNumber.trim() === '') {
+ errors.push(`${rowNumber}행: 채번은 필수 항목입니다.`)
+ hasError = true
+ }
+
+ if (!item.subtitle || typeof item.subtitle !== 'string' || item.subtitle.trim() === '') {
+ errors.push(`${rowNumber}행: 소제목은 필수 항목입니다.`)
+ hasError = true
+ }
+
+ if (item.itemNumber && item.itemNumber.trim() !== '') {
+ const itemKey = `${item.parentId || 'null'}:${item.itemNumber.toLowerCase()}`
+
+ // DB에 이미 존재하는지 체크
+ if (existingItemSet.has(itemKey)) {
+ warnings.push(`${rowNumber}행: 채번 "${item.itemNumber}"는 이미 존재합니다.`)
+ duplicateCount++
+ }
+
+ // 현재 파일 내에서 중복인지 체크
+ if (currentFileItems.has(itemKey)) {
+ errors.push(`${rowNumber}행: 채번 "${item.itemNumber}"가 파일 내에서 중복됩니다.`)
+ hasError = true
+ } else {
+ currentFileItems.add(itemKey)
+ }
+ }
+
+ // 숫자 필드 검증
+ if (item.sortOrder !== undefined) {
+ const sortOrderNum = typeof item.sortOrder === 'string' ? parseFloat(item.sortOrder) : item.sortOrder
+ if (typeof sortOrderNum !== 'number' || isNaN(sortOrderNum) || sortOrderNum < 0) {
+ warnings.push(`${rowNumber}행: 순서는 0 이상의 숫자여야 합니다.`)
+ }
+ }
+
+ if (!hasError) {
+ validRows++
+ }
+ }
+
+ return {
+ valid: errors.length === 0,
+ errors,
+ warnings,
+ summary: {
+ totalRows: data.length,
+ validRows,
+ duplicateCount,
+ errorCount: errors.length
+ }
+ }
+
+ } catch (error) {
+ errors.push(`유효성 검사 중 오류가 발생했습니다: ${error instanceof Error ? error.message : '알 수 없는 오류'}`)
+
+ return {
+ valid: false,
+ errors,
+ warnings,
+ summary: {
+ totalRows: data.length,
+ validRows: 0,
+ duplicateCount: 0,
+ errorCount: errors.length
+ }
+ }
+ }
} \ No newline at end of file
diff --git a/lib/gtc-contract/status/clone-gtc-document-dialog.tsx b/lib/gtc-contract/status/clone-gtc-document-dialog.tsx
new file mode 100644
index 00000000..1e56f2f7
--- /dev/null
+++ b/lib/gtc-contract/status/clone-gtc-document-dialog.tsx
@@ -0,0 +1,383 @@
+"use client"
+
+import * as React from "react"
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog"
+import { Button } from "@/components/ui/button"
+import { Textarea } from "@/components/ui/textarea"
+
+import {
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import {
+ Popover,
+ PopoverTrigger,
+ PopoverContent,
+} from "@/components/ui/popover"
+import {
+ Command,
+ CommandInput,
+ CommandList,
+ CommandGroup,
+ CommandItem,
+ CommandEmpty,
+} from "@/components/ui/command"
+import { Check, ChevronsUpDown, Loader, Copy } from "lucide-react"
+import { cn } from "@/lib/utils"
+import { toast } from "sonner"
+
+import { cloneGtcDocumentSchema, type CloneGtcDocumentSchema } from "@/lib/gtc-contract/validations"
+import { cloneGtcDocument, getAvailableProjectsForGtcExcluding, hasStandardGtcDocument } from "@/lib/gtc-contract/service"
+import { type ProjectForFilter } from "@/lib/gtc-contract/service"
+import { type GtcDocumentWithRelations } from "@/db/schema/gtc"
+import { useSession } from "next-auth/react"
+import { Input } from "@/components/ui/input"
+import { useRouter } from "next/navigation"
+
+interface CloneGtcDocumentDialogProps {
+ sourceDocument: GtcDocumentWithRelations
+ open?: boolean
+ onOpenChange?: (open: boolean) => void
+}
+
+export function CloneGtcDocumentDialog({
+ sourceDocument,
+ open: controlledOpen,
+ onOpenChange: controlledOnOpenChange
+}: CloneGtcDocumentDialogProps) {
+ const [internalOpen, setInternalOpen] = React.useState(false)
+ const [projects, setProjects] = React.useState<ProjectForFilter[]>([])
+ const [isClonePending, startCloneTransition] = React.useTransition()
+ const { data: session } = useSession()
+ const router = useRouter()
+ const [defaultType, setDefaultType] = React.useState<"standard" | "project">("standard")
+
+ const isControlled = controlledOpen !== undefined
+ const open = isControlled ? controlledOpen! : internalOpen
+ const setOpen = isControlled ? controlledOnOpenChange! : setInternalOpen
+
+ const currentUserId = React.useMemo(() => {
+ return session?.user?.id ? Number(session.user.id) : null
+ }, [session])
+
+
+
+
+ const form = useForm<CloneGtcDocumentSchema>({
+ resolver: zodResolver(cloneGtcDocumentSchema),
+ defaultValues: {
+ sourceDocumentId: sourceDocument.id,
+ type: sourceDocument.type,
+ projectId: sourceDocument.projectId,
+ title: sourceDocument.title || "",
+ editReason: "",
+ },
+ })
+
+ const resetForm = React.useCallback((type: "standard" | "project") => {
+ form.reset({
+ sourceDocumentId: sourceDocument.id,
+ type,
+ projectId: sourceDocument.projectId,
+ title: sourceDocument.title || "",
+ editReason: "",
+ })
+ }, [form, sourceDocument])
+
+ React.useEffect(() => {
+ if (open) {
+ // 표준 GTC 존재 여부와 사용 가능한 프로젝트 동시 조회
+ Promise.all([
+ hasStandardGtcDocument(),
+ getAvailableProjectsForGtcExcluding(sourceDocument.projectId || undefined)
+ ]).then(([hasStandard, availableProjects]) => {
+ const initialType = hasStandard ? "project" : "standard"
+ setDefaultType(initialType)
+ setProjects(availableProjects)
+
+ // 폼 기본값 설정: 원본 문서 타입을 우선으로 하되, 표준이 이미 있고 원본도 표준이면 프로젝트로 변경
+ const targetType = hasStandard && sourceDocument.type === "standard" ? "project" : sourceDocument.type
+ resetForm(targetType)
+ })
+ }
+ }, [open, sourceDocument.projectId, sourceDocument.type, resetForm])
+
+
+ const watchedType = form.watch("type")
+
+ React.useEffect(() => {
+ // 소스 문서가 변경되면 폼 기본값 업데이트 (다이얼로그가 열려있을 때만)
+ if (open) {
+ hasStandardGtcDocument().then((hasStandard) => {
+ const targetType = hasStandard && sourceDocument.type === "standard" ? "project" : sourceDocument.type
+ resetForm(targetType)
+ })
+ }
+ }, [sourceDocument, resetForm, open])
+
+ async function onSubmit(data: CloneGtcDocumentSchema) {
+ startCloneTransition(async () => {
+ if (!currentUserId) {
+ toast.error("로그인이 필요합니다")
+ return
+ }
+
+ try {
+ const result = await cloneGtcDocument({
+ ...data,
+ createdById: currentUserId
+ })
+
+ if (result.error) {
+ toast.error(`에러: ${result.error}`)
+ return
+ }
+
+ resetForm(sourceDocument.type)
+ setOpen(false)
+ router.refresh()
+
+ toast.success("GTC 문서가 복제되었습니다.")
+ } catch (error) {
+ toast.error("문서 복제 중 오류가 발생했습니다.")
+ }
+ })
+ }
+
+ function handleDialogOpenChange(nextOpen: boolean) {
+ if (!nextOpen) {
+ // 다이얼로그 닫을 때는 원본 문서 정보로 리셋
+ resetForm(sourceDocument.type)
+ }
+ setOpen(nextOpen)
+ }
+
+ const DialogWrapper = isControlled ? React.Fragment : Dialog
+
+ return (
+ <DialogWrapper>
+ {!isControlled && (
+ <DialogTrigger asChild>
+ <Button variant="outline" size="sm">
+ <Copy className="mr-2 h-4 w-4" />
+ 복제하기
+ </Button>
+ </DialogTrigger>
+ )}
+
+ <Dialog open={open} onOpenChange={handleDialogOpenChange}>
+ <DialogContent className="max-w-md">
+ <DialogHeader>
+ <DialogTitle>GTC 문서 복제</DialogTitle>
+ <DialogDescription>
+ 기존 문서를 복제하여 새로운 문서를 생성합니다. <br />
+ <span className="font-medium text-foreground">
+ 원본: {sourceDocument.title || `${sourceDocument.type === 'standard' ? '표준' : '프로젝트'} GTC v${sourceDocument.revision}`}
+ </span>
+ </DialogDescription>
+ </DialogHeader>
+
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)}>
+ <div className="space-y-4 py-4">
+ {/* 구분 (Type) */}
+ <FormField
+ control={form.control}
+ name="type"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>구분</FormLabel>
+ <FormControl>
+ <Select
+ onValueChange={(value) => {
+ field.onChange(value)
+ // 표준으로 변경시 프로젝트 ID 초기화
+ if (value === "standard") {
+ form.setValue("projectId", null)
+ }
+ }}
+ value={field.value}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="구분을 선택하세요" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="standard">표준</SelectItem>
+ <SelectItem value="project">프로젝트</SelectItem>
+ </SelectContent>
+ </Select>
+ </FormControl>
+ {defaultType === "project" && sourceDocument.type === "standard" && (
+ <FormDescription>
+ 표준 GTC 문서가 이미 존재합니다. 복제시에는 프로젝트 타입을 권장합니다.
+ </FormDescription>
+ )}
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 프로젝트 선택 (프로젝트 타입인 경우만) */}
+ {watchedType === "project" && (
+ <FormField
+ control={form.control}
+ name="projectId"
+ render={({ field }) => {
+ const selectedProject = projects.find(
+ (p) => p.id === field.value
+ )
+ const [popoverOpen, setPopoverOpen] = React.useState(false)
+
+ return (
+ <FormItem>
+ <FormLabel>프로젝트</FormLabel>
+ <FormControl>
+ <Popover
+ open={popoverOpen}
+ onOpenChange={setPopoverOpen}
+ modal={true}
+ >
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ role="combobox"
+ aria-expanded={popoverOpen}
+ className="w-full justify-between"
+ >
+ {selectedProject
+ ? `${selectedProject.name} (${selectedProject.code})`
+ : "프로젝트를 선택하세요..."}
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </PopoverTrigger>
+
+ <PopoverContent className="w-full p-0">
+ <Command>
+ <CommandInput
+ placeholder="프로젝트 검색..."
+ className="h-9"
+ />
+ <CommandList>
+ <CommandEmpty>
+ {projects.length === 0
+ ? "사용 가능한 프로젝트가 없습니다."
+ : "프로젝트를 찾을 수 없습니다."
+ }
+ </CommandEmpty>
+ <CommandGroup>
+ {projects.map((project) => {
+ const label = `${project.name} (${project.code})`
+ return (
+ <CommandItem
+ key={project.id}
+ value={label}
+ onSelect={() => {
+ field.onChange(project.id)
+ setPopoverOpen(false)
+ }}
+ >
+ {label}
+ <Check
+ className={cn(
+ "ml-auto h-4 w-4",
+ selectedProject?.id === project.id
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ />
+ </CommandItem>
+ )
+ })}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )
+ }}
+ />
+ )}
+
+ <FormField
+ control={form.control}
+ name="title"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>GTC 제목 (선택사항)</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="GTC 제목을 입력하세요..."
+ {...field}
+ />
+ </FormControl>
+ <FormDescription>
+ 워드의 제목으로 사용됩니다.
+ </FormDescription>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 편집 사유 */}
+ <FormField
+ control={form.control}
+ name="editReason"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>복제 사유 (선택사항)</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="복제 사유를 입력하세요..."
+ {...field}
+ rows={3}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ <DialogFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => setOpen(false)}
+ disabled={isClonePending}
+ >
+ Cancel
+ </Button>
+ <Button type="submit" disabled={isClonePending}>
+ {isClonePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ 복제하기
+ </Button>
+ </DialogFooter>
+ </form>
+ </Form>
+ </DialogContent>
+ </Dialog>
+ </DialogWrapper>
+ )
+} \ No newline at end of file
diff --git a/lib/gtc-contract/status/create-gtc-document-dialog.tsx b/lib/gtc-contract/status/create-gtc-document-dialog.tsx
index 003e4d51..174bb8dd 100644
--- a/lib/gtc-contract/status/create-gtc-document-dialog.tsx
+++ b/lib/gtc-contract/status/create-gtc-document-dialog.tsx
@@ -41,28 +41,45 @@ import { cn } from "@/lib/utils"
import { toast } from "sonner"
import { createGtcDocumentSchema, type CreateGtcDocumentSchema } from "@/lib/gtc-contract/validations"
-import { createGtcDocument, getProjectsForSelect } from "@/lib/gtc-contract/service"
-import { type Project } from "@/db/schema/projects"
+import { createGtcDocument, getAvailableProjectsForGtc, hasStandardGtcDocument } from "@/lib/gtc-contract/service"
+import { type ProjectForFilter } from "@/lib/gtc-contract/service"
import { useSession } from "next-auth/react"
import { Input } from "@/components/ui/input"
-import { useRouter } from "next/navigation";
+import { useRouter } from "next/navigation"
export function CreateGtcDocumentDialog() {
const [open, setOpen] = React.useState(false)
- const [projects, setProjects] = React.useState<Project[]>([])
+ const [projects, setProjects] = React.useState<ProjectForFilter[]>([])
const [isCreatePending, startCreateTransition] = React.useTransition()
+ const [defaultType, setDefaultType] = React.useState<"standard" | "project">("standard")
const { data: session } = useSession()
- const router = useRouter();
+ const router = useRouter()
const currentUserId = React.useMemo(() => {
return session?.user?.id ? Number(session.user.id) : null;
- }, [session]);
-
+ }, [session])
React.useEffect(() => {
if (open) {
- getProjectsForSelect().then((res) => {
- setProjects(res)
+ // 표준 GTC 존재 여부와 사용 가능한 프로젝트 동시 조회
+ Promise.all([
+ hasStandardGtcDocument(),
+ getAvailableProjectsForGtc()
+ ]).then(([hasStandard, availableProjects]) => {
+ const initialType = hasStandard ? "project" : "standard"
+ setDefaultType(initialType)
+ setProjects(availableProjects)
+
+ // 폼 기본값 설정 (setTimeout으로 다음 틱에 실행)
+ setTimeout(() => {
+ form.reset({
+ type: initialType,
+ projectId: null,
+ title: "",
+ revision: 0,
+ editReason: "",
+ })
+ }, 0)
})
}
}, [open])
@@ -78,11 +95,21 @@ export function CreateGtcDocumentDialog() {
},
})
+ const resetForm = React.useCallback((type: "standard" | "project") => {
+ form.reset({
+ type,
+ projectId: null,
+ title: "",
+ revision: 0,
+ editReason: "",
+ })
+ }, [form])
+
+
const watchedType = form.watch("type")
async function onSubmit(data: CreateGtcDocumentSchema) {
startCreateTransition(async () => {
-
if (!currentUserId) {
toast.error("로그인이 필요합니다")
return
@@ -99,9 +126,9 @@ export function CreateGtcDocumentDialog() {
return
}
- form.reset()
+ resetForm(defaultType)
setOpen(false)
- router.refresh();
+ router.refresh()
toast.success("GTC 문서가 생성되었습니다.")
} catch (error) {
@@ -112,7 +139,7 @@ export function CreateGtcDocumentDialog() {
function handleDialogOpenChange(nextOpen: boolean) {
if (!nextOpen) {
- form.reset()
+ resetForm(defaultType)
}
setOpen(nextOpen)
}
@@ -122,15 +149,15 @@ export function CreateGtcDocumentDialog() {
<DialogTrigger asChild>
<Button variant="default" size="sm">
<Plus className="mr-2 h-4 w-4" />
- Add GTC Document
+ GTC 추가
</Button>
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>
- <DialogTitle>Create New GTC Document</DialogTitle>
+ <DialogTitle>새 GTC 만들기</DialogTitle>
<DialogDescription>
- 새 GTC 문서 정보를 입력하고 <b>Create</b> 버튼을 누르세요.
+ 새 GTC 문서 정보를 입력하고 <b>생성</b> 버튼을 누르세요.
</DialogDescription>
</DialogHeader>
@@ -159,11 +186,26 @@ export function CreateGtcDocumentDialog() {
<SelectValue placeholder="구분을 선택하세요" />
</SelectTrigger>
<SelectContent>
- <SelectItem value="standard">표준</SelectItem>
- <SelectItem value="project">프로젝트</SelectItem>
+ {/* 기본값에 따라 순서 조정 */}
+ {defaultType === "project" ? (
+ <>
+ <SelectItem value="project">프로젝트</SelectItem>
+ <SelectItem value="standard">표준</SelectItem>
+ </>
+ ) : (
+ <>
+ <SelectItem value="standard">표준</SelectItem>
+ <SelectItem value="project">프로젝트</SelectItem>
+ </>
+ )}
</SelectContent>
</Select>
</FormControl>
+ {defaultType === "project" && (
+ <FormDescription>
+ 표준 GTC 문서가 이미 존재합니다. 새 문서는 프로젝트 타입을 권장합니다.
+ </FormDescription>
+ )}
<FormMessage />
</FormItem>
)}
@@ -198,7 +240,9 @@ export function CreateGtcDocumentDialog() {
>
{selectedProject
? `${selectedProject.name} (${selectedProject.code})`
- : "프로젝트를 선택하세요..."}
+ : projects.length === 0
+ ? "사용 가능한 프로젝트가 없습니다"
+ : "프로젝트를 선택하세요..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
@@ -210,7 +254,12 @@ export function CreateGtcDocumentDialog() {
className="h-9"
/>
<CommandList>
- <CommandEmpty>프로젝트를 찾을 수 없습니다.</CommandEmpty>
+ <CommandEmpty>
+ {projects.length === 0
+ ? "모든 프로젝트에 이미 GTC 문서가 있습니다."
+ : "프로젝트를 찾을 수 없습니다."
+ }
+ </CommandEmpty>
<CommandGroup>
{projects.map((project) => {
const label = `${project.name} (${project.code})`
@@ -241,6 +290,9 @@ export function CreateGtcDocumentDialog() {
</PopoverContent>
</Popover>
</FormControl>
+ <FormDescription>
+ {projects.length === 0 && "이미 GTC 문서가 있는 프로젝트는 표시되지 않습니다."}
+ </FormDescription>
<FormMessage />
</FormItem>
)
@@ -256,7 +308,7 @@ export function CreateGtcDocumentDialog() {
<FormLabel>GTC 제목 (선택사항)</FormLabel>
<FormControl>
<Input
- placeholder="GTC 제목를 입력하세요..."
+ placeholder="GTC 제목을 입력하세요..."
{...field}
/>
</FormControl>
@@ -295,7 +347,7 @@ export function CreateGtcDocumentDialog() {
onClick={() => setOpen(false)}
disabled={isCreatePending}
>
- Cancel
+ 취소
</Button>
<Button type="submit" disabled={isCreatePending}>
{isCreatePending && (
@@ -304,7 +356,7 @@ export function CreateGtcDocumentDialog() {
aria-hidden="true"
/>
)}
- Create
+ 생성
</Button>
</DialogFooter>
</form>
diff --git a/lib/gtc-contract/status/gtc-contract-table.tsx b/lib/gtc-contract/status/gtc-contract-table.tsx
index 0fb637b6..ce3a2c7a 100644
--- a/lib/gtc-contract/status/gtc-contract-table.tsx
+++ b/lib/gtc-contract/status/gtc-contract-table.tsx
@@ -27,6 +27,7 @@ import { UpdateGtcDocumentSheet } from "./update-gtc-document-sheet"
import { CreateGtcDocumentDialog } from "./create-gtc-document-dialog"
import { CreateNewRevisionDialog } from "./create-new-revision-dialog"
import { useRouter } from "next/navigation"
+import { CloneGtcDocumentDialog } from "./clone-gtc-document-dialog"
interface GtcDocumentsTableProps {
promises: Promise<
@@ -42,6 +43,8 @@ export function GtcDocumentsTable({ promises }: GtcDocumentsTableProps) {
const [{ data, pageCount }, projects, users] = React.use(promises)
const router = useRouter()
+ console.log(data)
+
const [rowAction, setRowAction] =
React.useState<DataTableRowAction<GtcDocumentWithRelations> | null>(null)
@@ -169,6 +172,16 @@ export function GtcDocumentsTable({ promises }: GtcDocumentsTableProps) {
originalDocument={rowAction?.row.original ?? null}
/>
+
+ {/* 복제 다이얼로그 */}
+ {rowAction?.type === "clone" && (
+ <CloneGtcDocumentDialog
+ sourceDocument={rowAction.row.original}
+ open={true}
+ onOpenChange={() => setRowAction(null)}
+ />
+ )}
+
{/* <CreateGtcDocumentDialog /> */}
</>
)
diff --git a/lib/gtc-contract/status/gtc-documents-table-columns.tsx b/lib/gtc-contract/status/gtc-documents-table-columns.tsx
index cd02a3e5..89415284 100644
--- a/lib/gtc-contract/status/gtc-documents-table-columns.tsx
+++ b/lib/gtc-contract/status/gtc-documents-table-columns.tsx
@@ -24,7 +24,7 @@ import { type GtcDocumentWithRelations } from "@/db/schema/gtc"
interface GetColumnsProps {
setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<GtcDocumentWithRelations> | null>>
- router: AppRouterInstance // ← 추가
+ router: AppRouterInstance
}
/** GTC Documents 테이블 컬럼 정의 (그룹 헤더 제거) */
@@ -75,12 +75,13 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef
accessorKey: "project",
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="프로젝트" />,
cell: ({ row }) => {
- const project = row.original.project
- if (!project) return <span className="text-muted-foreground">-</span>
+ const projectName = row.original.projectName
+ const projectCode = row.original.projectCode
+ if (!projectName) return <span className="text-muted-foreground">-</span>
return (
<div className="flex flex-col min-w-0">
- <span className="font-medium truncate">{project.name}</span>
- <span className="text-xs text-muted-foreground">{project.code}</span>
+ <span className="font-medium truncate">{projectName}</span>
+ <span className="text-xs text-muted-foreground">{projectCode}</span>
</div>
)
},
@@ -195,6 +196,10 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef
setRowAction({ row, type: "createRevision" })
}
+ const handleClone = () => {
+ setRowAction({ row, type: "clone" })
+ }
+
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -206,26 +211,30 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef
<Ellipsis className="size-4" aria-hidden />
</Button>
</DropdownMenuTrigger>
- <DropdownMenuContent align="end" className="w-48">
+ <DropdownMenuContent align="end" className="w-44">
<DropdownMenuItem onSelect={handleViewDetails}>
- <Eye className="mr-2 h-4 w-4" />
- 상세
+ {/* <Eye className="mr-2 h-4 w-4" /> */}
+ 상세보기
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => setRowAction({ row, type: "update" })}>
- 수정
+ 수정하기
</DropdownMenuItem>
<DropdownMenuItem onSelect={handleCreateNewRevision}>
- 새 리비전 생성
+ 리비전 생성하기
+ </DropdownMenuItem>
+
+ <DropdownMenuItem onSelect={handleClone}>
+ 복제하기
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => setRowAction({ row, type: "delete" })}>
- 삭제
- <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut>
+ 삭제하기
+ {/* <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut> */}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -241,4 +250,4 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef
...auditColumns,
actionsColumn,
]
-}
+} \ No newline at end of file
diff --git a/lib/gtc-contract/validations.ts b/lib/gtc-contract/validations.ts
index d00d795b..0566c1cb 100644
--- a/lib/gtc-contract/validations.ts
+++ b/lib/gtc-contract/validations.ts
@@ -70,4 +70,35 @@ export const createNewRevisionSchema = z.object({
export type GetGtcDocumentsSchema = Awaited<ReturnType<typeof searchParamsCache.parse>>
export type CreateGtcDocumentSchema = z.infer<typeof createGtcDocumentSchema>
export type UpdateGtcDocumentSchema = z.infer<typeof updateGtcDocumentSchema>
-export type CreateNewRevisionSchema = z.infer<typeof createNewRevisionSchema> \ No newline at end of file
+export type CreateNewRevisionSchema = z.infer<typeof createNewRevisionSchema>
+
+
+
+// 복제용 schema
+export const cloneGtcDocumentSchema = z.object({
+ sourceDocumentId: z.number().min(1, "원본 문서 ID가 필요합니다."),
+ type: z.enum(["standard", "project"]),
+ projectId: z.number().nullable().optional(),
+ title: z.string().optional(),
+ editReason: z.string().optional(),
+}).refine((data) => {
+ // 프로젝트 타입인 경우 projectId가 필수
+ if (data.type === "project") {
+ return data.projectId !== null && data.projectId !== undefined
+ }
+ return true
+}, {
+ message: "프로젝트 타입인 경우 프로젝트를 선택해야 합니다.",
+ path: ["projectId"]
+}).refine((data) => {
+ // 표준 타입인 경우 projectId는 null이어야 함
+ if (data.type === "standard") {
+ return data.projectId === null || data.projectId === undefined
+ }
+ return true
+}, {
+ message: "표준 타입인 경우 프로젝트를 선택할 수 없습니다.",
+ path: ["projectId"]
+})
+
+export type CloneGtcDocumentSchema = z.infer<typeof cloneGtcDocumentSchema> \ No newline at end of file