summaryrefslogtreecommitdiff
path: root/lib/gtc-contract
diff options
context:
space:
mode:
Diffstat (limited to 'lib/gtc-contract')
-rw-r--r--lib/gtc-contract/gtc-clauses/gtc-clauses-page-header.tsx102
-rw-r--r--lib/gtc-contract/gtc-clauses/service.ts936
-rw-r--r--lib/gtc-contract/gtc-clauses/table/bulk-update-gtc-clauses-dialog.tsx276
-rw-r--r--lib/gtc-contract/gtc-clauses/table/clause-preview-viewer.tsx435
-rw-r--r--lib/gtc-contract/gtc-clauses/table/clause-table.tsx234
-rw-r--r--lib/gtc-contract/gtc-clauses/table/clause-variable-settings-dialog.tsx364
-rw-r--r--lib/gtc-contract/gtc-clauses/table/create-gtc-clause-dialog.tsx442
-rw-r--r--lib/gtc-contract/gtc-clauses/table/delete-gtc-clauses-dialog.tsx175
-rw-r--r--lib/gtc-contract/gtc-clauses/table/duplicate-gtc-clause-dialog.tsx372
-rw-r--r--lib/gtc-contract/gtc-clauses/table/generate-variable-names-dialog.tsx348
-rw-r--r--lib/gtc-contract/gtc-clauses/table/gtc-clauses-table-columns.tsx283
-rw-r--r--lib/gtc-contract/gtc-clauses/table/gtc-clauses-table-floating-bar.tsx239
-rw-r--r--lib/gtc-contract/gtc-clauses/table/gtc-clauses-table-toolbar-actions.tsx195
-rw-r--r--lib/gtc-contract/gtc-clauses/table/markdown-image-editor.tsx360
-rw-r--r--lib/gtc-contract/gtc-clauses/table/preview-document-dialog.tsx189
-rw-r--r--lib/gtc-contract/gtc-clauses/table/reorder-gtc-clauses-dialog.tsx540
-rw-r--r--lib/gtc-contract/gtc-clauses/table/update-gtc-clause-sheet.tsx361
-rw-r--r--lib/gtc-contract/gtc-clauses/table/view-clause-variables-dialog.tsx231
-rw-r--r--lib/gtc-contract/gtc-clauses/validations.ts124
-rw-r--r--lib/gtc-contract/service.ts65
-rw-r--r--lib/gtc-contract/status/create-gtc-document-dialog.tsx31
-rw-r--r--lib/gtc-contract/status/gtc-documents-table-columns.tsx175
-rw-r--r--lib/gtc-contract/status/update-gtc-document-sheet.tsx34
-rw-r--r--lib/gtc-contract/validations.ts2
24 files changed, 6370 insertions, 143 deletions
diff --git a/lib/gtc-contract/gtc-clauses/gtc-clauses-page-header.tsx b/lib/gtc-contract/gtc-clauses/gtc-clauses-page-header.tsx
new file mode 100644
index 00000000..52faea3c
--- /dev/null
+++ b/lib/gtc-contract/gtc-clauses/gtc-clauses-page-header.tsx
@@ -0,0 +1,102 @@
+"use client"
+
+import * as React from "react"
+import { useRouter } from "next/navigation"
+import { Button } from "@/components/ui/button"
+import { Badge } from "@/components/ui/badge"
+import { ArrowLeft, Eye, Download, Settings } from "lucide-react"
+import { InformationButton } from "@/components/information/information-button"
+
+interface GtcClausesPageHeaderProps {
+ document: any // GtcDocumentWithRelations 타입
+}
+
+export function GtcClausesPageHeader({ document }: GtcClausesPageHeaderProps) {
+ const router = useRouter()
+
+ const handleBack = () => {
+ router.push('/evcp/basic-contract-template/gtc')
+ }
+
+ const handlePreview = () => {
+ // PDFTron 미리보기 기능
+ console.log("PDF Preview for document:", document.id)
+ }
+
+ const handleDownload = () => {
+ // 문서 다운로드 기능
+ console.log("Download document:", document.id)
+ }
+
+ const handleSettings = () => {
+ // 문서 설정 (템플릿 관리 등)
+ console.log("Document settings:", document.id)
+ }
+
+ return (
+ <div className="flex items-center justify-between">
+ {/* 헤더 왼쪽 */}
+ <div className="flex items-center gap-4">
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={handleBack}
+ className="gap-2"
+ >
+ <ArrowLeft className="h-4 w-4" />
+ 목록으로
+ </Button>
+
+ <div className="border-l border-border pl-4">
+ <div className="flex items-center gap-2 mb-1">
+ <h1 className="text-2xl font-bold tracking-tight">
+ GTC 조항 관리
+ </h1>
+ <InformationButton pagePath="evcp/basic-contract-template/gtc/clauses" />
+ </div>
+
+ <div className="flex items-center gap-3 text-sm text-muted-foreground">
+ <Badge variant={document.type === "standard" ? "default" : "secondary"}>
+ {document.type === "standard" ? "표준" : "프로젝트"}
+ </Badge>
+
+ {document.project && (
+ <>
+ <span>•</span>
+ <span>{document.project.name} ({document.project.code})</span>
+ </>
+ )}
+
+ <span>•</span>
+ <span>v{document.revision}</span>
+
+ {document.fileName && (
+ <>
+ <span>•</span>
+ <span>{document.fileName}</span>
+ </>
+ )}
+ </div>
+ </div>
+ </div>
+
+ {/* 헤더 오른쪽 - 액션 버튼들 */}
+ <div className="flex items-center gap-2">
+ {/* <Button variant="outline" size="sm" onClick={handlePreview}>
+ <Eye className="mr-2 h-4 w-4" />
+ 미리보기
+ </Button>
+
+ <Button variant="outline" size="sm" onClick={handleDownload}>
+ <Download className="mr-2 h-4 w-4" />
+ 다운로드
+ </Button>
+
+ <Button variant="outline" size="sm" onClick={handleSettings}>
+ <Settings className="mr-2 h-4 w-4" />
+ 설정
+ </Button> */}
+ </div>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/gtc-contract/gtc-clauses/service.ts b/lib/gtc-contract/gtc-clauses/service.ts
new file mode 100644
index 00000000..b6f620bc
--- /dev/null
+++ b/lib/gtc-contract/gtc-clauses/service.ts
@@ -0,0 +1,936 @@
+'use server'
+
+import db from "@/db/db"
+import {
+ gtcClauses,
+ gtcClausesTreeView,
+ type GtcClause,
+ type GtcClauseTreeView,
+ type NewGtcClause
+} from "@/db/schema/gtc"
+import { users } from "@/db/schema/users"
+import { and, asc, count, desc, eq, ilike, or, sql, gt, lt, inArray, like } from "drizzle-orm"
+import { unstable_cache } from "next/cache"
+import { filterColumns } from "@/lib/filter-columns"
+import type {
+ GetGtcClausesSchema,
+ CreateGtcClauseSchema,
+ UpdateGtcClauseSchema,
+ ReorderGtcClausesSchema,
+ BulkUpdateGtcClausesSchema,
+ GenerateVariableNamesSchema,
+} from "@/lib/gtc-contract/gtc-clauses/validations"
+import { decryptWithServerAction } from "@/components/drm/drmUtils"
+import { saveDRMFile } from "@/lib/file-stroage"
+
+interface ClauseImage {
+ id: string
+ url: string
+ fileName: string
+ size: number
+ savedName?: string
+ mimeType?: string
+ width?: number
+ height?: number
+ hash?: string
+}
+
+/**
+ * GTC 조항 목록 조회 (계층구조)
+ */
+export async function getGtcClauses(input: GetGtcClausesSchema & { documentId: number }) {
+ return unstable_cache(
+ async () => {
+ try {
+ const offset = (input.page - 1) * input.perPage
+
+ // 문서 ID 필터 (필수)
+ const documentWhere = eq(gtcClausesTreeView.documentId, input.documentId)
+
+ // 고급 필터
+ const advancedWhere = filterColumns({
+ table: gtcClausesTreeView,
+ filters: input.filters,
+ joinOperator: input.joinOperator,
+ })
+
+ // 전역 검색
+ let globalWhere
+ if (input.search) {
+ const s = `%${input.search}%`
+ globalWhere = or(
+ ilike(gtcClausesTreeView.itemNumber, s),
+ ilike(gtcClausesTreeView.subtitle, s),
+ ilike(gtcClausesTreeView.content, s),
+ ilike(gtcClausesTreeView.category, s)
+ )
+ }
+
+ // 카테고리 필터
+ let categoryWhere
+ if (input.category) {
+ categoryWhere = ilike(gtcClausesTreeView.category, `%${input.category}%`)
+ }
+
+ // 뎁스 필터
+ let depthWhere
+ if (input.depth > 0) {
+ depthWhere = eq(gtcClausesTreeView.depth, input.depth)
+ }
+
+ // 부모 필터
+ let parentWhere
+ if (input.parentId > 0) {
+ parentWhere = eq(gtcClausesTreeView.parentId, input.parentId)
+ }
+
+ // 최종 where 조건
+ const finalWhere = and(
+ documentWhere,
+ advancedWhere,
+ globalWhere,
+ categoryWhere,
+ depthWhere,
+ parentWhere,
+ eq(gtcClausesTreeView.isActive, true)
+ )
+
+ // 정렬
+ const orderBy =
+ input.sort.length > 0
+ ? input.sort.map((item) => {
+ const column = gtcClausesTreeView[item.id as keyof typeof gtcClausesTreeView]
+ return item.desc ? desc(column) : asc(column)
+ })
+ : [asc(gtcClausesTreeView.sortOrder), asc(gtcClausesTreeView.depth)]
+
+ // 데이터 조회
+ const { data, total } = await db.transaction(async (tx) => {
+ const data = await tx
+ .select()
+ .from(gtcClausesTreeView)
+ .where(finalWhere)
+ .orderBy(...orderBy)
+ .limit(input.perPage)
+ .offset(offset)
+
+ const total = await countGtcClauses(tx, finalWhere)
+ return { data, total }
+ })
+
+ const pageCount = Math.ceil(total / input.perPage)
+ return { data, pageCount }
+ } catch (err) {
+ console.error("Error fetching GTC clauses:", err)
+ return { data: [], pageCount: 0 }
+ }
+ },
+ [JSON.stringify(input)],
+ {
+ revalidate: 3600,
+ tags: [`gtc-clauses-${input.documentId}`],
+ }
+ )()
+}
+
+/**
+ * 계층구조 트리 형태로 GTC 조항 조회
+ */
+export async function getGtcClausesTree(documentId: number) {
+ return unstable_cache(
+ async () => {
+ try {
+ const allClauses = await db
+ .select()
+ .from(gtcClausesTreeView)
+ .where(
+ and(
+ eq(gtcClausesTreeView.documentId, documentId),
+ eq(gtcClausesTreeView.isActive, true)
+ )
+ )
+ .orderBy(asc(gtcClausesTreeView.sortOrder), asc(gtcClausesTreeView.depth))
+
+ // 계층구조로 변환
+ return buildClausesTree(allClauses)
+ } catch (err) {
+ console.error("Error fetching GTC clauses tree:", err)
+ return []
+ }
+ },
+ [`gtc-clauses-tree-${documentId}`],
+ {
+ revalidate: 3600,
+ tags: [`gtc-clauses-${documentId}`],
+ }
+ )()
+}
+
+/**
+ * 단일 GTC 조항 조회
+ */
+export async function getGtcClauseById(id: number) {
+ return unstable_cache(
+ async () => {
+ try {
+ const [clause] = await db
+ .select()
+ .from(gtcClausesTreeView)
+ .where(eq(gtcClausesTreeView.id, id))
+ .limit(1)
+
+ return clause || null
+ } catch (err) {
+ console.error("Error fetching GTC clause:", err)
+ return null
+ }
+ },
+ [`gtc-clause-${id}`],
+ {
+ revalidate: 3600,
+ tags: [`gtc-clause-${id}`],
+ }
+ )()
+}
+
+// helper: 이미지 저장
+async function saveClauseImagesFromFormData(
+ files: File[],
+ documentId: number,
+ userId: number
+) {
+ const saved: ClauseImage[] = []
+
+ for (const file of files) {
+ // DRM 복호화 + 저장
+ const result = await saveDRMFile(file, decryptWithServerAction, `gtc/${documentId}`, String(userId))
+ if (!result.success || !result.publicPath) {
+ throw new Error(result.error || "이미지 저장 실패")
+ }
+
+ // (선택) 해상도 파악
+ let width: number | undefined
+ let height: number | undefined
+ try {
+ // 이미지일 때만
+ if (file.type.startsWith("image/")) {
+ // 서버에서 해상도를 바로 읽으려면 sharp 등 라이브러리를 쓰세요.
+ // 여기서는 비용을 줄이기 위해 생략 (원하면 sharp로 추후 보강)
+ }
+ } catch { /* no-op */ }
+
+ saved.push({
+ id: uuid(),
+ url: result.publicPath, // <- 뷰어/Docx에서 그대로 fetch 가능한 경로
+ fileName: file.name,
+ savedName: result.fileName,
+ mimeType: file.type,
+ size: result.fileSize ?? file.size,
+ width,
+ height,
+ hash: undefined,
+ })
+ }
+
+ return saved
+}
+
+// ✅ 새 서버액션: 파일 포함 생성
+export async function createGtcClauseWithUploads(formData: FormData) {
+ try {
+ const documentId = Number(formData.get("documentId"))
+ const parentId = formData.get("parentId") ? Number(formData.get("parentId")) : null
+ const itemNumber = String(formData.get("itemNumber") || "")
+ const category = (formData.get("category") as string) || ""
+ const subtitle = String(formData.get("subtitle") || "")
+ const content = (formData.get("content") as string) || ""
+ const sortOrder = Number(formData.get("sortOrder") || 0)
+ const editReason = (formData.get("editReason") as string) || ""
+ const createdById = Number(formData.get("createdById"))
+
+ // 첨부 파일들
+ const files: File[] = []
+ for (const [key, value] of formData.entries()) {
+ if (key.startsWith("images[") && value instanceof File && value.size > 0) {
+ files.push(value)
+ }
+ }
+
+ // depth 계산
+ let depth = 0
+ if (parentId) {
+ const parent = await db.query.gtcClauses.findFirst({ where: eq(gtcClauses.id, parentId) })
+ if (parent) depth = (parent.depth ?? 0) + 1
+ }
+
+ console.log(files.length, "파일 확인")
+ // 파일 저장
+ const images = files.length > 0
+ ? await saveClauseImagesFromFormData(files, documentId, createdById)
+ : []
+
+ const newClause: NewGtcClause = {
+ documentId,
+ parentId,
+ itemNumber,
+ category,
+ subtitle,
+ content: content.trim() || null,
+ images: images.length ? images : null,
+ sortOrder: sortOrder.toString(),
+ depth,
+ createdById,
+ updatedById: createdById,
+ editReason,
+ }
+
+ const [result] = await db.insert(gtcClauses).values(newClause).returning()
+ await revalidateGtcClausesCaches(documentId)
+
+ return { data: result, error: null }
+ } catch (error) {
+ console.error("Error createGtcClauseWithUploads:", error)
+ return { data: null, error: "조항 생성 중 오류가 발생했습니다." }
+ }
+}
+
+
+
+
+function buildClauseDir(documentId: number) {
+ const now = new Date()
+ const y = now.getFullYear()
+ const m = String(now.getMonth() + 1).padStart(2, "0")
+ return `gtc/${documentId}/${y}/${m}`
+}
+
+async function saveUploads(files: File[], documentId: number, userId: number) {
+ const dir = buildClauseDir(documentId)
+ const saved: ClauseImage[] = []
+ for (const file of files) {
+ if (!file || file.size === 0) continue
+ const r = await saveDRMFile(file, decryptWithServerAction, dir, String(userId))
+ if (!r.success || !r.publicPath) {
+ throw new Error(r.error || "파일 저장 실패")
+ }
+ saved.push({
+ id: uuid(),
+ url: r.publicPath,
+ fileName: file.name,
+ savedName: r.fileName,
+ mimeType: file.type,
+ size: r.fileSize ?? file.size,
+ })
+ }
+ return saved
+}
+
+
+export async function updateGtcClauseWithUploads(formData: FormData) {
+ try {
+ const id = Number(formData.get("id"))
+ const documentId = Number(formData.get("documentId"))
+ const itemNumber = (formData.get("itemNumber") as string) || ""
+ const category = (formData.get("category") as string) || ""
+ const subtitle = (formData.get("subtitle") as string) || ""
+ const content = (formData.get("content") as string) || ""
+ const sortOrderStr = (formData.get("sortOrder") as string) || ""
+ const isActive = String(formData.get("isActive")).toLowerCase() !== "false"
+ const editReason = (formData.get("editReason") as string) || ""
+ const updatedById = Number(formData.get("updatedById"))
+
+ // 기존 조항 조회
+ const existing = await db.query.gtcClauses.findFirst({ where: eq(gtcClauses.id, id) })
+ if (!existing) return { data: null, error: "조항을 찾을 수 없습니다." }
+
+ // 삭제될 이미지 목록
+ let removedImageIds: string[] = []
+ try {
+ const raw = (formData.get("removedImageIds") as string) || "[]"
+ removedImageIds = JSON.parse(raw)
+ } catch {
+ removedImageIds = []
+ }
+
+ // 신규 파일 수집
+ const uploadFiles: File[] = []
+ for (const [key, value] of formData.entries()) {
+ if (key.startsWith("images[") && value instanceof File && value.size > 0) {
+ uploadFiles.push(value)
+ }
+ }
+
+ // 기존 이미지
+ const existingImages: ClauseImage[] = Array.isArray(existing.images) ? (existing.images as any) : (existing.images ? JSON.parse(JSON.stringify(existing.images)) : [])
+
+ // 삭제 반영
+ const kept = existingImages.filter(img => !removedImageIds.includes(img.id))
+
+ // 신규 저장
+ const newImages = uploadFiles.length ? await saveUploads(uploadFiles, documentId, updatedById) : []
+
+ const nextImages = [...kept, ...newImages]
+ const updateData: Partial<GtcClause> = {
+ itemNumber,
+ category,
+ subtitle,
+ content: content.trim() ? content.trim() : null,
+ sortOrder: sortOrderStr ? String(sortOrderStr) : existing.sortOrder,
+ isActive,
+ images: nextImages.length ? nextImages : null,
+ updatedById,
+ updatedAt: new Date(),
+ editReason,
+ }
+
+ // itemNumber 변경 시 fullPath 갱신 (간단 규칙)
+ if (itemNumber && itemNumber !== existing.itemNumber) {
+ updateData.fullPath = itemNumber
+ }
+
+ const [result] = await db.update(gtcClauses).set(updateData).where(eq(gtcClauses.id, id)).returning()
+
+ await revalidateGtcClausesCaches(existing.documentId)
+
+ return { data: result, error: null }
+ } catch (error) {
+ console.error("Error updateGtcClauseWithUploads:", error)
+ return { data: null, error: "조항 수정 중 오류가 발생했습니다." }
+ }
+}
+
+/**
+ * GTC 조항 생성
+ */
+export async function createGtcClause(
+ input: CreateGtcClauseSchema & { createdById: number }
+) {
+ try {
+ let depth = 0
+
+ if (input.parentId) {
+ const parent = await db.query.gtcClauses.findFirst({
+ where: eq(gtcClauses.id, input.parentId),
+ })
+ if (parent) {
+ depth = parent.depth + 1
+ }
+ }
+
+ const newClause: NewGtcClause = {
+ documentId: input.documentId,
+ parentId: input.parentId,
+ itemNumber: input.itemNumber,
+ category: input.category,
+ subtitle: input.subtitle,
+ content: input.content?.trim() || null,
+ images: input.images || null, // ✅ 이미지 데이터 저장
+ sortOrder: input.sortOrder.toString(),
+ depth,
+ createdById: input.createdById,
+ updatedById: input.createdById,
+ editReason: input.editReason,
+ }
+
+ const [result] = await db.insert(gtcClauses).values(newClause).returning()
+ await revalidateGtcClausesCaches(input.documentId)
+
+ return { data: result, error: null }
+ } catch (error) {
+ console.error("Error creating GTC clause:", error)
+ return { data: null, error: "조항 생성 중 오류가 발생했습니다." }
+ }
+}
+
+/**
+ * GTC 조항 수정
+ */
+export async function updateGtcClause(
+ id: number,
+ input: UpdateGtcClauseSchema & { updatedById: number }
+) {
+ try {
+ const updateData: Partial<GtcClause> = {
+ updatedById: input.updatedById,
+ updatedAt: new Date(),
+ images: input.images || null, // ✅ 이미지 데이터 저장
+ editReason: input.editReason,
+ }
+
+ // 선택적 업데이트 필드들
+ if (input.itemNumber !== undefined) updateData.itemNumber = input.itemNumber
+ if (input.category !== undefined) updateData.category = input.category
+ if (input.subtitle !== undefined) updateData.subtitle = input.subtitle
+ if (input.content !== undefined) {
+ // 빈 문자열은 null로 저장
+ updateData.content = input.content && input.content.trim() ? input.content.trim() : null
+ }
+ if (input.sortOrder !== undefined) updateData.sortOrder = input.sortOrder.toString()
+ if (input.isActive !== undefined) updateData.isActive = input.isActive
+
+ // fullPath 재계산 (itemNumber가 변경된 경우)
+ if (input.itemNumber) {
+
+ updateData.fullPath = input.itemNumber
+
+ }
+
+ const [result] = await db
+ .update(gtcClauses)
+ .set(updateData)
+ .where(eq(gtcClauses.id, id))
+ .returning()
+
+ // 캐시 무효화
+ const clause = await db.query.gtcClauses.findFirst({
+ where: eq(gtcClauses.id, id),
+ })
+ if (clause) {
+ await revalidateGtcClausesCaches(clause.documentId)
+ }
+
+ return { data: result, error: null }
+ } catch (error) {
+ console.error("Error updating GTC clause:", error)
+ return { data: null, error: "조항 수정 중 오류가 발생했습니다." }
+ }
+}
+
+/**
+ * GTC 조항 삭제 (소프트 삭제)
+ */
+/**
+ * GTC 조항 복수 삭제 (실제 삭제)
+ */
+export async function deleteGtcClauses(ids: number[], deletedById: number) {
+ try {
+ // 삭제 실행하고 documentId 반환받기
+ const deletedClauses = await db
+ .delete(gtcClauses)
+ .where(inArray(gtcClauses.id, ids))
+ .returning()
+
+ if (deletedClauses.length === 0) {
+ return {
+ data: null,
+ error: "삭제할 조항을 찾을 수 없습니다.",
+ deletedCount: 0
+ }
+ }
+
+ // documentId는 하나일 것이므로 첫 번째 것 사용
+ const documentId = deletedClauses[0].documentId
+
+ // 캐시 무효화
+ await revalidateGtcClausesCaches(documentId)
+
+ return {
+ data: deletedClauses,
+ error: null,
+ deletedCount: deletedClauses.length
+ }
+ } catch (error) {
+ console.error("Error deleting GTC clauses:", error)
+ return {
+ data: null,
+ error: error instanceof Error ? error.message : "조항 삭제 중 오류가 발생했습니다.",
+ deletedCount: 0
+ }
+ }
+}
+
+
+/**
+ * GTC 조항 단일 삭제 (실제 삭제)
+ */
+export async function deleteGtcClause(id: number, deletedById: number) {
+ const result = await deleteGtcClauses([id], deletedById)
+ return {
+ data: result.data?.[0] || null,
+ error: result.error
+ }
+}
+
+/**
+ * 조항과 모든 하위 조항을 안전하게 삭제
+ * (삭제 전 하위 조항 수 확인 포함)
+ */
+export async function deleteGtcClauseWithChildren(id: number, deletedById: number) {
+ try {
+ // 1. 먼저 해당 조항과 하위 조항 정보 조회
+ const clause = await db.query.gtcClauses.findFirst({
+ where: eq(gtcClauses.id, id),
+ columns: { id: true, itemNumber: true, subtitle: true, documentId: true }
+ })
+
+ if (!clause) {
+ return { data: null, error: "조항을 찾을 수 없습니다." }
+ }
+
+ // 2. 하위 조항들 조회
+ const children = await db.query.gtcClauses.findMany({
+ where: like(gtcClauses.fullPath, `${clause.itemNumber}.%`),
+ columns: { id: true, itemNumber: true, subtitle: true }
+ })
+
+ // 3. 모든 조항 삭제 (본인 + 하위 조항들)
+ const allIds = [clause.id, ...children.map(c => c.id)]
+ const result = await deleteGtcClauses(allIds, deletedById)
+
+ return {
+ ...result,
+ deletedClause: clause,
+ deletedChildren: children,
+ totalDeleted: allIds.length
+ }
+ } catch (error) {
+ console.error("Error deleting GTC clause with children:", error)
+ return { data: null, error: "조항 삭제 중 오류가 발생했습니다." }
+ }
+}
+
+/**
+ * GTC 조항 순서 변경
+ */
+export async function reorderGtcClauses(input: ReorderGtcClausesSchema & { updatedById: number }) {
+ try {
+ await db.transaction(async (tx) => {
+ for (const clause of input.clauses) {
+ await tx
+ .update(gtcClauses)
+ .set({
+ sortOrder: clause.sortOrder.toString(),
+ parentId: clause.parentId,
+ depth: clause.depth,
+ fullPath: clause.fullPath,
+ updatedById: input.updatedById,
+ updatedAt: new Date(),
+ editReason: input.editReason || "조항 순서 변경",
+ })
+ .where(eq(gtcClauses.id, clause.id))
+ }
+ })
+
+ // 캐시 무효화 (첫 번째 조항의 documentId 사용)
+ if (input.clauses.length > 0) {
+ const firstClause = await db.query.gtcClauses.findFirst({
+ where: eq(gtcClauses.id, input.clauses[0].id),
+ })
+ if (firstClause) {
+ await revalidateGtcClausesCaches(firstClause.documentId)
+ }
+ }
+
+ return { error: null }
+ } catch (error) {
+ console.error("Error reordering GTC clauses:", error)
+ return { error: "조항 순서 변경 중 오류가 발생했습니다." }
+ }
+}
+
+/**
+ * 벌크 업데이트
+ */
+export async function bulkUpdateGtcClauses(input: BulkUpdateGtcClausesSchema & { updatedById: number }) {
+ try {
+ const updateData: Partial<GtcClause> = {
+ updatedById: input.updatedById,
+ updatedAt: new Date(),
+ editReason: input.editReason,
+ }
+
+ if (input.updates.category !== undefined) updateData.category = input.updates.category
+ if (input.updates.isActive !== undefined) updateData.isActive = input.updates.isActive
+
+ await db.transaction(async (tx) => {
+ for (const clauseId of input.clauseIds) {
+ await tx
+ .update(gtcClauses)
+ .set(updateData)
+ .where(eq(gtcClauses.id, clauseId))
+ }
+ })
+
+ // 캐시 무효화
+ const firstClause = await db.query.gtcClauses.findFirst({
+ where: eq(gtcClauses.id, input.clauseIds[0]),
+ })
+ if (firstClause) {
+ await revalidateGtcClausesCaches(firstClause.documentId)
+ }
+
+ return { error: null }
+ } catch (error) {
+ console.error("Error bulk updating GTC clauses:", error)
+ return { error: "조항 일괄 수정 중 오류가 발생했습니다." }
+ }
+}
+
+/**
+ * PDFTron 변수명 자동 생성
+ */
+export async function generateVariableNames(input: GenerateVariableNamesSchema & { updatedById: number }) {
+ try {
+ const clauses = await db
+ .select()
+ .from(gtcClauses)
+ .where(
+ and(
+ eq(gtcClauses.documentId, input.documentId),
+ eq(gtcClauses.isActive, true)
+ )
+ )
+
+ await db.transaction(async (tx) => {
+ for (const clause of clauses) {
+ const basePrefix = input.includeVendorCode && input.vendorCode
+ ? `${input.vendorCode}_${input.prefix}`
+ : input.prefix
+
+ const pathPrefix = clause.fullPath?.replace(/\./g, "_") || clause.itemNumber.replace(/\./g, "_")
+ const varPrefix = `${basePrefix}_${pathPrefix}`
+
+ await tx
+ .update(gtcClauses)
+ .set({
+ numberVariableName: `${varPrefix}_NUMBER`,
+ subtitleVariableName: `${varPrefix}_SUBTITLE`,
+ contentVariableName: `${varPrefix}_CONTENT`,
+ updatedById: input.updatedById,
+ updatedAt: new Date(),
+ editReason: "PDFTron 변수명 자동 생성",
+ })
+ .where(eq(gtcClauses.id, clause.id))
+ }
+ })
+
+ await revalidateGtcClausesCaches(input.documentId)
+
+ return { error: null }
+ } catch (error) {
+ console.error("Error generating variable names:", error)
+ return { error: "변수명 생성 중 오류가 발생했습니다." }
+ }
+}
+
+/**
+ * 사용자 목록 조회 (필터용)
+ */
+export async function getUsersForFilter() {
+ return unstable_cache(
+ async () => {
+ try {
+ return await db
+ .select({
+ id: users.id,
+ name: users.name,
+ })
+ .from(users)
+ .where(eq(users.isActive, true))
+ .orderBy(asc(users.name))
+ } catch (err) {
+ console.error("Error fetching users for filter:", err)
+ return []
+ }
+ },
+ ["users-for-filter"],
+ {
+ revalidate: 3600,
+ tags: ["users"],
+ }
+ )()
+}
+
+// ===== 유틸리티 함수들 =====
+
+async function countGtcClauses(tx: any, where: any) {
+ const [{ count: total }] = await tx
+ .select({ count: count() })
+ .from(gtcClausesTreeView)
+ .where(where)
+ return total
+}
+
+function buildClausesTree(clauses: GtcClauseTreeView[]): GtcClauseTreeView[] {
+ const clauseMap = new Map<number, GtcClauseTreeView & { children: GtcClauseTreeView[] }>()
+ const rootClauses: (GtcClauseTreeView & { children: GtcClauseTreeView[] })[] = []
+
+ // 맵 생성
+ clauses.forEach(clause => {
+ clauseMap.set(clause.id, { ...clause, children: [] })
+ })
+
+ // 트리 구조 생성
+ clauses.forEach(clause => {
+ const clauseWithChildren = clauseMap.get(clause.id)!
+
+ if (clause.parentId) {
+ const parent = clauseMap.get(clause.parentId)
+ if (parent) {
+ parent.children.push(clauseWithChildren)
+ }
+ } else {
+ rootClauses.push(clauseWithChildren)
+ }
+ })
+
+ return rootClauses
+}
+
+async function revalidateGtcClausesCaches(documentId: number) {
+ const { revalidateTag } = await import("next/cache")
+ revalidateTag(`gtc-clauses-${documentId}`)
+ revalidateTag(`gtc-clauses-tree-${documentId}`)
+}
+
+/**
+ * 조항을 위로 이동
+ */
+export async function moveGtcClauseUp(clauseId: number, updatedById: number) {
+ try {
+ return await db.transaction(async (tx) => {
+ // 현재 조항 정보 조회
+ const [currentClause] = await tx
+ .select()
+ .from(gtcClauses)
+ .where(eq(gtcClauses.id, clauseId))
+ .limit(1)
+
+ if (!currentClause) {
+ return { error: "조항을 찾을 수 없습니다." }
+ }
+
+ // 같은 부모 아래에서 현재 조항보다 sortOrder가 작은 조항 중 가장 큰 것 찾기
+ const whereCondition = currentClause.parentId
+ ? and(
+ eq(gtcClauses.parentId, currentClause.parentId),
+ sql`${gtcClauses.sortOrder} < ${currentClause.sortOrder}`,
+ eq(gtcClauses.documentId, currentClause.documentId),
+ eq(gtcClauses.isActive, true)
+ )
+ : and(
+ sql`${gtcClauses.parentId} IS NULL`,
+ sql`${gtcClauses.sortOrder} < ${currentClause.sortOrder}`,
+ eq(gtcClauses.documentId, currentClause.documentId),
+ eq(gtcClauses.isActive, true)
+ )
+
+ const [prevClause] = await tx
+ .select()
+ .from(gtcClauses)
+ .where(whereCondition)
+ .orderBy(desc(gtcClauses.sortOrder))
+ .limit(1)
+
+ if (!prevClause) {
+ return { error: "이미 첫 번째 위치입니다." }
+ }
+
+ // sortOrder 교환
+ const tempOrder = currentClause.sortOrder
+
+ await tx
+ .update(gtcClauses)
+ .set({
+ sortOrder: prevClause.sortOrder,
+ updatedById,
+ updatedAt: new Date(),
+ editReason: "조항 순서 이동 (위로)"
+ })
+ .where(eq(gtcClauses.id, currentClause.id))
+
+ await tx
+ .update(gtcClauses)
+ .set({
+ sortOrder: tempOrder,
+ updatedById,
+ updatedAt: new Date(),
+ editReason: "조항 순서 이동 (아래로)"
+ })
+ .where(eq(gtcClauses.id, prevClause.id))
+
+ await revalidateGtcClausesCaches(currentClause.documentId)
+
+ return { error: null }
+ })
+ } catch (error) {
+ console.error("Error moving clause up:", error)
+ return { error: "조항 이동 중 오류가 발생했습니다." }
+ }
+}
+
+/**
+ * 조항을 아래로 이동
+ */
+export async function moveGtcClauseDown(clauseId: number, updatedById: number) {
+ try {
+ return await db.transaction(async (tx) => {
+ // 현재 조항 정보 조회
+ const [currentClause] = await tx
+ .select()
+ .from(gtcClauses)
+ .where(eq(gtcClauses.id, clauseId))
+ .limit(1)
+
+ if (!currentClause) {
+ return { error: "조항을 찾을 수 없습니다." }
+ }
+
+ // 같은 부모 아래에서 현재 조항보다 sortOrder가 큰 조항 중 가장 작은 것 찾기
+ const whereCondition = currentClause.parentId
+ ? and(
+ eq(gtcClauses.parentId, currentClause.parentId),
+ sql`${gtcClauses.sortOrder} > ${currentClause.sortOrder}`,
+ eq(gtcClauses.documentId, currentClause.documentId),
+ eq(gtcClauses.isActive, true)
+ )
+ : and(
+ sql`${gtcClauses.parentId} IS NULL`,
+ sql`${gtcClauses.sortOrder} > ${currentClause.sortOrder}`,
+ eq(gtcClauses.documentId, currentClause.documentId),
+ eq(gtcClauses.isActive, true)
+ )
+
+ const [nextClause] = await tx
+ .select()
+ .from(gtcClauses)
+ .where(whereCondition)
+ .orderBy(asc(gtcClauses.sortOrder))
+ .limit(1)
+
+ if (!nextClause) {
+ return { error: "이미 마지막 위치입니다." }
+ }
+
+ // sortOrder 교환
+ const tempOrder = currentClause.sortOrder
+
+ await tx
+ .update(gtcClauses)
+ .set({
+ sortOrder: nextClause.sortOrder,
+ updatedById,
+ updatedAt: new Date(),
+ editReason: "조항 순서 이동 (아래로)"
+ })
+ .where(eq(gtcClauses.id, currentClause.id))
+
+ await tx
+ .update(gtcClauses)
+ .set({
+ sortOrder: tempOrder,
+ updatedById,
+ updatedAt: new Date(),
+ editReason: "조항 순서 이동 (위로)"
+ })
+ .where(eq(gtcClauses.id, nextClause.id))
+
+ await revalidateGtcClausesCaches(currentClause.documentId)
+
+ return { error: null }
+ })
+ } catch (error) {
+ console.error("Error moving clause down:", error)
+ return { error: "조항 이동 중 오류가 발생했습니다." }
+ }
+} \ No newline at end of file
diff --git a/lib/gtc-contract/gtc-clauses/table/bulk-update-gtc-clauses-dialog.tsx b/lib/gtc-contract/gtc-clauses/table/bulk-update-gtc-clauses-dialog.tsx
new file mode 100644
index 00000000..a9ef0f0e
--- /dev/null
+++ b/lib/gtc-contract/gtc-clauses/table/bulk-update-gtc-clauses-dialog.tsx
@@ -0,0 +1,276 @@
+"use client"
+
+import * as React from "react"
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Textarea } from "@/components/ui/textarea"
+import { Badge } from "@/components/ui/badge"
+import { Switch } from "@/components/ui/switch"
+
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+ FormDescription,
+} from "@/components/ui/form"
+import { Loader, Edit, AlertCircle } from "lucide-react"
+import { toast } from "sonner"
+
+import { bulkUpdateGtcClausesSchema, type BulkUpdateGtcClausesSchema } from "@/lib/gtc-contract/gtc-clauses/validations"
+import { bulkUpdateGtcClauses } from "@/lib/gtc-contract/gtc-clauses/service"
+import { type GtcClauseTreeView } from "@/db/schema/gtc"
+import { useSession } from "next-auth/react"
+
+interface BulkUpdateGtcClausesDialogProps
+ extends React.ComponentPropsWithRef<typeof Dialog> {
+ selectedClauses: GtcClauseTreeView[]
+}
+
+export function BulkUpdateGtcClausesDialog({
+ selectedClauses,
+ ...props
+}: BulkUpdateGtcClausesDialogProps) {
+ const [isUpdatePending, startUpdateTransition] = React.useTransition()
+ const { data: session } = useSession()
+
+ const currentUserId = React.useMemo(() => {
+ return session?.user?.id ? Number(session.user.id) : null
+ }, [session])
+
+ const form = useForm<BulkUpdateGtcClausesSchema>({
+ resolver: zodResolver(bulkUpdateGtcClausesSchema),
+ defaultValues: {
+ clauseIds: selectedClauses.map(clause => clause.id),
+ updates: {
+ category: "",
+ isActive: true,
+ },
+ editReason: "",
+ },
+ })
+
+ React.useEffect(() => {
+ if (selectedClauses.length > 0) {
+ form.setValue("clauseIds", selectedClauses.map(clause => clause.id))
+ }
+ }, [selectedClauses, form])
+
+ async function onSubmit(data: BulkUpdateGtcClausesSchema) {
+ startUpdateTransition(async () => {
+ if (!currentUserId) {
+ toast.error("로그인이 필요합니다")
+ return
+ }
+
+ try {
+ const result = await bulkUpdateGtcClauses({
+ ...data,
+ updatedById: currentUserId
+ })
+
+ if (result.error) {
+ toast.error(`에러: ${result.error}`)
+ return
+ }
+
+ form.reset()
+ props.onOpenChange?.(false)
+ toast.success(`${selectedClauses.length}개의 조항이 수정되었습니다.`)
+ } catch (error) {
+ toast.error("조항 일괄 수정 중 오류가 발생했습니다.")
+ }
+ })
+ }
+
+ function handleDialogOpenChange(nextOpen: boolean) {
+ if (!nextOpen) {
+ form.reset()
+ }
+ props.onOpenChange?.(nextOpen)
+ }
+
+ // 선택된 조항들의 통계
+ const categoryCounts = React.useMemo(() => {
+ const counts: Record<string, number> = {}
+ selectedClauses.forEach(clause => {
+ const category = clause.category || "미분류"
+ counts[category] = (counts[category] || 0) + 1
+ })
+ return counts
+ }, [selectedClauses])
+
+ const activeCount = selectedClauses.filter(clause => clause.isActive).length
+ const inactiveCount = selectedClauses.length - activeCount
+
+ if (selectedClauses.length === 0) {
+ return null
+ }
+
+ return (
+ <Dialog {...props} onOpenChange={handleDialogOpenChange}>
+ <DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ <Edit className="h-5 w-5" />
+ 조항 일괄 수정
+ </DialogTitle>
+ <DialogDescription>
+ 선택한 {selectedClauses.length}개 조항의 공통 속성을 일괄 수정합니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ {/* 선택된 조항 요약 */}
+ <div className="space-y-4 p-4 bg-muted/50 rounded-lg">
+ <div className="flex items-center gap-2">
+ <AlertCircle className="h-4 w-4 text-muted-foreground" />
+ <span className="text-sm font-medium">선택된 조항 정보</span>
+ </div>
+
+ <div className="grid grid-cols-2 gap-4 text-sm">
+ <div>
+ <div className="font-medium text-muted-foreground mb-1">총 조항 수</div>
+ <div>{selectedClauses.length}개</div>
+ </div>
+
+ <div>
+ <div className="font-medium text-muted-foreground mb-1">상태</div>
+ <div className="flex gap-2">
+ <Badge variant="default">{activeCount}개 활성</Badge>
+ {inactiveCount > 0 && (
+ <Badge variant="secondary">{inactiveCount}개 비활성</Badge>
+ )}
+ </div>
+ </div>
+ </div>
+
+ {/* 분류별 통계 */}
+ <div>
+ <div className="font-medium text-muted-foreground mb-2">현재 분류 현황</div>
+ <div className="flex flex-wrap gap-1">
+ {Object.entries(categoryCounts).map(([category, count]) => (
+ <Badge key={category} variant="outline" className="text-xs">
+ {category}: {count}개
+ </Badge>
+ ))}
+ </div>
+ </div>
+
+ {/* 조항 미리보기 (최대 5개) */}
+ <div>
+ <div className="font-medium text-muted-foreground mb-2">포함된 조항 (일부)</div>
+ <div className="space-y-1 max-h-24 overflow-y-auto">
+ {selectedClauses.slice(0, 5).map(clause => (
+ <div key={clause.id} className="flex items-center gap-2 text-xs">
+ <Badge variant="outline">{clause.itemNumber}</Badge>
+ <span className="truncate">{clause.subtitle}</span>
+ </div>
+ ))}
+ {selectedClauses.length > 5 && (
+ <div className="text-xs text-muted-foreground">
+ ... 외 {selectedClauses.length - 5}개 조항
+ </div>
+ )}
+ </div>
+ </div>
+ </div>
+
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)}>
+ <div className="space-y-4">
+ {/* 분류 수정 */}
+ <FormField
+ control={form.control}
+ name="updates.category"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>분류 변경 (선택사항)</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="새로운 분류명을 입력하세요 (빈칸으로 두면 변경하지 않음)"
+ {...field}
+ />
+ </FormControl>
+ <FormDescription>
+ 모든 선택된 조항의 분류가 동일한 값으로 변경됩니다.
+ </FormDescription>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 활성 상태 변경 */}
+ <FormField
+ control={form.control}
+ name="updates.isActive"
+ render={({ field }) => (
+ <FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
+ <div className="space-y-0.5">
+ <FormLabel className="text-base">활성 상태</FormLabel>
+ <FormDescription>
+ 선택된 모든 조항의 활성 상태를 설정합니다.
+ </FormDescription>
+ </div>
+ <FormControl>
+ <Switch
+ checked={field.value}
+ onCheckedChange={field.onChange}
+ />
+ </FormControl>
+ </FormItem>
+ )}
+ />
+
+ {/* 편집 사유 */}
+ <FormField
+ control={form.control}
+ name="editReason"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>편집 사유 *</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="일괄 수정 사유를 입력하세요..."
+ {...field}
+ rows={3}
+ />
+ </FormControl>
+ <FormDescription>
+ 일괄 수정의 이유를 명확히 기록해주세요.
+ </FormDescription>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ <DialogFooter className="mt-6">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => props.onOpenChange?.(false)}
+ disabled={isUpdatePending}
+ >
+ Cancel
+ </Button>
+ <Button type="submit" disabled={isUpdatePending}>
+ {isUpdatePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ Update {selectedClauses.length} Clauses
+ </Button>
+ </DialogFooter>
+ </form>
+ </Form>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/gtc-contract/gtc-clauses/table/clause-preview-viewer.tsx b/lib/gtc-contract/gtc-clauses/table/clause-preview-viewer.tsx
new file mode 100644
index 00000000..30e369b4
--- /dev/null
+++ b/lib/gtc-contract/gtc-clauses/table/clause-preview-viewer.tsx
@@ -0,0 +1,435 @@
+"use client"
+
+import React, {
+ useState,
+ useEffect,
+ useRef,
+ SetStateAction,
+ Dispatch,
+} from "react"
+import { WebViewerInstance } from "@pdftron/webviewer"
+import { Loader2 } from "lucide-react"
+import { toast } from "sonner"
+
+import { type GtcClauseTreeView } from "@/db/schema/gtc"
+
+interface ClausePreviewViewerProps {
+ clauses: GtcClauseTreeView[]
+ document: any
+ instance: WebViewerInstance | null
+ setInstance: Dispatch<SetStateAction<WebViewerInstance | null>>
+}
+
+export function ClausePreviewViewer({
+ clauses,
+ document,
+ instance,
+ setInstance,
+}: ClausePreviewViewerProps) {
+ const [fileLoading, setFileLoading] = useState<boolean>(true)
+ const viewer = useRef<HTMLDivElement>(null)
+ const initialized = useRef(false)
+ const isCancelled = useRef(false)
+
+ // 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("뷰어 초기화에 실패했습니다.")
+ })
+ })
+ }
+ })
+ }
+
+ return () => {
+ if (instance) {
+ instance.UI.dispose()
+ }
+ isCancelled.current = true
+ }
+ }, [])
+
+ // 조항 데이터로 워드 문서 생성
+ const generateDocumentFromClauses = async (
+ instance: WebViewerInstance,
+ clauses: GtcClauseTreeView[],
+ document: any
+ ) => {
+ 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'
+ })
+
+ // ✅ PDFTron에서 DOCX 문서로 로드
+ await instance.UI.loadDocument(docxFile, {
+ filename: `${document?.title || 'GTC계약서'}_미리보기.docx`,
+ enableOfficeEditing: true, // DOCX 편집 모드 활성화
+ })
+
+ console.log("✅ DOCX 기반 문서 생성 완료")
+ toast.success("Word 문서 미리보기가 생성되었습니다.")
+
+ } catch (err) {
+ console.error("❌ DOCX 문서 생성 중 오류:", err)
+ toast.error(`문서 생성 실패: ${err instanceof Error ? err.message : '알 수 없는 오류'}`)
+ }
+ }
+
+ return (
+ <div className="relative w-full h-full overflow-hidden">
+ <div
+ ref={viewer}
+ className="w-full h-full"
+ style={{
+ position: 'relative',
+ overflow: 'hidden',
+ contain: 'layout style paint',
+ }}
+ >
+ {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-xs text-muted-foreground mt-1">
+ {clauses.filter(c => c.isActive !== false).length}개 조항 처리 중
+ </p>
+ </div>
+ )}
+ </div>
+ </div>
+ )
+}
+
+
+
+// ===== 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 };
+ }
+
+ // ===== 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 };
+ }
+
+// 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) {
+ const lines = text.split("\n");
+ return [
+ new Paragraph({
+ children: lines
+ .map((line, i) => [
+ new TextRun({ text: line }),
+ ...(i < lines.length - 1 ? [new TextRun({ break: 1 })] : []),
+ ])
+ .flat(),
+ indent: { left: indentLeft },
+ }),
+ ];
+ }
+
+ const IMG_TOKEN = /!\[([^\]]+)\]/g; // 예: ![image1753698566087]
+
+
+async function pushContentWithInlineImages(
+ content: string,
+ indentLeft: number,
+ children: any[],
+ imageMap: Map<string, any>
+ ) {
+ let lastIndex = 0;
+ for (const match of content.matchAll(IMG_TOKEN)) {
+ 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 },
+ })
+ );
+ } 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 추정
+ return clauses.map(clause => ({
+ ...clause,
+ estimatedDepth: clause.depth ?? estimateDepthFromItemNumber(clause.itemNumber)
+ })).sort((a, b) => {
+ // itemNumber 기준 자연 정렬
+ return a.itemNumber.localeCompare(b.itemNumber, undefined, {
+ numeric: true,
+ sensitivity: 'base'
+ })
+ })
+}
+
+// itemNumber로부터 depth 추정
+function estimateDepthFromItemNumber(itemNumber: string): number {
+ const parts = itemNumber.split('.')
+ return Math.max(0, parts.length - 1)
+}
diff --git a/lib/gtc-contract/gtc-clauses/table/clause-table.tsx b/lib/gtc-contract/gtc-clauses/table/clause-table.tsx
new file mode 100644
index 00000000..89674db9
--- /dev/null
+++ b/lib/gtc-contract/gtc-clauses/table/clause-table.tsx
@@ -0,0 +1,234 @@
+"use client"
+
+import * as React from "react"
+import { type GtcClauseTreeView } from "@/db/schema/gtc"
+import type {
+ DataTableAdvancedFilterField,
+ DataTableFilterField,
+ DataTableRowAction,
+} from "@/types/table"
+
+import { useDataTable } from "@/hooks/use-data-table"
+import { DataTable } from "@/components/data-table/data-table"
+import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
+
+import type {
+ getGtcClauses,
+ getUsersForFilter
+} from "@/lib/gtc-contract/gtc-clauses/service"
+import { getColumns } from "./gtc-clauses-table-columns"
+import { GtcClausesTableToolbarActions } from "./gtc-clauses-table-toolbar-actions"
+import { DeleteGtcClausesDialog } from "./delete-gtc-clauses-dialog"
+import { GtcClausesTableFloatingBar } from "./gtc-clauses-table-floating-bar"
+import { UpdateGtcClauseSheet } from "./update-gtc-clause-sheet"
+import { CreateGtcClauseDialog } from "./create-gtc-clause-dialog"
+import { ReorderGtcClausesDialog } from "./reorder-gtc-clauses-dialog"
+import { BulkUpdateGtcClausesDialog } from "./bulk-update-gtc-clauses-dialog"
+import { GenerateVariableNamesDialog } from "./generate-variable-names-dialog"
+import { DuplicateGtcClauseDialog } from "./duplicate-gtc-clause-dialog"
+import { ClauseVariableSettingsDialog } from "./clause-variable-settings-dialog"
+import { ViewClauseVariablesDialog } from "./view-clause-variables-dialog"
+
+interface GtcClausesTableProps {
+ promises: Promise<
+ [
+ Awaited<ReturnType<typeof getGtcClauses>>,
+ Awaited<ReturnType<typeof getUsersForFilter>>
+ ]
+ >
+ documentId: number
+ document: any
+}
+
+export function GtcClausesTable({ promises, documentId, document }: GtcClausesTableProps) {
+ const [{ data, pageCount }, users] = React.use(promises)
+
+ console.log(data)
+
+ const [rowAction, setRowAction] =
+ React.useState<DataTableRowAction<GtcClauseTreeView> | null>(null)
+
+ const columns = React.useMemo(
+ () => getColumns({ setRowAction, documentId }),
+ [setRowAction, documentId]
+ )
+
+ /**
+ * Filter fields for the data table.
+ */
+ const filterFields: DataTableFilterField<GtcClauseTreeView>[] = [
+ {
+ id: "itemNumber",
+ label: "채번",
+ placeholder: "채번으로 검색...",
+ },
+ {
+ id: "subtitle",
+ label: "소제목",
+ placeholder: "소제목으로 검색...",
+ },
+ {
+ id: "content",
+ label: "상세항목",
+ placeholder: "상세항목으로 검색...",
+ },
+ ]
+
+ /**
+ * Advanced filter fields for the data table.
+ */
+ const advancedFilterFields: DataTableAdvancedFilterField<GtcClauseTreeView>[] = [
+ {
+ id: "itemNumber",
+ label: "채번",
+ type: "text",
+ },
+ {
+ id: "category",
+ label: "분류",
+ type: "text",
+ },
+ {
+ id: "subtitle",
+ label: "소제목",
+ type: "text",
+ },
+ {
+ id: "content",
+ label: "상세항목",
+ type: "text",
+ },
+ {
+ id: "depth",
+ label: "계층 깊이",
+ type: "multi-select",
+ options: [
+ { label: "1단계", value: "0" },
+ { label: "2단계", value: "1" },
+ { label: "3단계", value: "2" },
+ { label: "4단계", value: "3" },
+ { label: "5단계", value: "4" },
+ ],
+ },
+ {
+ id: "createdByName",
+ label: "작성자",
+ type: "multi-select",
+ options: users.map((user) => ({
+ label: user.name,
+ value: user.name,
+ })),
+ },
+ {
+ id: "updatedByName",
+ label: "수정자",
+ type: "multi-select",
+ options: users.map((user) => ({
+ label: user.name,
+ value: user.name,
+ })),
+ },
+ {
+ id: "createdAt",
+ label: "작성일",
+ type: "date",
+ },
+ {
+ id: "updatedAt",
+ label: "수정일",
+ type: "date",
+ },
+ ]
+
+ const { table } = useDataTable({
+ data,
+ columns,
+ pageCount,
+ filterFields,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ initialState: {
+ sorting: [{"id":"itemNumber","desc":false}],
+ columnPinning: { right: ["actions"] },
+ },
+ getRowId: (originalRow) => `${originalRow.id}`,
+ shallow: false,
+ clearOnDefault: true,
+ })
+
+ return (
+ <>
+ <DataTable
+ table={table}
+ // floatingBar={<GtcClausesTableFloatingBar table={table} />}
+ >
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ <GtcClausesTableToolbarActions
+ table={table}
+ documentId={documentId}
+ document={document}
+ />
+ </DataTableAdvancedToolbar>
+ </DataTable>
+
+ {/* 삭제 다이얼로그 */}
+ <DeleteGtcClausesDialog
+ open={rowAction?.type === "delete"}
+ onOpenChange={() => setRowAction(null)}
+ gtcClauses={rowAction?.row.original ? [rowAction?.row.original] : []}
+ showTrigger={false}
+ onSuccess={() => rowAction?.row.toggleSelected(false)}
+ />
+
+ {/* 수정 시트 */}
+ <UpdateGtcClauseSheet
+ open={rowAction?.type === "update"}
+ onOpenChange={() => setRowAction(null)}
+ gtcClause={rowAction?.row.original ?? null}
+ documentId={documentId}
+ />
+
+ {/* 생성 다이얼로그 */}
+ {/* <CreateGtcClauseDialog
+ key="main-create"
+ documentId={documentId}
+ document={document}
+ /> */}
+
+ {/* 하위 조항 추가 다이얼로그 */}
+ <CreateGtcClauseDialog
+ key={`sub-create-${rowAction?.row.original.id || 'none'}`}
+ documentId={documentId}
+ document={document}
+ parentClause={rowAction?.type === "addSubClause" ? rowAction.row.original : null}
+ open={rowAction?.type === "addSubClause"}
+ onOpenChange={(open) => {
+ if (!open) {
+ setRowAction(null)
+ }
+ }}
+ showTrigger={false}
+ onSuccess={() => {
+ setRowAction(null)
+ // 테이블 리프레시 로직
+ }}
+ />
+
+ {/* 조항 복제 다이얼로그 */}
+ <DuplicateGtcClauseDialog
+ open={rowAction?.type === "duplicate"}
+ onOpenChange={() => setRowAction(null)}
+ sourceClause={rowAction?.row.original ?? null}
+ onSuccess={() => {
+ // 테이블 리프레시 로직
+ }}
+ />
+
+
+ </>
+ )
+} \ No newline at end of file
diff --git a/lib/gtc-contract/gtc-clauses/table/clause-variable-settings-dialog.tsx b/lib/gtc-contract/gtc-clauses/table/clause-variable-settings-dialog.tsx
new file mode 100644
index 00000000..36d47403
--- /dev/null
+++ b/lib/gtc-contract/gtc-clauses/table/clause-variable-settings-dialog.tsx
@@ -0,0 +1,364 @@
+"use client"
+
+import * as React from "react"
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Textarea } from "@/components/ui/textarea"
+import { Badge } from "@/components/ui/badge"
+
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+ FormDescription,
+} from "@/components/ui/form"
+import { Loader, Settings2, Wand2, Copy, Eye } from "lucide-react"
+import { toast } from "sonner"
+import { cn } from "@/lib/utils"
+
+import { updateGtcClauseSchema, type UpdateGtcClauseSchema } from "@/lib/gtc-contract/gtc-clauses/validations"
+import { updateGtcClause } from "@/lib/gtc-contract/gtc-clauses/service"
+import { type GtcClauseTreeView } from "@/db/schema/gtc"
+import { useSession } from "next-auth/react"
+
+interface ClauseVariableSettingsDialogProps
+ extends React.ComponentPropsWithRef<typeof Dialog> {
+ clause: GtcClauseTreeView | null
+ onSuccess?: () => void
+}
+
+export function ClauseVariableSettingsDialog({
+ clause,
+ onSuccess,
+ ...props
+}: ClauseVariableSettingsDialogProps) {
+ const [isUpdatePending, startUpdateTransition] = React.useTransition()
+ const [showPreview, setShowPreview] = React.useState(false)
+ const { data: session } = useSession()
+
+ const currentUserId = React.useMemo(() => {
+ return session?.user?.id ? Number(session.user.id) : null
+ }, [session])
+
+ const form = useForm<UpdateGtcClauseSchema>({
+ resolver: zodResolver(updateGtcClauseSchema),
+ defaultValues: {
+ numberVariableName: "",
+ subtitleVariableName: "",
+ contentVariableName: "",
+ editReason: "",
+ },
+ })
+
+ // clause가 변경될 때 폼 데이터 설정
+ React.useEffect(() => {
+ if (clause) {
+ form.reset({
+ numberVariableName: clause.numberVariableName || "",
+ subtitleVariableName: clause.subtitleVariableName || "",
+ contentVariableName: clause.contentVariableName || "",
+ editReason: "",
+ })
+ }
+ }, [clause, form])
+
+ const generateAutoVariableNames = () => {
+ if (!clause) return
+
+ const fullPath = clause.fullPath || clause.itemNumber
+
+ console.log(clause.fullPath,fullPath,"fullPath")
+ console.log(clause, "clause")
+
+ const prefix = "CLAUSE_" + fullPath.replace(/\./g, "_")
+
+ form.setValue("numberVariableName", `${prefix}_NUMBER`)
+ form.setValue("subtitleVariableName", `${prefix}_SUBTITLE`)
+ form.setValue("contentVariableName", `${prefix}_CONTENT`)
+
+ toast.success("변수명이 자동 생성되었습니다.")
+ }
+
+ const copyCurrentVariableNames = () => {
+ if (!clause) return
+
+ const currentVars = {
+ number: clause.autoNumberVariable,
+ subtitle: clause.autoSubtitleVariable,
+ content: clause.autoContentVariable,
+ }
+
+ form.setValue("numberVariableName", currentVars.number)
+ form.setValue("subtitleVariableName", currentVars.subtitle)
+ form.setValue("contentVariableName", currentVars.content)
+
+ toast.success("현재 변수명이 복사되었습니다.")
+ }
+
+ async function onSubmit(data: UpdateGtcClauseSchema) {
+ startUpdateTransition(async () => {
+ if (!clause || !currentUserId) {
+ toast.error("조항 정보를 찾을 수 없습니다.")
+ return
+ }
+
+ try {
+ const result = await updateGtcClause(clause.id, {
+ numberVariableName: data.numberVariableName,
+ subtitleVariableName: data.subtitleVariableName,
+ contentVariableName: data.contentVariableName,
+ editReason: data.editReason || "PDFTron 변수명 설정",
+ updatedById: currentUserId,
+ })
+
+ if (result.error) {
+ toast.error(result.error)
+ return
+ }
+
+ form.reset()
+ props.onOpenChange?.(false)
+ toast.success("변수명이 설정되었습니다!")
+ onSuccess?.()
+ } catch (error) {
+ toast.error("변수명 설정 중 오류가 발생했습니다.")
+ }
+ })
+ }
+
+ function handleDialogOpenChange(nextOpen: boolean) {
+ if (!nextOpen) {
+ form.reset()
+ setShowPreview(false)
+ }
+ props.onOpenChange?.(nextOpen)
+ }
+
+ const currentNumberVar = form.watch("numberVariableName")
+ const currentSubtitleVar = form.watch("subtitleVariableName")
+ const currentContentVar = form.watch("contentVariableName")
+
+ const hasAllVariables = currentNumberVar && currentSubtitleVar && currentContentVar
+
+ if (!clause) {
+ return null
+ }
+
+ return (
+ <Dialog {...props} onOpenChange={handleDialogOpenChange}>
+ <DialogContent className="max-w-2xl max-h-[90vh] flex flex-col">
+ <DialogHeader className="flex-shrink-0">
+ <DialogTitle className="flex items-center gap-2">
+ <Settings2 className="h-5 w-5" />
+ PDFTron 변수명 설정
+ </DialogTitle>
+ <DialogDescription>
+ 조항의 PDFTron 변수명을 설정하여 문서 생성에 사용합니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ {/* 조항 정보 */}
+ <div className="p-3 bg-muted/50 rounded-lg text-sm flex-shrink-0">
+ <div className="font-medium mb-2">대상 조항</div>
+ <div className="space-y-1 text-muted-foreground">
+ <div className="flex items-center gap-2">
+ <Badge variant="outline">{clause.itemNumber}</Badge>
+ <span>{clause.subtitle}</span>
+ <Badge variant={clause.hasAllVariableNames ? "default" : "destructive"}>
+ {clause.hasAllVariableNames ? "설정됨" : "미설정"}
+ </Badge>
+ </div>
+ {clause.fullPath && (
+ <div className="text-xs">경로: {clause.fullPath}</div>
+ )}
+ {clause.category && (
+ <div className="text-xs">분류: {clause.category}</div>
+ )}
+ </div>
+ </div>
+
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col flex-1 min-h-0">
+ <div className="flex-1 overflow-y-auto px-1">
+ <div className="space-y-4 py-4">
+ {/* 자동 생성 버튼들 */}
+ <div className="flex gap-2">
+ <Button
+ type="button"
+ variant="outline"
+ size="sm"
+ onClick={generateAutoVariableNames}
+ className="flex items-center gap-2"
+ >
+ <Wand2 className="h-4 w-4" />
+ 자동 생성
+ </Button>
+
+ <Button
+ type="button"
+ variant="outline"
+ size="sm"
+ onClick={copyCurrentVariableNames}
+ className="flex items-center gap-2"
+ >
+ <Copy className="h-4 w-4" />
+ 현재값 복사
+ </Button>
+
+ <Button
+ type="button"
+ variant="outline"
+ size="sm"
+ onClick={() => setShowPreview(!showPreview)}
+ className="flex items-center gap-2"
+ >
+ <Eye className="h-4 w-4" />
+ {showPreview ? "미리보기 숨기기" : "미리보기"}
+ </Button>
+ </div>
+
+ {/* 현재 설정된 변수명 표시 */}
+ {clause.hasAllVariableNames && (
+ <div className="p-3 bg-blue-50 border border-blue-200 rounded-lg">
+ <div className="text-sm font-medium text-blue-900 mb-2">현재 설정된 변수명</div>
+ <div className="space-y-1 text-xs">
+ <div><code className="bg-blue-100 px-1 rounded">{clause.numberVariableName}</code></div>
+ <div><code className="bg-blue-100 px-1 rounded">{clause.subtitleVariableName}</code></div>
+ <div><code className="bg-blue-100 px-1 rounded">{clause.contentVariableName}</code></div>
+ </div>
+ </div>
+ )}
+
+ {/* 변수명 입력 필드들 */}
+ <div className="space-y-4">
+ <FormField
+ control={form.control}
+ name="numberVariableName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>채번 변수명 *</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="예: CLAUSE_1_NUMBER, HEADER_1_NUM 등"
+ {...field}
+ />
+ </FormControl>
+ <FormDescription>
+ 문서에서 조항 번호를 표시할 변수명입니다.
+ </FormDescription>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="subtitleVariableName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>소제목 변수명 *</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="예: CLAUSE_1_SUBTITLE, HEADER_1_TITLE 등"
+ {...field}
+ />
+ </FormControl>
+ <FormDescription>
+ 문서에서 조항 제목을 표시할 변수명입니다.
+ </FormDescription>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="contentVariableName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>상세항목 변수명 *</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="예: CLAUSE_1_CONTENT, BODY_1_TEXT 등"
+ {...field}
+ />
+ </FormControl>
+ <FormDescription>
+ 문서에서 조항 내용을 표시할 변수명입니다.
+ </FormDescription>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ {/* 미리보기 */}
+ {showPreview && hasAllVariables && (
+ <div className="p-3 bg-gray-50 border rounded-lg">
+ <div className="text-sm font-medium mb-2">PDFTron 템플릿 미리보기</div>
+ <div className="space-y-2 text-xs font-mono bg-white p-2 rounded border">
+ <div className="text-blue-600">{"{{" + currentNumberVar + "}}"}. {"{{" + currentSubtitleVar + "}}"}</div>
+ <div className="text-gray-600 ml-4">{"{{" + currentContentVar + "}}"}</div>
+ </div>
+ <div className="text-xs text-muted-foreground mt-2">
+ 실제 문서에서 위와 같은 형태로 표시됩니다.
+ </div>
+ </div>
+ )}
+
+ {/* 편집 사유 */}
+ <FormField
+ control={form.control}
+ name="editReason"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>편집 사유 (선택사항)</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="변수명 설정 사유를 입력하세요..."
+ {...field}
+ rows={2}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+ </div>
+
+ <DialogFooter className="flex-shrink-0 border-t pt-4">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => props.onOpenChange?.(false)}
+ disabled={isUpdatePending}
+ >
+ Cancel
+ </Button>
+ <Button
+ type="submit"
+ disabled={isUpdatePending || !hasAllVariables}
+ >
+ {isUpdatePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ <Settings2 className="mr-2 h-4 w-4" />
+ Save Variables
+ </Button>
+ </DialogFooter>
+ </form>
+ </Form>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/gtc-contract/gtc-clauses/table/create-gtc-clause-dialog.tsx b/lib/gtc-contract/gtc-clauses/table/create-gtc-clause-dialog.tsx
new file mode 100644
index 00000000..b65e5261
--- /dev/null
+++ b/lib/gtc-contract/gtc-clauses/table/create-gtc-clause-dialog.tsx
@@ -0,0 +1,442 @@
+"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 { Input } from "@/components/ui/input"
+import { Textarea } from "@/components/ui/textarea"
+
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+ FormDescription,
+} from "@/components/ui/form"
+import {
+ Popover,
+ PopoverTrigger,
+ PopoverContent,
+} from "@/components/ui/popover"
+import {
+ Command,
+ CommandInput,
+ CommandList,
+ CommandGroup,
+ CommandItem,
+ CommandEmpty,
+} from "@/components/ui/command"
+import { Check, ChevronsUpDown, Loader, Plus, Info } from "lucide-react"
+import { cn } from "@/lib/utils"
+import { toast } from "sonner"
+
+import { createGtcClauseSchema, type CreateGtcClauseSchema } from "@/lib/gtc-contract/gtc-clauses/validations"
+import { createGtcClause, getGtcClausesTree } from "@/lib/gtc-contract/gtc-clauses/service"
+import { type GtcClauseTreeView } from "@/db/schema/gtc"
+import { useSession } from "next-auth/react"
+import { MarkdownImageEditor } from "./markdown-image-editor"
+
+interface ClauseImage {
+ id: string
+ url: string
+ fileName: string
+ size: number
+}
+
+interface CreateGtcClauseDialogProps {
+ documentId: number
+ document: any
+ parentClause?: GtcClauseTreeView | null
+ onSuccess?: () => void
+ open?: boolean
+ onOpenChange?: (open: boolean) => void
+ showTrigger?: boolean
+}
+
+export function CreateGtcClauseDialog({
+ documentId,
+ document,
+ parentClause = null,
+ onSuccess,
+ open: controlledOpen,
+ onOpenChange: controlledOnOpenChange,
+ showTrigger = true
+}: CreateGtcClauseDialogProps) {
+ const [internalOpen, setInternalOpen] = React.useState(false)
+
+ // controlled vs uncontrolled 모드
+ const isControlled = controlledOpen !== undefined
+ const open = isControlled ? controlledOpen : internalOpen
+ const setOpen = isControlled ? controlledOnOpenChange! : setInternalOpen
+ const [parentClauses, setParentClauses] = React.useState<GtcClauseTreeView[]>([])
+ const [isCreatePending, startCreateTransition] = React.useTransition()
+ const { data: session } = useSession()
+
+ // ✅ 이미지 상태 추가
+ const [images, setImages] = React.useState<ClauseImage[]>([])
+
+ const currentUserId = React.useMemo(() => {
+ return session?.user?.id ? Number(session.user.id) : null
+ }, [session])
+
+ React.useEffect(() => {
+ if (open) {
+ loadParentClauses()
+ }
+ }, [open, documentId])
+
+ const loadParentClauses = async () => {
+ try {
+ const tree = await getGtcClausesTree(documentId)
+ setParentClauses(flattenTree(tree))
+ } catch (error) {
+ console.error("Error loading parent clauses:", error)
+ }
+ }
+
+ const form = useForm<CreateGtcClauseSchema>({
+ resolver: zodResolver(createGtcClauseSchema),
+ defaultValues: {
+ documentId,
+ parentId: parentClause?.id || null,
+ itemNumber: "",
+ category: "",
+ subtitle: "",
+ content: "",
+ sortOrder: 0,
+ editReason: "",
+ },
+ })
+
+ // ✅ 이미지와 콘텐츠 변경 핸들러
+ const handleContentImageChange = (content: string, newImages: ClauseImage[]) => {
+ form.setValue("content", content)
+ setImages(newImages)
+ }
+
+ async function onSubmit(data: CreateGtcClauseSchema) {
+ startCreateTransition(async () => {
+ if (!currentUserId) {
+ toast.error("로그인이 필요합니다")
+ return
+ }
+
+ try {
+ // ✅ 이미지 데이터도 함께 전송
+ const result = await createGtcClause({
+ ...data,
+ images: images, // 이미지 배열 추가
+ createdById: currentUserId
+ })
+
+ if (result.error) {
+ toast.error(`에러: ${result.error}`)
+ return
+ }
+
+ form.reset()
+ setImages([]) // ✅ 이미지 상태 초기화
+ setOpen(false)
+ toast.success("GTC 조항이 생성되었습니다.")
+ onSuccess?.()
+ } catch (error) {
+ toast.error("조항 생성 중 오류가 발생했습니다.")
+ }
+ })
+ }
+
+ function handleDialogOpenChange(nextOpen: boolean) {
+ if (!nextOpen) {
+ form.reset()
+ setImages([]) // ✅ 다이얼로그 닫을 때 이미지 상태 초기화
+ }
+ setOpen(nextOpen)
+ }
+
+ const selectedParent = parentClauses.find(c => c.id === form.watch("parentId"))
+
+ return (
+ <Dialog open={open} onOpenChange={handleDialogOpenChange}>
+ {showTrigger && (
+ <DialogTrigger asChild>
+ <Button variant="default" size="sm">
+ <Plus className="mr-2 h-4 w-4" />
+ {parentClause ? "하위 조항 추가" : "조항 추가"}
+ </Button>
+ </DialogTrigger>
+ )}
+
+ <DialogContent className="max-w-4xl h-[90vh] flex flex-col"> {/* ✅ 너비 확장 */}
+ <DialogHeader className="flex-shrink-0">
+ <DialogTitle>
+ {parentClause ? "하위 조항 생성" : "새 조항 생성"}
+ </DialogTitle>
+ <DialogDescription>
+ 새 GTC 조항 정보를 입력하고 <b>Create</b> 버튼을 누르세요. 이미지를 포함할 수 있습니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ {/* 문서 정보 표시 */}
+ <div className="p-3 bg-muted/50 rounded-lg text-sm flex-shrink-0">
+ <div className="font-medium mb-1">문서 정보</div>
+ <div className="text-muted-foreground space-y-1">
+ <div>구분: {document?.type === "standard" ? "표준" : "프로젝트"}</div>
+ {document?.project && (
+ <div>프로젝트: {document.project.name} ({document.project.code})</div>
+ )}
+ <div>리비전: v{document?.revision}</div>
+ </div>
+ </div>
+
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col flex-1 min-h-0">
+ {/* 스크롤 가능한 폼 내용 영역 */}
+ <div className="flex-1 overflow-y-auto px-1">
+ <div className="space-y-4 py-4">
+ {/* 부모 조항 선택 */}
+ <FormField
+ control={form.control}
+ name="parentId"
+ render={({ field }) => {
+ 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"
+ >
+ {selectedParent
+ ? `${selectedParent.itemNumber} - ${selectedParent.subtitle}`
+ : "부모 조항을 선택하세요... (최상위 조항인 경우 선택 안함)"}
+ <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>조항을 찾을 수 없습니다.</CommandEmpty>
+ <CommandGroup>
+ {/* 최상위 조항 옵션 */}
+ <CommandItem
+ value="none"
+ onSelect={() => {
+ field.onChange(null)
+ setPopoverOpen(false)
+ }}
+ >
+ 최상위 조항
+ <Check
+ className={cn(
+ "ml-auto h-4 w-4",
+ !field.value ? "opacity-100" : "opacity-0"
+ )}
+ />
+ </CommandItem>
+
+ {parentClauses.map((clause) => {
+ const label = `${clause.itemNumber} - ${clause.subtitle}`
+ return (
+ <CommandItem
+ key={clause.id}
+ value={label}
+ onSelect={() => {
+ field.onChange(clause.id)
+ setPopoverOpen(false)
+ }}
+ >
+ <div className="flex items-center w-full">
+ <span style={{ marginLeft: `${clause.depth * 12}px` }}>
+ {label}
+ </span>
+ </div>
+ <Check
+ className={cn(
+ "ml-auto h-4 w-4",
+ selectedParent?.id === clause.id
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ />
+ </CommandItem>
+ )
+ })}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )
+ }}
+ />
+
+ {/* 채번 */}
+ <FormField
+ control={form.control}
+ name="itemNumber"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>채번 *</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="예: 1, 1.1, 2.3.1, A, B-1 등"
+ {...field}
+ />
+ </FormControl>
+ <FormDescription>
+ 조항의 번호입니다. 영문, 숫자, 점(.), 하이픈(-), 언더스코어(_)를 사용할 수 있습니다.
+ </FormDescription>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 분류 */}
+ <FormField
+ control={form.control}
+ name="category"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>분류 (선택사항)</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="예: 일반조항, 특수조항, 기술조항 등"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 소제목 */}
+ <FormField
+ control={form.control}
+ name="subtitle"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>소제목 *</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="예: PREAMBLE, DEFINITIONS, GENERAL CONDITIONS 등"
+ {...field}
+ />
+ </FormControl>
+ <FormDescription>
+ 조항의 제목입니다. 문서에서 헤더로 표시됩니다.
+ </FormDescription>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* ✅ 상세항목 - MarkdownImageEditor 사용 */}
+ <FormField
+ control={form.control}
+ name="content"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>상세항목 (선택사항)</FormLabel>
+ <FormControl>
+ <MarkdownImageEditor
+ content={field.value || ""}
+ images={images}
+ onChange={handleContentImageChange}
+ placeholder="조항의 상세 내용을 입력하세요... 이미지를 추가하려면 '이미지 추가' 버튼을 클릭하세요."
+ rows={8}
+ />
+ </FormControl>
+ <FormDescription>
+ 조항의 실제 내용입니다. 텍스트와 이미지를 조합할 수 있으며, 하위 조항들을 그룹핑하는 제목용 조항인 경우 비워둘 수 있습니다.
+ </FormDescription>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 편집 사유 */}
+ <FormField
+ control={form.control}
+ name="editReason"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>편집 사유 (선택사항)</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="조항 생성 사유를 입력하세요..."
+ {...field}
+ rows={2}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+ </div>
+
+ {/* 고정된 푸터 */}
+ <DialogFooter className="flex-shrink-0 border-t pt-4">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => setOpen(false)}
+ disabled={isCreatePending}
+ >
+ Cancel
+ </Button>
+ <Button type="submit" disabled={isCreatePending}>
+ {isCreatePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ Create
+ </Button>
+ </DialogFooter>
+ </form>
+ </Form>
+ </DialogContent>
+ </Dialog>
+ )
+}
+
+// 트리를 평면 배열로 변환하는 유틸리티 함수
+function flattenTree(tree: any[]): any[] {
+ const result: any[] = []
+
+ function traverse(nodes: any[]) {
+ for (const node of nodes) {
+ result.push(node)
+ if (node.children && node.children.length > 0) {
+ traverse(node.children)
+ }
+ }
+ }
+
+ traverse(tree)
+ return result
+} \ No newline at end of file
diff --git a/lib/gtc-contract/gtc-clauses/table/delete-gtc-clauses-dialog.tsx b/lib/gtc-contract/gtc-clauses/table/delete-gtc-clauses-dialog.tsx
new file mode 100644
index 00000000..29483c57
--- /dev/null
+++ b/lib/gtc-contract/gtc-clauses/table/delete-gtc-clauses-dialog.tsx
@@ -0,0 +1,175 @@
+"use client"
+
+import * as React from "react"
+import { Loader, Trash2, AlertTriangle } from "lucide-react"
+import { toast } from "sonner"
+
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from "@/components/ui/alert-dialog"
+import { Button } from "@/components/ui/button"
+import { Badge } from "@/components/ui/badge"
+
+import { type GtcClauseTreeView } from "@/db/schema/gtc"
+import { useSession } from "next-auth/react"
+import { deleteGtcClauses } from "../service"
+
+interface DeleteGtcClausesDialogProps
+ extends React.ComponentPropsWithRef<typeof AlertDialog> {
+ gtcClauses: GtcClauseTreeView[]
+ showTrigger?: boolean
+ onSuccess?: () => void
+}
+
+export function DeleteGtcClausesDialog({
+ gtcClauses,
+ showTrigger = true,
+ onSuccess,
+ ...props
+}: DeleteGtcClausesDialogProps) {
+ const [isDeletePending, startDeleteTransition] = React.useTransition()
+ const { data: session } = useSession()
+
+ const currentUserId = React.useMemo(() => {
+ return session?.user?.id ? Number(session.user.id) : null
+ }, [session])
+
+ function onDelete() {
+ startDeleteTransition(async () => {
+ if (!currentUserId) {
+ toast.error("로그인이 필요합니다")
+ return
+ }
+
+ try {
+ // ✅ 한 번에 모든 조항 삭제 (배열로 전달)
+ const ids = gtcClauses.map(clause => clause.id)
+ const result = await deleteGtcClauses(ids)
+
+ if (result.error) {
+ toast.error(`삭제 중 오류가 발생했습니다: ${result.error}`)
+ return
+ }
+
+ props.onOpenChange?.(false)
+ toast.success(
+ `${result.deletedCount}개의 조항이 삭제되었습니다.`
+ )
+ onSuccess?.()
+ } catch (error) {
+ console.error("Delete error:", error)
+ toast.error("조항 삭제 중 오류가 발생했습니다.")
+ }
+ })
+ }
+
+ const clausesWithChildren = gtcClauses.filter(clause => clause.childrenCount > 0)
+ const hasChildrenWarning = clausesWithChildren.length > 0
+
+ // 총 삭제될 하위 조항 수 계산
+ const totalChildrenCount = clausesWithChildren.reduce((sum, clause) => sum + clause.childrenCount, 0)
+
+ return (
+ <AlertDialog {...props}>
+ {showTrigger && (
+ <AlertDialogTrigger asChild>
+ <Button variant="outline" size="sm">
+ <Trash2 className="mr-2 h-4 w-4" />
+ 삭제 ({gtcClauses.length})
+ </Button>
+ </AlertDialogTrigger>
+ )}
+
+ <AlertDialogContent className="max-w-md">
+ <AlertDialogHeader>
+ <div className="flex items-center gap-2">
+ <AlertTriangle className="h-5 w-5 text-destructive" />
+ <AlertDialogTitle>조항 삭제</AlertDialogTitle>
+ </div>
+ <AlertDialogDescription asChild>
+ <div className="space-y-3">
+ <p>
+ 선택한 {gtcClauses.length}개의 조항을 <strong>완전히 삭제</strong>하시겠습니까?
+ 이 작업은 되돌릴 수 없습니다.
+ </p>
+
+ {/* 삭제할 조항 목록 */}
+ <div className="space-y-2">
+ <div className="text-sm font-medium">삭제할 조항:</div>
+ <div className="max-h-32 overflow-y-auto space-y-1">
+ {gtcClauses.map((clause) => (
+ <div key={clause.id} className="flex items-center gap-2 text-sm p-2 bg-muted/50 rounded">
+ <Badge variant="outline" className="text-xs">
+ {clause.itemNumber}
+ </Badge>
+ <span className="flex-1 truncate">{clause.subtitle}</span>
+ {clause.childrenCount > 0 && (
+ <Badge variant="destructive" className="text-xs">
+ 하위 {clause.childrenCount}개
+ </Badge>
+ )}
+ </div>
+ ))}
+ </div>
+ </div>
+
+ {/* 하위 조항 경고 */}
+ {hasChildrenWarning && (
+ <div className="p-3 bg-destructive/10 border border-destructive/20 rounded-md">
+ <div className="flex items-center gap-2 text-destructive text-sm font-medium mb-2">
+ <AlertTriangle className="h-4 w-4" />
+ 중요: 하위 조항 포함 삭제
+ </div>
+ <div className="space-y-1 text-sm text-destructive/80">
+ <p>하위 조항이 있는 조항을 삭제하면 모든 하위 조항도 함께 삭제됩니다.</p>
+ <p className="font-medium">
+ 총 삭제될 조항: {gtcClauses.length + totalChildrenCount}개
+ <span className="text-xs ml-1">
+ (선택한 {gtcClauses.length}개 + 하위 {totalChildrenCount}개)
+ </span>
+ </p>
+ </div>
+ <div className="mt-2 text-xs text-destructive/70">
+ 영향받는 조항: {clausesWithChildren.map(c => c.itemNumber).join(', ')}
+ </div>
+ </div>
+ )}
+
+ <div className="p-2 bg-amber-50 border border-amber-200 rounded text-xs text-amber-800">
+ ⚠️ <strong>실제 삭제</strong>: 데이터베이스에서 완전히 제거되며 복구할 수 없습니다.
+ </div>
+ </div>
+ </AlertDialogDescription>
+ </AlertDialogHeader>
+
+ <AlertDialogFooter>
+ <AlertDialogCancel disabled={isDeletePending}>
+ Cancel
+ </AlertDialogCancel>
+ <AlertDialogAction
+ onClick={onDelete}
+ disabled={isDeletePending}
+ className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
+ >
+ {isDeletePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ <Trash2 className="mr-2 h-4 w-4" />
+ Delete Permanently
+ </AlertDialogAction>
+ </AlertDialogFooter>
+ </AlertDialogContent>
+ </AlertDialog>
+ )
+} \ No newline at end of file
diff --git a/lib/gtc-contract/gtc-clauses/table/duplicate-gtc-clause-dialog.tsx b/lib/gtc-contract/gtc-clauses/table/duplicate-gtc-clause-dialog.tsx
new file mode 100644
index 00000000..cb5ac81d
--- /dev/null
+++ b/lib/gtc-contract/gtc-clauses/table/duplicate-gtc-clause-dialog.tsx
@@ -0,0 +1,372 @@
+"use client"
+
+import * as React from "react"
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Textarea } from "@/components/ui/textarea"
+import { Badge } from "@/components/ui/badge"
+
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+ FormDescription,
+} from "@/components/ui/form"
+import { Loader, Copy, Info } from "lucide-react"
+import { toast } from "sonner"
+
+import { createGtcClauseSchema, type CreateGtcClauseSchema } from "@/lib/gtc-contract/gtc-clauses/validations"
+import { createGtcClause } from "@/lib/gtc-contract/gtc-clauses/service"
+import { type GtcClauseTreeView } from "@/db/schema/gtc"
+import { useSession } from "next-auth/react"
+
+interface DuplicateGtcClauseDialogProps
+ extends React.ComponentPropsWithRef<typeof Dialog> {
+ sourceClause: GtcClauseTreeView | null
+ onSuccess?: () => void
+}
+
+export function DuplicateGtcClauseDialog({
+ sourceClause,
+ onSuccess,
+ ...props
+}: DuplicateGtcClauseDialogProps) {
+ const [isCreatePending, startCreateTransition] = React.useTransition()
+ const { data: session } = useSession()
+
+ const currentUserId = React.useMemo(() => {
+ return session?.user?.id ? Number(session.user.id) : null
+ }, [session])
+
+
+ const form = useForm<CreateGtcClauseSchema>({
+ resolver: zodResolver(createGtcClauseSchema),
+ defaultValues: {
+ documentId: 0,
+ parentId: null,
+ itemNumber: "",
+ category: "",
+ subtitle: "",
+ content: "",
+ sortOrder: 0,
+ // numberVariableName: "",
+ // subtitleVariableName: "",
+ // contentVariableName: "",
+ editReason: "",
+ },
+ })
+
+ // sourceClause가 변경될 때 폼 데이터 설정
+ React.useEffect(() => {
+ if (sourceClause) {
+ // 새로운 채번 생성 (원본에 "_copy" 추가)
+ const newItemNumber = `${sourceClause.itemNumber}_copy`
+
+ form.reset({
+ documentId: sourceClause.documentId,
+ parentId: sourceClause.parentId,
+ itemNumber: newItemNumber,
+ category: sourceClause.category || "",
+ subtitle: `${sourceClause.subtitle} (복제)`,
+ content: sourceClause.content || "",
+ sortOrder:parseFloat(sourceClause.sortOrder) + 0.1, // 원본 바로 다음에 위치
+ // numberVariableName: "",
+ // subtitleVariableName: "",
+ // contentVariableName: "",
+ editReason: `조항 복제 (원본: ${sourceClause.itemNumber})`,
+ })
+
+ // 자동 변수명 생성
+ // generateVariableNames(newItemNumber, sourceClause.parentId)
+ }
+ }, [sourceClause, form])
+
+ // const generateVariableNames = (itemNumber: string, parentId: number | null) => {
+ // if (!sourceClause) return
+
+ // let fullPath = itemNumber
+
+ // if (parentId && sourceClause.fullPath) {
+ // const parentPath = sourceClause.fullPath.split('.').slice(0, -1).join('.')
+ // if (parentPath) {
+ // fullPath = `${parentPath}.${itemNumber}`
+ // }
+ // }
+
+ // const prefix = "CLAUSE_" + fullPath.replace(/\./g, "_")
+
+ // form.setValue("numberVariableName", `${prefix}_NUMBER`)
+ // form.setValue("subtitleVariableName", `${prefix}_SUBTITLE`)
+ // form.setValue("contentVariableName", `${prefix}_CONTENT`)
+ // }
+
+ async function onSubmit(data: CreateGtcClauseSchema) {
+ startCreateTransition(async () => {
+ if (!currentUserId) {
+ toast.error("로그인이 필요합니다")
+ return
+ }
+
+ try {
+ const result = await createGtcClause({
+ ...data,
+ createdById: currentUserId
+ })
+
+ if (result.error) {
+ toast.error(`에러: ${result.error}`)
+ return
+ }
+
+ form.reset()
+ props.onOpenChange?.(false)
+ toast.success("조항이 복제되었습니다.")
+ onSuccess?.()
+ } catch (error) {
+ toast.error("조항 복제 중 오류가 발생했습니다.")
+ }
+ })
+ }
+
+ function handleDialogOpenChange(nextOpen: boolean) {
+ if (!nextOpen) {
+ form.reset()
+ }
+ props.onOpenChange?.(nextOpen)
+ }
+
+ if (!sourceClause) {
+ return null
+ }
+
+ return (
+ <Dialog {...props} onOpenChange={handleDialogOpenChange}>
+ <DialogContent className="max-w-2xl h-[90vh] flex flex-col">
+ <DialogHeader className="flex-shrink-0">
+ <DialogTitle className="flex items-center gap-2">
+ <Copy className="h-5 w-5" />
+ 조항 복제
+ </DialogTitle>
+ <DialogDescription>
+ 기존 조항을 복제하여 새로운 조항을 생성합니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ {/* 원본 조항 정보 */}
+ <div className="p-3 bg-muted/50 rounded-lg text-sm flex-shrink-0">
+ <div className="flex items-center gap-2 mb-2">
+ <Info className="h-4 w-4 text-muted-foreground" />
+ <span className="font-medium">복제할 원본 조항</span>
+ </div>
+ <div className="space-y-1 text-muted-foreground">
+ <div className="flex items-center gap-2">
+ <Badge variant="outline">{sourceClause.itemNumber}</Badge>
+ <span>{sourceClause.subtitle}</span>
+ </div>
+ {sourceClause.category && (
+ <div>분류: {sourceClause.category}</div>
+ )}
+ {sourceClause.content && (
+ <div className="text-xs">
+ 내용: {sourceClause.content.substring(0, 100)}
+ {sourceClause.content.length > 100 && "..."}
+ </div>
+ )}
+ </div>
+ </div>
+
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col flex-1 min-h-0">
+ <div className="flex-1 overflow-y-auto px-1">
+ <div className="space-y-4 py-4">
+ {/* 새 채번 */}
+ <FormField
+ control={form.control}
+ name="itemNumber"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>새 채번 *</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="예: 1_copy, 1.1_v2, 2.3.1_new 등"
+ {...field}
+ />
+ </FormControl>
+ <FormDescription>
+ 복제된 조항의 새로운 번호입니다. 원본과 다른 번호를 사용해주세요.
+ </FormDescription>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 분류 */}
+ <FormField
+ control={form.control}
+ name="category"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>분류</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="분류를 수정하거나 그대로 두세요"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 소제목 */}
+ <FormField
+ control={form.control}
+ name="subtitle"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>소제목 *</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="소제목을 수정하세요"
+ {...field}
+ />
+ </FormControl>
+ <FormDescription>
+ 복제 시 "(복제)" 접미사가 자동으로 추가됩니다.
+ </FormDescription>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 상세항목 */}
+ <FormField
+ control={form.control}
+ name="content"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>상세항목</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="내용을 수정하거나 그대로 두세요"
+ {...field}
+ rows={6}
+ />
+ </FormControl>
+ <FormDescription>
+ 원본 조항의 내용이 복사됩니다. 필요시 수정하세요.
+ </FormDescription>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* PDFTron 변수명 섹션 */}
+ {/* <div className="space-y-3 p-3 border rounded-lg">
+ <div className="flex items-center gap-2">
+ <Info className="h-4 w-4 text-muted-foreground" />
+ <span className="text-sm font-medium">PDFTron 변수명 (자동 생성)</span>
+ </div>
+
+ <div className="grid grid-cols-1 gap-3">
+ <FormField
+ control={form.control}
+ name="numberVariableName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>채번 변수명</FormLabel>
+ <FormControl>
+ <Input {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="subtitleVariableName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>소제목 변수명</FormLabel>
+ <FormControl>
+ <Input {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="contentVariableName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>상세항목 변수명</FormLabel>
+ <FormControl>
+ <Input {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ <div className="text-xs text-muted-foreground">
+ 새 채번을 기반으로 변수명이 자동 생성됩니다.
+ </div>
+ </div> */}
+
+ {/* 편집 사유 */}
+ <FormField
+ control={form.control}
+ name="editReason"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>복제 사유</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="조항 복제 사유를 입력하세요..."
+ {...field}
+ rows={2}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+ </div>
+
+ <DialogFooter className="flex-shrink-0 border-t pt-4">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => props.onOpenChange?.(false)}
+ disabled={isCreatePending}
+ >
+ Cancel
+ </Button>
+ <Button type="submit" disabled={isCreatePending}>
+ {isCreatePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ <Copy className="mr-2 h-4 w-4" />
+ Duplicate
+ </Button>
+ </DialogFooter>
+ </form>
+ </Form>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/gtc-contract/gtc-clauses/table/generate-variable-names-dialog.tsx b/lib/gtc-contract/gtc-clauses/table/generate-variable-names-dialog.tsx
new file mode 100644
index 00000000..ef4ed9f9
--- /dev/null
+++ b/lib/gtc-contract/gtc-clauses/table/generate-variable-names-dialog.tsx
@@ -0,0 +1,348 @@
+"use client"
+
+import * as React from "react"
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Badge } from "@/components/ui/badge"
+import { Switch } from "@/components/ui/switch"
+
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+ FormDescription,
+} from "@/components/ui/form"
+import { Loader, Wand2, Info, Eye } from "lucide-react"
+import { toast } from "sonner"
+
+import { generateVariableNamesSchema, type GenerateVariableNamesSchema } from "@/lib/gtc-contract/gtc-clauses/validations"
+import { generateVariableNames, getGtcClausesTree } from "@/lib/gtc-contract/gtc-clauses/service"
+import { type GtcClauseTreeView } from "@/db/schema/gtc"
+import { useSession } from "next-auth/react"
+
+interface GenerateVariableNamesDialogProps
+ extends React.ComponentPropsWithRef<typeof Dialog> {
+ documentId: number
+ document: any
+}
+
+export function GenerateVariableNamesDialog({
+ documentId,
+ document,
+ ...props
+}: GenerateVariableNamesDialogProps) {
+ const [isGenerating, startGenerating] = React.useTransition()
+ const [clauses, setClauses] = React.useState<GtcClauseTreeView[]>([])
+ const [previewClauses, setPreviewClauses] = React.useState<GtcClauseTreeView[]>([])
+ const [showPreview, setShowPreview] = React.useState(false)
+ const { data: session } = useSession()
+
+ const currentUserId = React.useMemo(() => {
+ return session?.user?.id ? Number(session.user.id) : null
+ }, [session])
+
+ React.useEffect(() => {
+ if (props.open && documentId) {
+ loadClauses()
+ }
+ }, [props.open, documentId])
+
+ const loadClauses = async () => {
+ try {
+ const tree = await getGtcClausesTree(documentId)
+ const flatClauses = flattenTree(tree)
+ setClauses(flatClauses)
+ } catch (error) {
+ console.error("Error loading clauses:", error)
+ }
+ }
+
+ const form = useForm<GenerateVariableNamesSchema>({
+ resolver: zodResolver(generateVariableNamesSchema),
+ defaultValues: {
+ documentId,
+ prefix: "CLAUSE",
+ includeVendorCode: false,
+ vendorCode: "",
+ },
+ })
+
+ const watchedPrefix = form.watch("prefix")
+ const watchedIncludeVendorCode = form.watch("includeVendorCode")
+ const watchedVendorCode = form.watch("vendorCode")
+
+ // 미리보기 생성
+ React.useEffect(() => {
+ if (clauses.length > 0) {
+ generatePreview()
+ }
+ }, [clauses, watchedPrefix, watchedIncludeVendorCode, watchedVendorCode])
+
+ const generatePreview = () => {
+ const basePrefix = watchedIncludeVendorCode && watchedVendorCode
+ ? `${watchedVendorCode}_${watchedPrefix}`
+ : watchedPrefix
+
+ console.log(basePrefix,"basePrefix")
+
+ const updated = clauses.slice(0, 5).map(clause => {
+ console.log(clause.fullPath,"clause.fullPath")
+
+ const pathPrefix = clause.fullPath?.replace(/\./g, "_") || clause.itemNumber.replace(/\./g, "_")
+ const varPrefix = `${basePrefix}_${pathPrefix}`
+
+ return {
+ ...clause,
+ previewNumberVar: `${varPrefix}_NUMBER`,
+ previewSubtitleVar: `${varPrefix}_SUBTITLE`,
+ previewContentVar: `${varPrefix}_CONTENT`,
+ }
+ })
+
+ setPreviewClauses(updated as any)
+ }
+
+ async function onSubmit(data: GenerateVariableNamesSchema) {
+ startGenerating(async () => {
+ if (!currentUserId) {
+ toast.error("로그인이 필요합니다")
+ return
+ }
+
+ try {
+ const result = await generateVariableNames({
+ ...data,
+ updatedById: currentUserId
+ })
+
+ if (result.error) {
+ toast.error(`에러: ${result.error}`)
+ return
+ }
+
+ form.reset()
+ props.onOpenChange?.(false)
+ toast.success("PDFTron 변수명이 생성되었습니다.")
+ } catch (error) {
+ toast.error("변수명 생성 중 오류가 발생했습니다.")
+ }
+ })
+ }
+
+ function handleDialogOpenChange(nextOpen: boolean) {
+ if (!nextOpen) {
+ form.reset()
+ setShowPreview(false)
+ }
+ props.onOpenChange?.(nextOpen)
+ }
+
+ const clausesWithoutVariables = clauses.filter(clause => !clause.hasAllVariableNames)
+ const clausesWithVariables = clauses.filter(clause => clause.hasAllVariableNames)
+
+ return (
+ <Dialog {...props} onOpenChange={handleDialogOpenChange}>
+ <DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ <Wand2 className="h-5 w-5" />
+ PDFTron 변수명 자동 생성
+ </DialogTitle>
+ <DialogDescription>
+ 문서의 모든 조항에 대해 PDFTron 변수명을 자동으로 생성합니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ {/* 문서 및 조항 현황 */}
+ <div className="space-y-4 p-4 bg-muted/50 rounded-lg">
+ <div className="flex items-center gap-2">
+ <Info className="h-4 w-4 text-muted-foreground" />
+ <span className="text-sm font-medium">문서 및 조항 현황</span>
+ </div>
+
+ <div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
+ <div>
+ <div className="font-medium text-muted-foreground mb-1">문서 타입</div>
+ <Badge variant={document?.type === "standard" ? "default" : "secondary"}>
+ {document?.type === "standard" ? "표준" : "프로젝트"}
+ </Badge>
+ </div>
+
+ <div>
+ <div className="font-medium text-muted-foreground mb-1">총 조항 수</div>
+ <div>{clauses.length}개</div>
+ </div>
+
+ <div>
+ <div className="font-medium text-muted-foreground mb-1">변수명 설정 완료</div>
+ <Badge variant="default">{clausesWithVariables.length}개</Badge>
+ </div>
+
+ <div>
+ <div className="font-medium text-muted-foreground mb-1">변수명 미설정</div>
+ <Badge variant="destructive">{clausesWithoutVariables.length}개</Badge>
+ </div>
+ </div>
+
+ {document?.project && (
+ <div className="text-sm">
+ <span className="font-medium text-muted-foreground">프로젝트: </span>
+ {document.project.name} ({document.project.code})
+ </div>
+ )}
+ </div>
+
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)}>
+ <div className="space-y-4">
+ {/* 기본 접두사 */}
+ <FormField
+ control={form.control}
+ name="prefix"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>기본 접두사</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="CLAUSE"
+ {...field}
+ />
+ </FormControl>
+ <FormDescription>
+ 모든 변수명의 시작에 사용될 접두사입니다.
+ </FormDescription>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 벤더 코드 포함 여부 */}
+ <FormField
+ control={form.control}
+ name="includeVendorCode"
+ render={({ field }) => (
+ <FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
+ <div className="space-y-0.5">
+ <FormLabel className="text-base">벤더 코드 포함</FormLabel>
+ <FormDescription>
+ 변수명에 벤더 코드를 포함시킵니다. (벤더별 GTC 용)
+ </FormDescription>
+ </div>
+ <FormControl>
+ <Switch
+ checked={field.value}
+ onCheckedChange={field.onChange}
+ />
+ </FormControl>
+ </FormItem>
+ )}
+ />
+
+ {/* 벤더 코드 입력 */}
+ {watchedIncludeVendorCode && (
+ <FormField
+ control={form.control}
+ name="vendorCode"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>벤더 코드</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="예: VENDOR_A, ABC_CORP 등"
+ {...field}
+ />
+ </FormControl>
+ <FormDescription>
+ 변수명에 포함될 벤더 식별 코드입니다.
+ </FormDescription>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ )}
+ />
+
+ {/* 미리보기 토글 */}
+ <div className="flex items-center justify-between">
+ <Button
+ type="button"
+ variant="outline"
+ size="sm"
+ onClick={() => setShowPreview(!showPreview)}
+ >
+ <Eye className="mr-2 h-4 w-4" />
+ {showPreview ? "미리보기 숨기기" : "미리보기 보기"}
+ </Button>
+ </div>
+
+ {/* 미리보기 */}
+ {showPreview && (
+ <div className="space-y-3 p-4 border rounded-lg bg-muted/30">
+ <div className="text-sm font-medium">변수명 미리보기 (상위 5개 조항)</div>
+ <div className="space-y-2 max-h-64 overflow-y-auto">
+ {previewClauses.map((clause: any) => (
+ <div key={clause.id} className="p-2 bg-background rounded border text-xs">
+ <div className="flex items-center gap-2 mb-1">
+ <Badge variant="outline">{clause.itemNumber}</Badge>
+ <span className="font-medium truncate">{clause.subtitle}</span>
+ </div>
+ <div className="space-y-1 text-muted-foreground">
+ <div>채번: <code className="text-foreground">{clause.previewNumberVar}</code></div>
+ <div>소제목: <code className="text-foreground">{clause.previewSubtitleVar}</code></div>
+ <div>상세항목: <code className="text-foreground">{clause.previewContentVar}</code></div>
+ </div>
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+ </div>
+
+ <DialogFooter className="mt-6">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => props.onOpenChange?.(false)}
+ disabled={isGenerating}
+ >
+ Cancel
+ </Button>
+ <Button type="submit" disabled={isGenerating}>
+ {isGenerating && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ Generate Variables
+ </Button>
+ </DialogFooter>
+ </form>
+ </Form>
+ </DialogContent>
+ </Dialog>
+ )
+}
+
+// 트리를 평면 배열로 변환하는 유틸리티 함수
+function flattenTree(tree: any[]): any[] {
+ const result: any[] = []
+
+ function traverse(nodes: any[]) {
+ for (const node of nodes) {
+ result.push(node)
+ if (node.children && node.children.length > 0) {
+ traverse(node.children)
+ }
+ }
+ }
+
+ traverse(tree)
+ return result
+} \ No newline at end of file
diff --git a/lib/gtc-contract/gtc-clauses/table/gtc-clauses-table-columns.tsx b/lib/gtc-contract/gtc-clauses/table/gtc-clauses-table-columns.tsx
new file mode 100644
index 00000000..29efeb9c
--- /dev/null
+++ b/lib/gtc-contract/gtc-clauses/table/gtc-clauses-table-columns.tsx
@@ -0,0 +1,283 @@
+"use client"
+
+import * as React from "react"
+import { type DataTableRowAction } from "@/types/table"
+import { type ColumnDef } from "@tanstack/react-table"
+import { Ellipsis, Edit, Trash2, Plus, Copy } from "lucide-react"
+import { cn, compareItemNumber, formatDate } from "@/lib/utils"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { Checkbox } from "@/components/ui/checkbox"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+import { toast } from "sonner"
+import { useSession } from "next-auth/react"
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import { type GtcClauseTreeView } from "@/db/schema/gtc"
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<GtcClauseTreeView> | null>>
+ documentId: number
+}
+
+export function getColumns({ setRowAction, documentId }: GetColumnsProps): ColumnDef<GtcClauseTreeView>[] {
+ // 1) select
+ const selectColumn: ColumnDef<GtcClauseTreeView> = {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && "indeterminate")
+ }
+ onCheckedChange={(v) => table.toggleAllPageRowsSelected(!!v)}
+ aria-label="Select all"
+ className="translate-y-0.5"
+ />
+ ),
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(v) => row.toggleSelected(!!v)}
+ aria-label="Select row"
+ className="translate-y-0.5"
+ />
+ ),
+ size: 40,
+ enableSorting: false,
+ enableHiding: false,
+ }
+
+ // 2) 조항 정보
+ const clauseInfoColumns: ColumnDef<GtcClauseTreeView>[] = [
+ {
+ accessorKey: "itemNumber",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="채번" />,
+ cell: ({ row }) => {
+ const itemNumber = row.getValue("itemNumber") as string
+ const depth = row.original.depth
+ const childrenCount = row.original.childrenCount
+ return (
+ <div className="flex items-center gap-2">
+ <div style={{ marginLeft: `${depth * 20}px` }} className="flex items-center gap-1">
+ <span className="font-mono text-sm font-medium">{itemNumber}</span>
+ {childrenCount > 0 && (
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger>
+ <Badge variant="outline" className="h-5 px-1 text-xs">
+ {childrenCount}
+ </Badge>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>{childrenCount}개의 하위 조항</p>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ )}
+ </div>
+ </div>
+ )
+ },
+ size: 100,
+ enableResizing: true,
+ sortingFn: (rowA, rowB, colId) =>
+ compareItemNumber(rowA.getValue<string>(colId), rowB.getValue<string>(colId)),
+ meta: { excelHeader: "채번" },
+ },
+ {
+ accessorKey: "category",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="분류" />,
+ cell: ({ row }) => {
+ const category = row.getValue("category") as string
+ return category ? (
+ <Badge variant="secondary" className="text-xs">{category}</Badge>
+ ) : (
+ <span className="text-muted-foreground">-</span>
+ )
+ },
+ size: 100,
+ enableResizing: true,
+ meta: { excelHeader: "분류" },
+ },
+ {
+ accessorKey: "subtitle",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="소제목" />,
+ cell: ({ row }) => {
+ const subtitle = row.getValue("subtitle") as string
+ const depth = row.original.depth
+ return (
+ <div className="flex flex-col min-w-0">
+ <span
+ className={cn(
+ "font-medium truncate",
+ depth === 0 && "text-base",
+ depth === 1 && "text-sm",
+ depth >= 2 && "text-sm text-muted-foreground",
+ )}
+ title={subtitle}
+ >
+ {subtitle}
+ </span>
+ </div>
+ )
+ },
+ size: 150,
+ enableResizing: true,
+ meta: { excelHeader: "소제목" },
+ },
+ {
+ accessorKey: "content",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="상세항목" />,
+ cell: ({ row }) => {
+ const content = row.getValue("content") as string | null
+ if (!content) {
+ return (
+ <div className="flex items-center gap-2">
+ <Badge variant="outline" className="text-xs">그룹핑 조항</Badge>
+ <span className="text-xs text-muted-foreground">상세내용 없음</span>
+ </div>
+ )
+ }
+ const truncated = content.length > 100 ? `${content.substring(0, 100)}...` : content
+ return (
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <div className="">
+ <p className="text-sm line-clamp-2 text-muted-foreground">{content}</p>
+ </div>
+ </TooltipTrigger>
+ <TooltipContent className="max-w-sm">
+ <p className="whitespace-pre-wrap">{content}</p>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ )
+ },
+ size: 200,
+ maxSize: 500,
+ enableResizing: true,
+ meta: { excelHeader: "상세항목" },
+ },
+ ]
+
+ // 3) 등록/수정 정보
+ const auditColumns: ColumnDef<GtcClauseTreeView>[] = [
+ {
+ accessorKey: "createdAt",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="작성일" />,
+ cell: ({ row }) => {
+ const date = row.getValue("createdAt") as Date
+ return date ? formatDate(date, "KR") : "-"
+ },
+ size: 120,
+ enableResizing: true,
+ meta: { excelHeader: "작성일" },
+ },
+ {
+ accessorKey: "createdByName",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="작성자" />,
+ cell: ({ row }) => {
+ const v = row.getValue("createdByName") as string
+ return v ? <span className="text-sm">{v}</span> : <span className="text-muted-foreground">-</span>
+ },
+ size: 80,
+ enableResizing: true,
+ meta: { excelHeader: "작성자" },
+ },
+ {
+ accessorKey: "updatedAt",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="수정일" />,
+ cell: ({ row }) => {
+ const date = row.getValue("updatedAt") as Date
+ return <span className="text-sm">{date ? formatDate(date, "KR") : "-"}</span>
+ },
+ size: 120,
+ enableResizing: true,
+ meta: { excelHeader: "수정일" },
+ },
+ {
+ accessorKey: "updatedByName",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="수정자" />,
+ cell: ({ row }) => {
+ const v = row.getValue("updatedByName") as string
+ return v ? <span className="text-sm">{v}</span> : <span className="text-muted-foreground">-</span>
+ },
+ size: 80,
+ enableResizing: true,
+ meta: { excelHeader: "수정자" },
+ },
+ ]
+
+ // 4) actions
+ const actionsColumn: ColumnDef<GtcClauseTreeView> = {
+ id: "actions",
+ enableHiding: false,
+ cell: function Cell({ row }) {
+ const { data: session } = useSession()
+ const gtcClause = row.original
+ const currentUserId = React.useMemo(
+ () => (session?.user?.id ? Number(session.user.id) : null),
+ [session],
+ )
+
+ const handleEdit = () => setRowAction({ row, type: "update" })
+ const handleDelete = () => setRowAction({ row, type: "delete" })
+ const handleAddSubClause = () => setRowAction({ row, type: "addSubClause" })
+ const handleDuplicate = () => setRowAction({ row, type: "duplicate" })
+
+ return (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button aria-label="Open menu" variant="ghost" className="flex size-8 p-0 data-[state=open]:bg-muted">
+ <Ellipsis className="size-4" aria-hidden="true" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-48">
+ <DropdownMenuItem onSelect={handleEdit}>
+ <Edit className="mr-2 h-4 w-4" />
+ 편집
+ </DropdownMenuItem>
+ <DropdownMenuItem onSelect={handleAddSubClause}>
+ <Plus className="mr-2 h-4 w-4" />
+ 하위 조항 추가
+ </DropdownMenuItem>
+ <DropdownMenuItem onSelect={handleDuplicate}>
+ <Copy className="mr-2 h-4 w-4" />
+ 복제
+ </DropdownMenuItem>
+ <DropdownMenuSeparator />
+ <DropdownMenuItem onSelect={handleDelete} className="text-destructive">
+ <Trash2 className="mr-2 h-4 w-4" />
+ 삭제
+ <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut>
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ )
+ },
+ size: 40,
+ maxSize: 40,
+ }
+
+ // 🔹 그룹 헤더 제거: 평탄화된 컬럼 배열 반환
+ return [
+ selectColumn,
+ ...clauseInfoColumns,
+ ...auditColumns,
+ actionsColumn,
+ ]
+} \ No newline at end of file
diff --git a/lib/gtc-contract/gtc-clauses/table/gtc-clauses-table-floating-bar.tsx b/lib/gtc-contract/gtc-clauses/table/gtc-clauses-table-floating-bar.tsx
new file mode 100644
index 00000000..5b701df6
--- /dev/null
+++ b/lib/gtc-contract/gtc-clauses/table/gtc-clauses-table-floating-bar.tsx
@@ -0,0 +1,239 @@
+"use client"
+
+import * as React from "react"
+import { type Table } from "@tanstack/react-table"
+import {
+ Edit,
+ Trash2,
+ ArrowUpDown,
+ Download,
+ Copy,
+ Wand2,
+ X
+} from "lucide-react"
+
+import { Button } from "@/components/ui/button"
+import { Separator } from "@/components/ui/separator"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+
+import { type GtcClauseTreeView } from "@/db/schema/gtc"
+import { DeleteGtcClausesDialog } from "./delete-gtc-clauses-dialog"
+
+interface GtcClausesTableFloatingBarProps {
+ table: Table<GtcClauseTreeView>
+}
+
+export function GtcClausesTableFloatingBar({ table }: GtcClausesTableFloatingBarProps) {
+ const selectedRows = table.getSelectedRowModel().rows
+ const selectedCount = selectedRows.length
+
+ const [showDeleteDialog, setShowDeleteDialog] = React.useState(false)
+
+ if (selectedCount === 0) return null
+
+ const selectedClauses = selectedRows.map(row => row.original)
+
+ const handleClearSelection = () => {
+ table.toggleAllRowsSelected(false)
+ }
+
+ const handleBulkEdit = () => {
+ console.log("Bulk edit:", selectedClauses)
+ }
+
+ const handleReorder = () => {
+ console.log("Reorder:", selectedClauses)
+ }
+
+ const handleExport = () => {
+ console.log("Export:", selectedClauses)
+ }
+
+ const handleDuplicate = () => {
+ console.log("Duplicate:", selectedClauses)
+ }
+
+ const handleGenerateVariables = () => {
+ console.log("Generate variables:", selectedClauses)
+ }
+
+ const canReorder = selectedClauses.every(clause =>
+ clause.parentId === selectedClauses[0].parentId
+ )
+
+ const hasVariablesMissing = selectedClauses.some(clause =>
+ !clause.hasAllVariableNames
+ )
+
+ return (
+ <div className="fixed bottom-4 left-1/2 z-50 w-fit -translate-x-1/2">
+ <div className="w-fit rounded-lg border bg-card p-2 shadow-2xl animate-in fade-in-0 slide-in-from-bottom-2">
+ <div className="flex items-center gap-2">
+ {/* 선택된 항목 수 */}
+ <div className="flex items-center gap-2 px-2">
+ <span className="text-sm font-medium">
+ {selectedCount}개 선택됨
+ </span>
+ </div>
+
+ <Separator orientation="vertical" className="h-6" />
+
+ {/* 액션 버튼들 */}
+ <div className="flex items-center gap-1">
+ {/* 일괄 수정 */}
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={handleBulkEdit}
+ >
+ <Edit className="h-4 w-4" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>선택한 조항들을 일괄 수정</p>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+
+ {/* 순서 변경 (같은 부모의 조항들만) */}
+ {canReorder && (
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={handleReorder}
+ >
+ <ArrowUpDown className="h-4 w-4" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>선택한 조항들의 순서 변경</p>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ )}
+
+ {/* 복제 */}
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={handleDuplicate}
+ >
+ <Copy className="h-4 w-4" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>선택한 조항들을 복제</p>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+
+ {/* 변수명 생성 (변수가 없는 조항이 있을 때만) */}
+ {hasVariablesMissing && (
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={handleGenerateVariables}
+ >
+ <Wand2 className="h-4 w-4" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>선택한 조항들의 PDFTron 변수명 생성</p>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ )}
+
+ {/* 내보내기 */}
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={handleExport}
+ >
+ <Download className="h-4 w-4" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>선택한 조항들을 Excel로 내보내기</p>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ </div>
+
+ <Separator orientation="vertical" className="h-6" />
+
+ {/* 삭제 버튼 */}
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => setShowDeleteDialog(true)}
+ className="text-destructive hover:text-destructive"
+ >
+ <Trash2 className="h-4 w-4" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>선택한 조항들을 삭제</p>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+
+ <Separator orientation="vertical" className="h-6" />
+
+ {/* 선택 해제 버튼 */}
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={handleClearSelection}
+ >
+ <X className="h-4 w-4" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>선택 해제</p>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ </div>
+ </div>
+
+ {/* 삭제 다이얼로그 */}
+ <DeleteGtcClausesDialog
+ open={showDeleteDialog}
+ onOpenChange={setShowDeleteDialog}
+ gtcClauses={selectedClauses}
+ showTrigger={false}
+ onSuccess={() => {
+ table.toggleAllRowsSelected(false)
+ setShowDeleteDialog(false)
+ }}
+ />
+ </div>
+ )
+} \ 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
new file mode 100644
index 00000000..2a7452ef
--- /dev/null
+++ b/lib/gtc-contract/gtc-clauses/table/gtc-clauses-table-toolbar-actions.tsx
@@ -0,0 +1,195 @@
+"use client"
+
+import * as React from "react"
+import { type Table } from "@tanstack/react-table"
+import {
+ Download,
+ Upload,
+ Settings2,
+ ArrowUpDown,
+ Edit,
+ Eye,
+ FileText,
+ Wand2
+} from "lucide-react"
+
+import { Button } from "@/components/ui/button"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+
+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"
+
+interface GtcClausesTableToolbarActionsProps {
+ table: Table<GtcClauseTreeView>
+ documentId: number
+ document: any
+}
+
+export function GtcClausesTableToolbarActions({
+ table,
+ documentId,
+ document,
+}: 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 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")
+ }
+
+ const handleImportFromExcel = () => {
+ // Excel 가져오기 로직
+ console.log("Import from Excel")
+ }
+
+ const handlePreviewDocument = () => {
+ // ✅ 미리보기 다이얼로그 열기
+ setShowPreviewDialog(true)
+ }
+
+ const handleGenerateDocument = () => {
+ // 최종 문서 생성
+ console.log("Generate final document")
+ }
+
+ const handleReorderClauses = () => {
+ setShowReorderDialog(true)
+ }
+
+ const handleBulkUpdate = () => {
+ setShowBulkUpdateDialog(true)
+ }
+
+ const handleGenerateVariables = () => {
+ setShowGenerateVariablesDialog(true)
+ }
+
+ const handleRefreshTable = () => {
+ // 테이블 새로고침 로직
+ console.log("Refresh table after creation")
+ // table.reset() 또는 상위 컴포넌트의 refetch 함수 호출
+ }
+
+ return (
+ <>
+ <div className="flex items-center gap-2">
+ {/* 조항 추가 버튼 */}
+ <CreateGtcClauseDialog
+ documentId={documentId}
+ document={document}
+ onSuccess={handleRefreshTable}
+ />
+
+ {/* 선택된 항목이 있을 때 표시되는 액션들 */}
+ {selectedCount > 0 && (
+ <>
+ <DeleteGtcClausesDialog
+ gtcClauses={allClauses}
+ onSuccess={() => table.toggleAllRowsSelected(false)}
+
+ />
+ </>
+ )}
+
+ {/* 관리 도구 드롭다운 */}
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button variant="outline" size="sm">
+ <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" />
+ 조항 순서 변경
+ </DropdownMenuItem>
+
+ <DropdownMenuItem onClick={handleGenerateVariables}>
+ <Wand2 className="mr-2 h-4 w-4" />
+ PDFTron 변수명 일괄 생성
+ </DropdownMenuItem> */}
+
+ <DropdownMenuSeparator />
+
+ <DropdownMenuItem onClick={handleExportToExcel}>
+ <Download className="mr-2 h-4 w-4" />
+ Excel로 내보내기
+ </DropdownMenuItem>
+
+ <DropdownMenuItem onClick={handleImportFromExcel}>
+ <Upload className="mr-2 h-4 w-4" />
+ Excel에서 가져오기
+ </DropdownMenuItem>
+
+ <DropdownMenuSeparator />
+
+ <DropdownMenuItem onClick={handlePreviewDocument}>
+ <Eye className="mr-2 h-4 w-4" />
+ 문서 미리보기
+ </DropdownMenuItem>
+
+ {/* <DropdownMenuItem onClick={handleGenerateDocument}>
+ <FileText className="mr-2 h-4 w-4" />
+ 최종 문서 생성
+ </DropdownMenuItem> */}
+ </DropdownMenuContent>
+ </DropdownMenu>
+
+ {/* 조건부로 표시되는 다이얼로그들 */}
+ {showReorderDialog && (
+ <div>
+ {/* ReorderGtcClausesDialog 컴포넌트가 여기에 올 예정 */}
+ </div>
+ )}
+
+ {showBulkUpdateDialog && (
+ <div>
+ {/* BulkUpdateGtcClausesDialog 컴포넌트가 여기에 올 예정 */}
+ </div>
+ )}
+
+ {showGenerateVariablesDialog && (
+ <div>
+ {/* GenerateVariableNamesDialog 컴포넌트가 여기에 올 예정 */}
+ </div>
+ )}
+ </div>
+
+ {/* ✅ 미리보기 다이얼로그 */}
+ <PreviewDocumentDialog
+ open={showPreviewDialog}
+ onOpenChange={setShowPreviewDialog}
+ clauses={allClauses}
+ document={document}
+ onExport={() => {
+ console.log("Export from preview dialog")
+ }}
+ />
+ </>
+ )
+} \ No newline at end of file
diff --git a/lib/gtc-contract/gtc-clauses/table/markdown-image-editor.tsx b/lib/gtc-contract/gtc-clauses/table/markdown-image-editor.tsx
new file mode 100644
index 00000000..422d8475
--- /dev/null
+++ b/lib/gtc-contract/gtc-clauses/table/markdown-image-editor.tsx
@@ -0,0 +1,360 @@
+"use client"
+
+import * as React from "react"
+import { Button } from "@/components/ui/button"
+import { Textarea } from "@/components/ui/textarea"
+import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
+import { Upload, X, Image as ImageIcon, Eye, EyeOff } from "lucide-react"
+import { toast } from "sonner"
+import { cn } from "@/lib/utils"
+
+interface ClauseImage {
+ id: string
+ url: string
+ fileName: string
+ size: number
+}
+
+interface MarkdownImageEditorProps {
+ content: string
+ images: ClauseImage[]
+ onChange: (content: string, images: ClauseImage[]) => void
+ placeholder?: string
+ rows?: number
+ className?: string
+}
+
+export function MarkdownImageEditor({
+ content,
+ images,
+ onChange,
+ placeholder = "텍스트를 입력하고, 이미지를 삽입하려면 '이미지 추가' 버튼을 클릭하세요.",
+ rows = 6,
+ className
+}: MarkdownImageEditorProps) {
+ const [imageUploadOpen, setImageUploadOpen] = React.useState(false)
+ const [showPreview, setShowPreview] = React.useState(false)
+ const [uploading, setUploading] = React.useState(false)
+ const textareaRef = React.useRef<HTMLTextAreaElement>(null)
+
+ // 이미지 업로드 핸들러
+ const handleImageUpload = async (file: File) => {
+ if (!file.type.startsWith('image/')) {
+ toast.error('이미지 파일만 업로드 가능합니다.')
+ return
+ }
+
+ if (file.size > 5 * 1024 * 1024) { // 5MB 제한
+ toast.error('파일 크기는 5MB 이하여야 합니다.')
+ return
+ }
+
+ setUploading(true)
+ try {
+ // 실제 구현에서는 서버로 업로드
+ const uploadedUrl = await uploadImage(file)
+ const imageId = `image${Date.now()}`
+
+ // 이미지 배열에 추가
+ const newImages = [...images, {
+ id: imageId,
+ url: uploadedUrl,
+ fileName: file.name,
+ size: file.size,
+ }]
+
+ // 커서 위치에 이미지 참조 삽입
+ const imageRef = `![${imageId}]`
+ const textarea = textareaRef.current
+
+ if (textarea) {
+ const start = textarea.selectionStart
+ const end = textarea.selectionEnd
+ const newContent = content.substring(0, start) + imageRef + content.substring(end)
+
+ onChange(newContent, newImages)
+
+ // 커서 위치를 이미지 참조 뒤로 이동
+ setTimeout(() => {
+ textarea.focus()
+ textarea.setSelectionRange(start + imageRef.length, start + imageRef.length)
+ }, 0)
+ } else {
+ // 텍스트 끝에 추가
+ const newContent = content + (content ? '\n\n' : '') + imageRef
+ onChange(newContent, newImages)
+ }
+
+ toast.success('이미지가 추가되었습니다.')
+ setImageUploadOpen(false)
+ } catch (error) {
+ toast.error('이미지 업로드에 실패했습니다.')
+ console.error('Image upload error:', error)
+ } finally {
+ setUploading(false)
+ }
+ }
+
+ // 이미지 제거
+ const removeImage = (imageId: string) => {
+ // 이미지 배열에서 제거
+ const newImages = images.filter(img => img.id !== imageId)
+
+ // 텍스트에서 이미지 참조 제거
+ const imageRef = `![${imageId}]`
+ const newContent = content.replace(new RegExp(`\\!\\[${imageId}\\]`, 'g'), '')
+
+ onChange(newContent, newImages)
+ toast.success('이미지가 제거되었습니다.')
+ }
+
+ // 커서 위치에 텍스트 삽입
+ const insertAtCursor = (text: string) => {
+ const textarea = textareaRef.current
+ if (!textarea) return
+
+ const start = textarea.selectionStart
+ const end = textarea.selectionEnd
+ const newContent = content.substring(0, start) + text + content.substring(end)
+
+ onChange(newContent, images)
+
+ // 커서 위치 조정
+ setTimeout(() => {
+ textarea.focus()
+ textarea.setSelectionRange(start + text.length, start + text.length)
+ }, 0)
+ }
+
+ return (
+ <div className={cn("space-y-3", className)}>
+ {/* 에디터 툴바 */}
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-2">
+ <Button
+ type="button"
+ variant="outline"
+ size="sm"
+ onClick={() => setImageUploadOpen(true)}
+ disabled={uploading}
+ >
+ <Upload className="h-4 w-4 mr-1" />
+ {uploading ? '업로드 중...' : '이미지 추가'}
+ </Button>
+
+ <Button
+ type="button"
+ variant="outline"
+ size="sm"
+ onClick={() => setShowPreview(!showPreview)}
+ >
+ {showPreview ? (
+ <>
+ <EyeOff className="h-4 w-4 mr-1" />
+ 편집
+ </>
+ ) : (
+ <>
+ <Eye className="h-4 w-4 mr-1" />
+ 미리보기
+ </>
+ )}
+ </Button>
+ </div>
+
+ {images.length > 0 && (
+ <span className="text-xs text-muted-foreground">
+ {images.length}개 이미지 첨부됨
+ </span>
+ )}
+ </div>
+
+ {/* 에디터 영역 */}
+ {showPreview ? (
+ /* 미리보기 모드 */
+ <div className="border rounded-lg p-4 bg-muted/10 min-h-[200px]">
+ <div className="space-y-3">
+ {renderMarkdownPreview(content, images)}
+ </div>
+ </div>
+ ) : (
+ /* 편집 모드 */
+ <div className="relative">
+ <Textarea
+ ref={textareaRef}
+ value={content}
+ onChange={(e) => onChange(e.target.value, images)}
+ placeholder={placeholder}
+ rows={rows}
+ className="font-mono text-sm resize-none"
+ />
+
+ {/* 이미지 참조 안내 */}
+ {content.includes('![image') && (
+ <div className="absolute bottom-2 right-2 text-xs text-muted-foreground bg-background/80 px-2 py-1 rounded">
+ ![imageXXX] = 이미지 삽입 위치
+ </div>
+ )}
+ </div>
+ )}
+
+ {/* 첨부된 이미지 목록 */}
+ {images.length > 0 && !showPreview && (
+ <div className="space-y-2">
+ <div className="text-sm font-medium">첨부된 이미지:</div>
+ <div className="grid grid-cols-2 md:grid-cols-4 gap-2">
+ {images.map((img) => (
+ <div key={img.id} className="relative group">
+ <img
+ src={img.url}
+ alt={img.fileName}
+ className="w-full h-16 object-cover rounded border"
+ />
+ <div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity rounded flex items-center justify-center">
+ <div className="text-white text-xs text-center">
+ <div className="font-medium">![{img.id}]</div>
+ <div className="truncate max-w-16" title={img.fileName}>
+ {img.fileName}
+ </div>
+ </div>
+ </div>
+ <Button
+ type="button"
+ variant="destructive"
+ size="sm"
+ className="absolute -top-1 -right-1 h-5 w-5 p-0 opacity-0 group-hover:opacity-100"
+ onClick={() => removeImage(img.id)}
+ >
+ <X className="h-3 w-3" />
+ </Button>
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+
+ {/* 이미지 업로드 다이얼로그 */}
+ <Dialog open={imageUploadOpen} onOpenChange={setImageUploadOpen}>
+ <DialogContent className="max-w-md">
+ <DialogHeader>
+ <DialogTitle>이미지 추가</DialogTitle>
+ </DialogHeader>
+ <div className="space-y-4">
+ <div className="border-2 border-dashed border-muted-foreground/25 rounded-lg p-6">
+ <div className="flex flex-col items-center justify-center text-center">
+ <ImageIcon className="h-8 w-8 text-muted-foreground mb-2" />
+ <p className="text-sm text-muted-foreground mb-4">
+ 이미지를 선택하거나 드래그해서 업로드하세요
+ </p>
+ <input
+ type="file"
+ accept="image/*"
+ onChange={(e) => {
+ const file = e.target.files?.[0]
+ if (file) handleImageUpload(file)
+ }}
+ className="hidden"
+ id="image-upload"
+ disabled={uploading}
+ />
+ <label
+ htmlFor="image-upload"
+ className={cn(
+ "cursor-pointer inline-flex items-center gap-2 text-sm bg-primary text-primary-foreground hover:bg-primary/90 px-4 py-2 rounded-md",
+ uploading && "opacity-50 cursor-not-allowed"
+ )}
+ >
+ <Upload className="h-4 w-4" />
+ 파일 선택
+ </label>
+ </div>
+ </div>
+ <div className="text-xs text-muted-foreground space-y-1">
+ <div>• 지원 형식: JPG, PNG, GIF, WebP</div>
+ <div>• 최대 크기: 5MB</div>
+ <div>• 현재 커서 위치에 삽입됩니다</div>
+ </div>
+ </div>
+ </DialogContent>
+ </Dialog>
+ </div>
+ )
+}
+
+// 마크다운 미리보기 렌더링
+function renderMarkdownPreview(content: string, images: ClauseImage[]) {
+ if (!content.trim()) {
+ return (
+ <p className="text-muted-foreground italic">
+ 내용을 입력하세요...
+ </p>
+ )
+ }
+
+ const parts = content.split(/(\![a-zA-Z0-9_]+\])/)
+
+ return parts.map((part, index) => {
+ // 이미지 참조인지 확인
+ const imageMatch = part.match(/^!\[(.+)\]$/)
+
+ if (imageMatch) {
+ const imageId = imageMatch[1]
+ const image = images.find(img => img.id === imageId)
+
+ if (image) {
+ return (
+ <div key={index} className="my-3">
+ <img
+ src={image.url}
+ alt={image.fileName}
+ className="max-w-full h-auto rounded border shadow-sm"
+ style={{ maxHeight: '400px' }}
+ />
+ <p className="text-xs text-muted-foreground mt-1 text-center">
+ {image.fileName} ({formatFileSize(image.size)})
+ </p>
+ </div>
+ )
+ } else {
+ return (
+ <div key={index} className="my-2 p-2 bg-yellow-50 border border-yellow-200 rounded">
+ <p className="text-xs text-yellow-700">
+ ⚠️ 이미지를 찾을 수 없음: {part}
+ </p>
+ </div>
+ )
+ }
+ } else {
+ // 일반 텍스트 (줄바꿈 처리)
+ return part.split('\n').map((line, lineIndex) => (
+ <p key={`${index}-${lineIndex}`} className="text-sm">
+ {line || '\u00A0'} {/* 빈 줄 처리 */}
+ </p>
+ ))
+ }
+ })
+}
+
+// 파일 크기 포맷팅
+function formatFileSize(bytes: number): string {
+ if (bytes === 0) return '0 Bytes'
+
+ const k = 1024
+ const sizes = ['Bytes', 'KB', 'MB', 'GB']
+ const i = Math.floor(Math.log(bytes) / Math.log(k))
+
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
+}
+
+// 임시 이미지 업로드 함수 (실제 구현 필요)
+async function uploadImage(file: File): Promise<string> {
+ // TODO: 실제 서버 업로드 로직 구현
+ // 현재는 임시로 ObjectURL 반환
+ return new Promise((resolve) => {
+ const reader = new FileReader()
+ reader.onload = (e) => {
+ resolve(e.target?.result as string)
+ }
+ reader.readAsDataURL(file)
+ })
+} \ 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
new file mode 100644
index 00000000..29ab1b5a
--- /dev/null
+++ b/lib/gtc-contract/gtc-clauses/table/preview-document-dialog.tsx
@@ -0,0 +1,189 @@
+"use client"
+
+import * as React from "react"
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"
+import { Button } from "@/components/ui/button"
+import { Badge } from "@/components/ui/badge"
+import { Separator } from "@/components/ui/separator"
+
+import {
+ Eye,
+ Download,
+ Loader2,
+ FileText,
+ RefreshCw,
+ Settings
+} from "lucide-react"
+import { toast } from "sonner"
+
+import { type GtcClauseTreeView } from "@/db/schema/gtc"
+import { ClausePreviewViewer } from "./clause-preview-viewer"
+
+interface PreviewDocumentDialogProps
+ extends React.ComponentPropsWithRef<typeof Dialog> {
+ clauses: GtcClauseTreeView[]
+ document: any
+ onExport?: () => void
+}
+
+export function PreviewDocumentDialog({
+ clauses,
+ document,
+ onExport,
+ ...props
+}: PreviewDocumentDialogProps) {
+ const [isGenerating, setIsGenerating] = React.useState(false)
+ const [documentGenerated, setDocumentGenerated] = React.useState(false)
+ const [viewerInstance, setViewerInstance] = React.useState<any>(null)
+
+ // 조항 통계 계산
+ const stats = React.useMemo(() => {
+ const activeClausesCount = clauses.filter(c => c.isActive !== false).length
+ const topLevelCount = clauses.filter(c => !c.parentId && c.isActive !== false).length
+ const hasContentCount = clauses.filter(c => c.content && c.isActive !== false).length
+
+ return {
+ total: activeClausesCount,
+ topLevel: topLevelCount,
+ withContent: hasContentCount,
+ withoutContent: activeClausesCount - hasContentCount
+ }
+ }, [clauses])
+
+ const handleGeneratePreview = async () => {
+ setIsGenerating(true)
+ try {
+ // 잠시 후 문서 생성 완료로 설정 (실제로는 뷰어에서 처리)
+ setTimeout(() => {
+ setDocumentGenerated(true)
+ setIsGenerating(false)
+ toast.success("문서 미리보기가 생성되었습니다.")
+ }, 1500)
+ } catch (error) {
+ setIsGenerating(false)
+ toast.error("문서 생성 중 오류가 발생했습니다.")
+ }
+ }
+
+ const handleExportDocument = () => {
+ if (viewerInstance) {
+ // PDFTron의 다운로드 기능 실행
+ viewerInstance.UI.downloadPdf({
+ filename: `${document?.title || 'GTC계약서'}_미리보기.pdf`
+ })
+ toast.success("문서가 다운로드됩니다.")
+ }
+ }
+
+ const handleRegenerateDocument = () => {
+ setDocumentGenerated(false)
+ handleGeneratePreview()
+ }
+
+ React.useEffect(() => {
+ // 다이얼로그가 열릴 때 자동으로 미리보기 생성
+ if (props.open && !documentGenerated && !isGenerating) {
+ handleGeneratePreview()
+ }
+ }, [props.open])
+
+ return (
+ <Dialog {...props}>
+ <DialogContent className="max-w-7xl h-[90vh] flex flex-col">
+ <DialogHeader className="flex-shrink-0">
+ <DialogTitle className="flex items-center gap-2">
+ <Eye className="h-5 w-5" />
+ 문서 미리보기
+ </DialogTitle>
+ <DialogDescription>
+ 현재 조항들을 기반으로 생성된 문서를 미리보기합니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ {/* 문서 정보 및 통계 */}
+ <div className="flex-shrink-0 p-4 bg-muted/30 rounded-lg">
+ <div className="flex items-center justify-between mb-3">
+ <div className="flex items-center gap-2">
+ <FileText className="h-4 w-4" />
+ <span className="font-medium">{document?.title || 'GTC 계약서'}</span>
+ <Badge variant="outline">{stats.total}개 조항</Badge>
+ </div>
+ <div className="flex items-center gap-2">
+ {documentGenerated && (
+ <>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleRegenerateDocument}
+ disabled={isGenerating}
+ >
+ <RefreshCw className="mr-2 h-3 w-3" />
+ 재생성
+ </Button>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleExportDocument}
+ >
+ <Download className="mr-2 h-3 w-3" />
+ PDF 다운로드
+ </Button>
+ </>
+ )}
+ </div>
+ </div>
+
+ <div className="grid grid-cols-4 gap-4 text-sm">
+ <div className="text-center p-2 bg-background rounded">
+ <div className="font-medium text-lg">{stats.total}</div>
+ <div className="text-muted-foreground">총 조항</div>
+ </div>
+ <div className="text-center p-2 bg-background rounded">
+ <div className="font-medium text-lg">{stats.topLevel}</div>
+ <div className="text-muted-foreground">최상위 조항</div>
+ </div>
+ <div className="text-center p-2 bg-background rounded">
+ <div className="font-medium text-lg text-green-600">{stats.withContent}</div>
+ <div className="text-muted-foreground">내용 있음</div>
+ </div>
+ <div className="text-center p-2 bg-background rounded">
+ <div className="font-medium text-lg text-amber-600">{stats.withoutContent}</div>
+ <div className="text-muted-foreground">제목만</div>
+ </div>
+ </div>
+ </div>
+
+ <Separator />
+
+ {/* PDFTron 뷰어 영역 */}
+ <div className="flex-1 min-h-0 relative">
+ {isGenerating ? (
+ <div className="absolute inset-0 flex flex-col items-center justify-center bg-background">
+ <Loader2 className="h-8 w-8 animate-spin text-primary mb-4" />
+ <p className="text-lg font-medium mb-2">문서 생성 중...</p>
+ <p className="text-sm text-muted-foreground">
+ {stats.total}개의 조항을 배치하고 있습니다.
+ </p>
+ </div>
+ ) : documentGenerated ? (
+ <ClausePreviewViewer
+ clauses={clauses}
+ document={document}
+ instance={viewerInstance}
+ setInstance={setViewerInstance}
+ />
+ ) : (
+ <div className="absolute inset-0 flex flex-col items-center justify-center bg-muted/10">
+ <FileText className="h-12 w-12 text-muted-foreground mb-4" />
+ <p className="text-lg font-medium mb-2">문서 미리보기 준비 중</p>
+ <Button onClick={handleGeneratePreview} disabled={isGenerating}>
+ <Eye className="mr-2 h-4 w-4" />
+ 미리보기 생성
+ </Button>
+ </div>
+ )}
+ </div>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/gtc-contract/gtc-clauses/table/reorder-gtc-clauses-dialog.tsx b/lib/gtc-contract/gtc-clauses/table/reorder-gtc-clauses-dialog.tsx
new file mode 100644
index 00000000..7d0180df
--- /dev/null
+++ b/lib/gtc-contract/gtc-clauses/table/reorder-gtc-clauses-dialog.tsx
@@ -0,0 +1,540 @@
+"use client"
+
+import * as React from "react"
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog"
+import { Button } from "@/components/ui/button"
+import { Textarea } from "@/components/ui/textarea"
+import { Badge } from "@/components/ui/badge"
+import { ScrollArea } from "@/components/ui/scroll-area"
+
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import {
+ Loader,
+ ArrowUpDown,
+ ArrowUp,
+ ArrowDown,
+ GripVertical,
+ RotateCcw,
+ Info
+} from "lucide-react"
+import { toast } from "sonner"
+import { cn } from "@/lib/utils"
+
+import { reorderGtcClausesSchema, type ReorderGtcClausesSchema } from "@/lib/gtc-contract/gtc-clauses/validations"
+import { reorderGtcClauses, getGtcClausesTree } from "@/lib/gtc-contract/gtc-clauses/service"
+import { type GtcClauseTreeView } from "@/db/schema/gtc"
+import { useSession } from "next-auth/react"
+
+interface ReorderGtcClausesDialogProps
+ extends React.ComponentPropsWithRef<typeof Dialog> {
+ documentId: number
+ onSuccess?: () => void
+}
+
+interface ClauseWithOrder extends GtcClauseTreeView {
+ newSortOrder: number
+ hasChanges: boolean
+ children?: ClauseWithOrder[]
+}
+
+export function ReorderGtcClausesDialog({
+ documentId,
+ onSuccess,
+ ...props
+}: ReorderGtcClausesDialogProps) {
+ const [isUpdatePending, startUpdateTransition] = React.useTransition()
+ const [clauses, setClauses] = React.useState<ClauseWithOrder[]>([])
+ const [originalClauses, setOriginalClauses] = React.useState<ClauseWithOrder[]>([])
+ const [isLoading, setIsLoading] = React.useState(false)
+ const [draggedItem, setDraggedItem] = React.useState<ClauseWithOrder | null>(null)
+ const { data: session } = useSession()
+
+ const currentUserId = React.useMemo(() => {
+ return session?.user?.id ? Number(session.user.id) : null
+ }, [session])
+
+ const form = useForm<ReorderGtcClausesSchema>({
+ resolver: zodResolver(reorderGtcClausesSchema),
+ defaultValues: {
+ clauses: [],
+ editReason: "",
+ },
+ })
+
+ // 조항 데이터 로드
+ React.useEffect(() => {
+ if (props.open && documentId) {
+ loadClauses()
+ }
+ }, [props.open, documentId])
+
+ const loadClauses = async () => {
+ setIsLoading(true)
+ try {
+ const tree = await getGtcClausesTree(documentId)
+ const flatClauses = flattenTreeWithOrder(tree)
+ setClauses(flatClauses)
+ setOriginalClauses(JSON.parse(JSON.stringify(flatClauses))) // 깊은 복사
+ } catch (error) {
+ console.error("Error loading clauses:", error)
+ toast.error("조항 목록을 불러오는 중 오류가 발생했습니다.")
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ // 트리를 평면 배열로 변환하면서 순서 정보 추가
+ const flattenTreeWithOrder = (tree: any[]): ClauseWithOrder[] => {
+ const result: ClauseWithOrder[] = []
+
+ function traverse(nodes: any[], parentId: number | null = null) {
+ nodes.forEach((node, index) => {
+ const clauseWithOrder: ClauseWithOrder = {
+ ...node,
+ newSortOrder: parseFloat(node.sortOrder),
+ hasChanges: false,
+ }
+
+ result.push(clauseWithOrder)
+
+ if (node.children && node.children.length > 0) {
+ traverse(node.children, node.id)
+ }
+ })
+ }
+
+ traverse(tree)
+ return result
+ }
+
+ // 조항 순서 변경
+ const moveClause = (clauseId: number, direction: 'up' | 'down') => {
+ setClauses(prev => {
+ const newClauses = [...prev]
+ const clauseIndex = newClauses.findIndex(c => c.id === clauseId)
+
+ if (clauseIndex === -1) return prev
+
+ const clause = newClauses[clauseIndex]
+
+ // 같은 부모를 가진 형제 조항들 찾기
+ const siblings = newClauses.filter(c => c.parentId === clause.parentId)
+ const siblingIndex = siblings.findIndex(c => c.id === clauseId)
+
+ if (direction === 'up' && siblingIndex > 0) {
+ // 위로 이동
+ const targetSibling = siblings[siblingIndex - 1]
+ const tempOrder = clause.newSortOrder
+ clause.newSortOrder = targetSibling.newSortOrder
+ targetSibling.newSortOrder = tempOrder
+
+ clause.hasChanges = true
+ targetSibling.hasChanges = true
+ } else if (direction === 'down' && siblingIndex < siblings.length - 1) {
+ // 아래로 이동
+ const targetSibling = siblings[siblingIndex + 1]
+ const tempOrder = clause.newSortOrder
+ clause.newSortOrder = targetSibling.newSortOrder
+ targetSibling.newSortOrder = tempOrder
+
+ clause.hasChanges = true
+ targetSibling.hasChanges = true
+ }
+
+ // sortOrder로 정렬
+ return newClauses.sort((a, b) => {
+ if (a.parentId !== b.parentId) {
+ // 부모가 다르면 부모 기준으로 정렬
+ return (a.parentId || 0) - (b.parentId || 0)
+ }
+ return a.newSortOrder - b.newSortOrder
+ })
+ })
+ }
+
+ // 변경사항 초기화
+ const resetChanges = () => {
+ setClauses(JSON.parse(JSON.stringify(originalClauses)))
+ toast.success("변경사항이 초기화되었습니다.")
+ }
+
+ // 드래그 앤 드롭 핸들러
+ const handleDragStart = (e: React.DragEvent, clause: ClauseWithOrder) => {
+ setDraggedItem(clause)
+ e.dataTransfer.effectAllowed = 'move'
+ }
+
+ const handleDragOver = (e: React.DragEvent) => {
+ e.preventDefault()
+ e.dataTransfer.dropEffect = 'move'
+ }
+
+ const handleDrop = (e: React.DragEvent, targetClause: ClauseWithOrder) => {
+ e.preventDefault()
+
+ if (!draggedItem || draggedItem.id === targetClause.id) {
+ setDraggedItem(null)
+ return
+ }
+
+ // 같은 부모를 가진 경우에만 순서 변경 허용
+ if (draggedItem.parentId === targetClause.parentId) {
+ setClauses(prev => {
+ const newClauses = [...prev]
+ const draggedIndex = newClauses.findIndex(c => c.id === draggedItem.id)
+ const targetIndex = newClauses.findIndex(c => c.id === targetClause.id)
+
+ if (draggedIndex !== -1 && targetIndex !== -1) {
+ const tempOrder = newClauses[draggedIndex].newSortOrder
+ newClauses[draggedIndex].newSortOrder = newClauses[targetIndex].newSortOrder
+ newClauses[targetIndex].newSortOrder = tempOrder
+
+ newClauses[draggedIndex].hasChanges = true
+ newClauses[targetIndex].hasChanges = true
+ }
+
+ return newClauses.sort((a, b) => {
+ if (a.parentId !== b.parentId) {
+ return (a.parentId || 0) - (b.parentId || 0)
+ }
+ return a.newSortOrder - b.newSortOrder
+ })
+ })
+ }
+
+ setDraggedItem(null)
+ }
+
+ async function onSubmit(data: ReorderGtcClausesSchema) {
+ startUpdateTransition(async () => {
+ if (!currentUserId) {
+ toast.error("로그인이 필요합니다")
+ return
+ }
+
+ // 변경된 조항들만 필터링
+ const changedClauses = clauses.filter(c => c.hasChanges).map(c => ({
+ id: c.id,
+ sortOrder: c.newSortOrder,
+ parentId: c.parentId,
+ depth: c.depth,
+ fullPath: c.fullPath,
+ }))
+
+ if (changedClauses.length === 0) {
+ toast.error("변경된 조항이 없습니다.")
+ return
+ }
+
+ try {
+ const result = await reorderGtcClauses({
+ clauses: changedClauses,
+ editReason: data.editReason || "조항 순서 변경",
+ updatedById: currentUserId
+ })
+
+ if (result.error) {
+ toast.error(`에러: ${result.error}`)
+ return
+ }
+
+ form.reset()
+ props.onOpenChange?.(false)
+ toast.success(`${changedClauses.length}개 조항의 순서가 변경되었습니다.`)
+ onSuccess?.()
+ } catch (error) {
+ toast.error("조항 순서 변경 중 오류가 발생했습니다.")
+ }
+ })
+ }
+
+ function handleDialogOpenChange(nextOpen: boolean) {
+ if (!nextOpen) {
+ form.reset()
+ setClauses([])
+ setOriginalClauses([])
+ }
+ props.onOpenChange?.(nextOpen)
+ }
+
+ const changedCount = clauses.filter(c => c.hasChanges).length
+ const groupedClauses = groupClausesByParent(clauses)
+
+ return (
+ <Dialog {...props} onOpenChange={handleDialogOpenChange}>
+ <DialogContent className="max-w-4xl h-[90vh] flex flex-col">
+ <DialogHeader className="flex-shrink-0">
+ <DialogTitle className="flex items-center gap-2">
+ <ArrowUpDown className="h-5 w-5" />
+ 조항 순서 변경
+ </DialogTitle>
+ <DialogDescription>
+ 드래그 앤 드롭 또는 화살표 버튼으로 조항의 순서를 변경하세요. 같은 계층 내에서만 순서 변경이 가능합니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ {/* 상태 정보 */}
+ <div className="flex items-center justify-between p-3 bg-muted/50 rounded-lg flex-shrink-0">
+ <div className="flex items-center gap-4 text-sm">
+ <div className="flex items-center gap-2">
+ <Info className="h-4 w-4 text-muted-foreground" />
+ <span>총 {clauses.length}개 조항</span>
+ </div>
+ {changedCount > 0 && (
+ <Badge variant="default">
+ {changedCount}개 변경됨
+ </Badge>
+ )}
+ </div>
+
+ <div className="flex gap-2">
+ {changedCount > 0 && (
+ <Button
+ type="button"
+ variant="outline"
+ size="sm"
+ onClick={resetChanges}
+ >
+ <RotateCcw className="mr-2 h-4 w-4" />
+ 초기화
+ </Button>
+ )}
+ </div>
+ </div>
+
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col flex-1 min-h-0">
+ {/* 조항 목록 */}
+ <ScrollArea className="flex-1 border rounded-lg">
+ {isLoading ? (
+ <div className="flex items-center justify-center h-32">
+ <Loader className="h-6 w-6 animate-spin" />
+ <span className="ml-2">조항을 불러오는 중...</span>
+ </div>
+ ) : (
+ <div className="p-4 space-y-2">
+ {Object.entries(groupedClauses).map(([parentInfo, clauses]) => (
+ <ClauseGroup
+ key={parentInfo}
+ parentInfo={parentInfo}
+ clauses={clauses}
+ onMove={moveClause}
+ onDragStart={handleDragStart}
+ onDragOver={handleDragOver}
+ onDrop={handleDrop}
+ />
+ ))}
+ </div>
+ )}
+ </ScrollArea>
+
+ {/* 편집 사유 */}
+ <div className="mt-4 flex-shrink-0">
+ <FormField
+ control={form.control}
+ name="editReason"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>편집 사유 (선택사항)</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="순서 변경 사유를 입력하세요..."
+ {...field}
+ rows={2}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ <DialogFooter className="flex-shrink-0 border-t pt-4 mt-4">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => props.onOpenChange?.(false)}
+ disabled={isUpdatePending}
+ >
+ Cancel
+ </Button>
+ <Button
+ type="submit"
+ disabled={isUpdatePending || changedCount === 0}
+ >
+ {isUpdatePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ <ArrowUpDown className="mr-2 h-4 w-4" />
+ Apply Changes ({changedCount})
+ </Button>
+ </DialogFooter>
+ </form>
+ </Form>
+ </DialogContent>
+ </Dialog>
+ )
+}
+
+// 조항 그룹 컴포넌트
+interface ClauseGroupProps {
+ parentInfo: string
+ clauses: ClauseWithOrder[]
+ onMove: (clauseId: number, direction: 'up' | 'down') => void
+ onDragStart: (e: React.DragEvent, clause: ClauseWithOrder) => void
+ onDragOver: (e: React.DragEvent) => void
+ onDrop: (e: React.DragEvent, clause: ClauseWithOrder) => void
+}
+
+function ClauseGroup({
+ parentInfo,
+ clauses,
+ onMove,
+ onDragStart,
+ onDragOver,
+ onDrop
+}: ClauseGroupProps) {
+ const isRootLevel = parentInfo === "root"
+
+ return (
+ <div className="space-y-1">
+ {!isRootLevel && (
+ <div className="text-sm font-medium text-muted-foreground px-2 py-1 bg-muted/30 rounded">
+ {parentInfo}
+ </div>
+ )}
+
+ {clauses.map((clause, index) => (
+ <ClauseItem
+ key={clause.id}
+ clause={clause}
+ index={index}
+ isFirst={index === 0}
+ isLast={index === clauses.length - 1}
+ onMove={onMove}
+ onDragStart={onDragStart}
+ onDragOver={onDragOver}
+ onDrop={onDrop}
+ />
+ ))}
+ </div>
+ )
+}
+
+// 개별 조항 컴포넌트
+interface ClauseItemProps {
+ clause: ClauseWithOrder
+ index: number
+ isFirst: boolean
+ isLast: boolean
+ onMove: (clauseId: number, direction: 'up' | 'down') => void
+ onDragStart: (e: React.DragEvent, clause: ClauseWithOrder) => void
+ onDragOver: (e: React.DragEvent) => void
+ onDrop: (e: React.DragEvent, clause: ClauseWithOrder) => void
+}
+
+function ClauseItem({
+ clause,
+ isFirst,
+ isLast,
+ onMove,
+ onDragStart,
+ onDragOver,
+ onDrop
+}: ClauseItemProps) {
+ return (
+ <div
+ className={cn(
+ "flex items-center gap-2 p-3 border rounded-lg bg-background",
+ clause.hasChanges && "border-blue-300 bg-blue-50",
+ "hover:bg-muted/50 transition-colors"
+ )}
+ draggable
+ onDragStart={(e) => onDragStart(e, clause)}
+ onDragOver={onDragOver}
+ onDrop={(e) => onDrop(e, clause)}
+ >
+ {/* 드래그 핸들 */}
+ <GripVertical className="h-4 w-4 text-muted-foreground cursor-grab" />
+
+ {/* 조항 정보 */}
+ <div className="flex-1 min-w-0">
+ <div className="flex items-center gap-2 mb-1">
+ <Badge variant="outline" className="text-xs">
+ {clause.itemNumber}
+ </Badge>
+ <span className="font-medium truncate">{clause.subtitle}</span>
+ {clause.hasChanges && (
+ <Badge variant="default" className="text-xs">
+ 변경됨
+ </Badge>
+ )}
+ </div>
+ {clause.content && (
+ <p className="text-xs text-muted-foreground line-clamp-1">
+ {clause.content.substring(0, 100)}...
+ </p>
+ )}
+ </div>
+
+ {/* 순서 변경 버튼 */}
+ <div className="flex gap-1">
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ className="h-8 w-8 p-0"
+ disabled={isFirst}
+ onClick={() => onMove(clause.id, 'up')}
+ >
+ <ArrowUp className="h-4 w-4" />
+ </Button>
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ className="h-8 w-8 p-0"
+ disabled={isLast}
+ onClick={() => onMove(clause.id, 'down')}
+ >
+ <ArrowDown className="h-4 w-4" />
+ </Button>
+ </div>
+ </div>
+ )
+}
+
+// 조항을 부모별로 그룹화
+function groupClausesByParent(clauses: ClauseWithOrder[]): Record<string, ClauseWithOrder[]> {
+ const groups: Record<string, ClauseWithOrder[]> = {}
+
+ clauses.forEach(clause => {
+ const parentKey = clause.parentId
+ ? `${clause.parentItemNumber || 'Unknown'} - ${clause.parentSubtitle || 'Unknown'}`
+ : "root"
+
+ if (!groups[parentKey]) {
+ groups[parentKey] = []
+ }
+ groups[parentKey].push(clause)
+ })
+
+ // 각 그룹 내에서 sortOrder로 정렬
+ Object.keys(groups).forEach(key => {
+ groups[key].sort((a, b) => a.newSortOrder - b.newSortOrder)
+ })
+
+ return groups
+} \ No newline at end of file
diff --git a/lib/gtc-contract/gtc-clauses/table/update-gtc-clause-sheet.tsx b/lib/gtc-contract/gtc-clauses/table/update-gtc-clause-sheet.tsx
new file mode 100644
index 00000000..aae0396b
--- /dev/null
+++ b/lib/gtc-contract/gtc-clauses/table/update-gtc-clause-sheet.tsx
@@ -0,0 +1,361 @@
+"use client"
+
+import * as React from "react"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { Loader, Info } from "lucide-react"
+import { useForm } from "react-hook-form"
+import { toast } from "sonner"
+
+import {
+ Sheet,
+ SheetClose,
+ SheetContent,
+ SheetDescription,
+ SheetFooter,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Textarea } from "@/components/ui/textarea"
+import { Badge } from "@/components/ui/badge"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+ FormDescription,
+} from "@/components/ui/form"
+
+import { type GtcClauseTreeView } from "@/db/schema/gtc"
+import { updateGtcClauseSchema, type UpdateGtcClauseSchema } from "@/lib/gtc-contract/gtc-clauses/validations"
+import { updateGtcClause } from "@/lib/gtc-contract/gtc-clauses/service"
+import { useSession } from "next-auth/react"
+import { MarkdownImageEditor } from "./markdown-image-editor"
+
+interface ClauseImage {
+ id: string
+ url: string
+ fileName: string
+ size: number
+ savedName?: string
+ mimeType?: string
+ width?: number
+ height?: number
+ hash?: string
+}
+
+
+export interface UpdateGtcClauseSheetProps
+ extends React.ComponentPropsWithRef<typeof Sheet> {
+ gtcClause: GtcClauseTreeView | null
+ documentId: number
+}
+
+export function UpdateGtcClauseSheet({ gtcClause, documentId, ...props }: UpdateGtcClauseSheetProps) {
+ const [isUpdatePending, startUpdateTransition] = React.useTransition()
+ const { data: session } = useSession()
+ const [images, setImages] = React.useState<ClauseImage[]>([])
+ const [rawFiles, setRawFiles] = React.useState<File[]>([])
+ const [removedImageIds, setRemovedImageIds] = React.useState<string[]>([])
+
+
+ const currentUserId = React.useMemo(() => {
+ return session?.user?.id ? Number(session.user.id) : null
+ }, [session])
+
+ const form = useForm<UpdateGtcClauseSchema>({
+ resolver: zodResolver(updateGtcClauseSchema),
+ defaultValues: {
+ itemNumber: "",
+ category: "",
+ subtitle: "",
+ content: "",
+ // numberVariableName: "",
+ // subtitleVariableName: "",
+ // contentVariableName: "",
+ editReason: "",
+ isActive: true,
+ },
+ })
+
+ React.useEffect(() => {
+ if (gtcClause) {
+ form.reset({
+ itemNumber: gtcClause.itemNumber,
+ category: gtcClause.category || "",
+ subtitle: gtcClause.subtitle,
+ content: gtcClause.content || "",
+ editReason: "",
+ isActive: gtcClause.isActive,
+ })
+ // ✅ 초기 이미지 세팅
+ setImages((gtcClause.images as any[]) || [])
+ setRawFiles([])
+ setRemovedImageIds([])
+ }
+ }, [gtcClause, form])
+
+
+
+ async function onSubmit(input: UpdateGtcClauseSchema) {
+ startUpdateTransition(async () => {
+ if (!gtcClause || !currentUserId) {
+ toast.error("조항 정보를 찾을 수 없습니다.")
+ return
+ }
+
+ try {
+ const result = await updateGtcClause(gtcClause.id, {
+ ...input,
+ images: images, // 이미지 배열 추가
+ updatedById: currentUserId,
+ })
+
+ if (result.error) {
+ toast.error(result.error)
+ return
+ }
+
+ form.reset()
+ props.onOpenChange?.(false)
+ toast.success("GTC 조항이 업데이트되었습니다!")
+ } catch (error) {
+ toast.error("조항 업데이트 중 오류가 발생했습니다.")
+ }
+ })
+ }
+
+ const getDepthBadge = (depth: number) => {
+ const levels = ["1단계", "2단계", "3단계", "4단계", "5단계+"]
+ return levels[depth] || levels[4]
+ }
+
+ const handleContentImageChange = (content: string, newImages: ClauseImage[]) => {
+ form.setValue("content", content)
+ setImages(newImages)
+ }
+
+
+ return (
+ <Sheet {...props}>
+ <SheetContent className="flex flex-col sm:max-w-xl h-full">
+ <SheetHeader className="text-left flex-shrink-0">
+ <SheetTitle>GTC 조항 수정</SheetTitle>
+ <SheetDescription>
+ 조항 정보를 수정하고 변경사항을 저장하세요
+ </SheetDescription>
+ </SheetHeader>
+
+ {/* 조항 정보 표시 */}
+ <div className="space-y-2 p-3 bg-muted/50 rounded-lg flex-shrink-0">
+ <div className="text-sm font-medium">현재 조항 정보</div>
+ <div className="text-xs text-muted-foreground space-y-1">
+ <div className="flex items-center gap-2">
+ <span>위치:</span>
+ <Badge variant="outline">
+ {getDepthBadge(gtcClause?.depth || 0)}
+ </Badge>
+ {gtcClause?.fullPath && (
+ <span className="font-mono">{gtcClause.fullPath}</span>
+ )}
+ </div>
+ {gtcClause?.parentItemNumber && (
+ <div>부모 조항: {gtcClause.parentItemNumber} - {gtcClause.parentSubtitle}</div>
+ )}
+ {gtcClause?.childrenCount > 0 && (
+ <div>하위 조항: {gtcClause.childrenCount}개</div>
+ )}
+ </div>
+ </div>
+
+ <Form {...form}>
+ <form
+ onSubmit={form.handleSubmit(onSubmit)}
+ className="flex flex-col flex-1 min-h-0"
+ >
+ {/* 스크롤 가능한 폼 내용 영역 */}
+ <div className="flex-1 overflow-y-auto px-1">
+ <div className="space-y-4 py-2">
+ {/* 채번 */}
+ <FormField
+ control={form.control}
+ name="itemNumber"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>채번 *</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="예: 1, 1.1, 2.3.1, A, B-1 등"
+ {...field}
+ />
+ </FormControl>
+ <FormDescription>
+ 조항의 번호입니다. 영문, 숫자, 점(.), 하이픈(-), 언더스코어(_)를 사용할 수 있습니다.
+ </FormDescription>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 분류 */}
+ <FormField
+ control={form.control}
+ name="category"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>분류</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="예: 일반조항, 특수조항, 기술조항 등"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 소제목 */}
+ <FormField
+ control={form.control}
+ name="subtitle"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>소제목 *</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="예: PREAMBLE, DEFINITIONS, GENERAL CONDITIONS 등"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 상세항목 */}
+ <FormField
+ control={form.control}
+ name="content"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>상세항목 (선택사항)</FormLabel>
+ <FormControl>
+ <MarkdownImageEditor
+ content={field.value || ""}
+ images={images}
+ onChange={handleContentImageChange}
+ placeholder="조항의 상세 내용을 입력하세요... 이미지를 추가하려면 '이미지 추가' 버튼을 클릭하세요."
+ rows={8}
+ />
+ </FormControl>
+ <FormDescription>
+ 조항의 실제 내용입니다. 텍스트와 이미지를 조합할 수 있으며, 하위 조항들을 그룹핑하는 제목용 조항인 경우 비워둘 수 있습니다.
+ </FormDescription>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* PDFTron 변수명 섹션 */}
+ {/* <div className="space-y-3 p-3 border rounded-lg">
+ <div className="flex items-center gap-2">
+ <Info className="h-4 w-4 text-muted-foreground" />
+ <span className="text-sm font-medium">PDFTron 변수명 설정</span>
+ {gtcClause?.hasAllVariableNames && (
+ <Badge variant="default" className="text-xs">설정됨</Badge>
+ )}
+ </div>
+
+ <div className="grid grid-cols-1 gap-3">
+ <FormField
+ control={form.control}
+ name="numberVariableName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>채번 변수명</FormLabel>
+ <FormControl>
+ <Input {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="subtitleVariableName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>소제목 변수명</FormLabel>
+ <FormControl>
+ <Input {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="contentVariableName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>상세항목 변수명</FormLabel>
+ <FormControl>
+ <Input {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+ </div> */}
+
+ {/* 편집 사유 */}
+ <FormField
+ control={form.control}
+ name="editReason"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>편집 사유 (권장)</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="수정 사유를 입력하세요..."
+ {...field}
+ rows={3}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+ </div>
+
+ <SheetFooter className="gap-2 pt-2 sm:space-x-0">
+ <SheetClose asChild>
+ <Button type="button" variant="outline">
+ Cancel
+ </Button>
+ </SheetClose>
+
+ <Button type="submit" disabled={isUpdatePending}>
+ {isUpdatePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ Save
+ </Button>
+ </SheetFooter>
+ </form>
+ </Form>
+ </SheetContent>
+ </Sheet>
+ )
+} \ No newline at end of file
diff --git a/lib/gtc-contract/gtc-clauses/table/view-clause-variables-dialog.tsx b/lib/gtc-contract/gtc-clauses/table/view-clause-variables-dialog.tsx
new file mode 100644
index 00000000..e500c069
--- /dev/null
+++ b/lib/gtc-contract/gtc-clauses/table/view-clause-variables-dialog.tsx
@@ -0,0 +1,231 @@
+"use client"
+
+import * as React from "react"
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog"
+import { Button } from "@/components/ui/button"
+import { Badge } from "@/components/ui/badge"
+import { Input } from "@/components/ui/input"
+
+import { Eye, Copy, Check, Settings2 } from "lucide-react"
+import { toast } from "sonner"
+import { cn } from "@/lib/utils"
+
+import { type GtcClauseTreeView } from "@/db/schema/gtc"
+
+interface ViewClauseVariablesDialogProps
+ extends React.ComponentPropsWithRef<typeof Dialog> {
+ clause: GtcClauseTreeView | null
+ onEditVariables?: () => void
+}
+
+export function ViewClauseVariablesDialog({
+ clause,
+ onEditVariables,
+ ...props
+}: ViewClauseVariablesDialogProps) {
+ const [copiedField, setCopiedField] = React.useState<string | null>(null)
+
+ const copyToClipboard = async (text: string, fieldName: string) => {
+ try {
+ await navigator.clipboard.writeText(text)
+ setCopiedField(fieldName)
+ toast.success(`${fieldName} 변수명이 복사되었습니다.`)
+
+ // 2초 후 복사 상태 초기화
+ setTimeout(() => {
+ setCopiedField(null)
+ }, 2000)
+ } catch (error) {
+ toast.error("복사 중 오류가 발생했습니다.")
+ }
+ }
+
+ const copyAllVariables = async () => {
+ if (!clause) return
+
+ const allVariables = [
+ clause.autoNumberVariable,
+ clause.autoSubtitleVariable,
+ clause.autoContentVariable
+ ].filter(Boolean).join('\n')
+
+ try {
+ await navigator.clipboard.writeText(allVariables)
+ toast.success("모든 변수명이 복사되었습니다.")
+ } catch (error) {
+ toast.error("복사 중 오류가 발생했습니다.")
+ }
+ }
+
+ if (!clause) {
+ return null
+ }
+
+ const variables = [
+ {
+ label: "채번 변수명",
+ value: clause.autoNumberVariable,
+ fieldName: "채번",
+ description: "조항 번호를 표시하는 변수"
+ },
+ {
+ label: "소제목 변수명",
+ value: clause.autoSubtitleVariable,
+ fieldName: "소제목",
+ description: "조항 제목을 표시하는 변수"
+ },
+ {
+ label: "상세항목 변수명",
+ value: clause.autoContentVariable,
+ fieldName: "상세항목",
+ description: "조항 내용을 표시하는 변수"
+ }
+ ]
+
+ return (
+ <Dialog {...props}>
+ <DialogContent className="max-w-2xl">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ <Eye className="h-5 w-5" />
+ PDFTron 변수명 보기
+ </DialogTitle>
+ <DialogDescription>
+ 현재 조항에 설정된 PDFTron 변수명을 확인하고 복사할 수 있습니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ {/* 조항 정보 */}
+ <div className="p-3 bg-muted/50 rounded-lg">
+ <div className="font-medium mb-2">조항 정보</div>
+ <div className="space-y-1 text-sm text-muted-foreground">
+ <div className="flex items-center gap-2">
+ <Badge variant="outline">{clause.itemNumber}</Badge>
+ <span>{clause.subtitle}</span>
+ <Badge variant={clause.hasAllVariableNames ? "default" : "destructive"}>
+ {clause.hasAllVariableNames ? "설정됨" : "미설정"}
+ </Badge>
+ </div>
+ {clause.fullPath && (
+ <div>경로: {clause.fullPath}</div>
+ )}
+ {clause.category && (
+ <div>분류: {clause.category}</div>
+ )}
+ </div>
+ </div>
+
+ {/* 변수명 목록 */}
+ <div className="space-y-4">
+ <div className="flex items-center justify-between">
+ <h3 className="text-sm font-medium">설정된 변수명</h3>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={copyAllVariables}
+ className="h-8"
+ >
+ <Copy className="mr-2 h-3 w-3" />
+ 전체 복사
+ </Button>
+ </div>
+
+ <div className="space-y-3">
+ {variables.map((variable, index) => (
+ <div key={index} className="space-y-2">
+ <div className="flex items-center justify-between">
+ <div>
+ <div className="text-sm font-medium">{variable.label}</div>
+ <div className="text-xs text-muted-foreground">{variable.description}</div>
+ </div>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => copyToClipboard(variable.value, variable.fieldName)}
+ className="h-8 px-2"
+ >
+ {copiedField === variable.fieldName ? (
+ <Check className="h-3 w-3 text-green-500" />
+ ) : (
+ <Copy className="h-3 w-3" />
+ )}
+ </Button>
+ </div>
+ <div className="relative">
+ <Input
+ value={variable.value}
+ readOnly
+ className="font-mono text-xs bg-muted/30"
+ />
+ </div>
+ </div>
+ ))}
+ </div>
+ </div>
+
+ {/* PDFTron 템플릿 미리보기 */}
+ <div className="space-y-2">
+ <h3 className="text-sm font-medium">PDFTron 템플릿 미리보기</h3>
+ <div className="p-3 bg-gray-50 border rounded-lg">
+ <div className="space-y-2 text-xs font-mono">
+ <div className="text-blue-600">
+ {"{{" + clause.autoNumberVariable + "}}"}. {"{{" + clause.autoSubtitleVariable + "}}"}
+ </div>
+ <div className="text-gray-600 ml-4">
+ {"{{" + clause.autoContentVariable + "}}"}
+ </div>
+ </div>
+ <div className="text-xs text-muted-foreground mt-2">
+ 실제 문서에서 위와 같은 형태로 표시됩니다.
+ </div>
+ </div>
+ </div>
+
+ {/* 실제 값 미리보기 */}
+ <div className="space-y-2">
+ <h3 className="text-sm font-medium">실제 값 미리보기</h3>
+ <div className="p-3 bg-blue-50 border border-blue-200 rounded-lg">
+ <div className="space-y-2 text-sm">
+ <div className="font-medium text-blue-900">
+ {clause.itemNumber}. {clause.subtitle}
+ </div>
+ {clause.content && (
+ <div className="text-blue-800 ml-4">
+ {clause.content.length > 150
+ ? `${clause.content.substring(0, 150)}...`
+ : clause.content
+ }
+ </div>
+ )}
+ {!clause.content && (
+ <div className="text-blue-600 ml-4 italic">
+ (그룹핑 조항 - 상세내용 없음)
+ </div>
+ )}
+ </div>
+ </div>
+ </div>
+
+ <DialogFooter>
+ {onEditVariables && (
+ <Button
+ variant="outline"
+ onClick={() => {
+ props.onOpenChange?.(false)
+ onEditVariables()
+ }}
+ >
+ <Settings2 className="mr-2 h-4 w-4" />
+ 변수 설정 수정
+ </Button>
+ )}
+ <Button
+ onClick={() => props.onOpenChange?.(false)}
+ >
+ Close
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/gtc-contract/gtc-clauses/validations.ts b/lib/gtc-contract/gtc-clauses/validations.ts
new file mode 100644
index 00000000..edbcf612
--- /dev/null
+++ b/lib/gtc-contract/gtc-clauses/validations.ts
@@ -0,0 +1,124 @@
+import { type GtcClause } from "@/db/schema/gtc"
+import {
+ createSearchParamsCache,
+ parseAsArrayOf,
+ parseAsInteger,
+ parseAsString,
+ parseAsStringEnum,
+} from "nuqs/server"
+import * as z from "zod"
+import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"
+
+export const searchParamsCache = createSearchParamsCache({
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault(
+ []
+ ),
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(20),
+ sort: getSortingStateParser<GtcClause>().withDefault([
+ { id: "sortOrder", desc: false },
+ ]),
+ // 검색 필터들
+ category: parseAsString.withDefault(""),
+ depth: parseAsInteger.withDefault(0),
+ parentId: parseAsInteger.withDefault(0),
+ // advanced filter
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+ search: parseAsString.withDefault(""),
+})
+
+const clauseImageSchema = z.object({
+ id: z.string(),
+ url: z.string().url(),
+ fileName: z.string(),
+ size: z.number().positive(),
+})
+
+export const createGtcClauseSchema = z.object({
+ documentId: z.number(),
+ parentId: z.number().nullable().optional(),
+ itemNumber: z.string().min(1, "채번을 입력해주세요"),
+ category: z.string().optional(),
+ subtitle: z.string().min(1, "소제목을 입력해주세요"),
+ images: z.array(clauseImageSchema).optional(), // ✅ 이미지 배열 추가
+
+ content: z.string().optional(), // 그룹핑용 조항은 내용이 없을 수 있음
+ sortOrder: z.number().default(0),
+ editReason: z.string().optional(),
+}).superRefine(async (data, ctx) => {
+ // 채번 형식 검증 (숫자, 문자 모두 허용하되 특수문자 제한)
+ const itemNumberRegex = /^[a-zA-Z0-9._-]+$/
+ if (!itemNumberRegex.test(data.itemNumber)) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "채번은 영문, 숫자, 점(.), 하이픈(-), 언더스코어(_)만 사용 가능합니다",
+ path: ["itemNumber"],
+ })
+ }
+})
+
+export const updateGtcClauseSchema = z.object({
+ itemNumber: z.string().min(1, "채번을 입력해주세요").optional(),
+ category: z.string().optional(),
+ subtitle: z.string().min(1, "소제목을 입력해주세요").optional(),
+ content: z.string().optional(), // 내용도 nullable
+ sortOrder: z.number().optional(),
+ images: z.array(clauseImageSchema).optional(), // ✅ 이미지 배열 추가
+ isActive: z.boolean().optional(),
+ editReason: z.string().optional(),
+}).superRefine(async (data, ctx) => {
+ // 채번 형식 검증
+ if (data.itemNumber) {
+ const itemNumberRegex = /^[a-zA-Z0-9._-]+$/
+ if (!itemNumberRegex.test(data.itemNumber)) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "채번은 영문, 숫자, 점(.), 하이픈(-), 언더스코어(_)만 사용 가능합니다",
+ path: ["itemNumber"],
+ })
+ }
+ }
+})
+
+export const reorderGtcClausesSchema = z.object({
+ clauses: z.array(z.object({
+ id: z.number(),
+ sortOrder: z.number(),
+ parentId: z.number().nullable(),
+ depth: z.number(),
+ fullPath: z.string().optional(),
+ })),
+ editReason: z.string().optional(),
+})
+
+export const bulkUpdateGtcClausesSchema = z.object({
+ clauseIds: z.array(z.number()).min(1, "수정할 조항을 선택해주세요"),
+ updates: z.object({
+ category: z.string().optional(),
+ isActive: z.boolean().optional(),
+ }),
+ editReason: z.string().min(1, "편집 사유를 입력해주세요"),
+})
+
+export const generateVariableNamesSchema = z.object({
+ documentId: z.number(),
+ prefix: z.string().default("CLAUSE"),
+ includeVendorCode: z.boolean().default(false),
+ vendorCode: z.string().optional(),
+}).superRefine(async (data, ctx) => {
+ if (data.includeVendorCode && !data.vendorCode) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "벤더 코드 포함 시 벤더 코드를 입력해주세요",
+ path: ["vendorCode"],
+ })
+ }
+})
+
+export type GetGtcClausesSchema = Awaited<ReturnType<typeof searchParamsCache.parse>>
+export type CreateGtcClauseSchema = z.infer<typeof createGtcClauseSchema>
+export type UpdateGtcClauseSchema = z.infer<typeof updateGtcClauseSchema>
+export type ReorderGtcClausesSchema = z.infer<typeof reorderGtcClausesSchema>
+export type BulkUpdateGtcClausesSchema = z.infer<typeof bulkUpdateGtcClausesSchema>
+export type GenerateVariableNamesSchema = z.infer<typeof generateVariableNamesSchema> \ No newline at end of file
diff --git a/lib/gtc-contract/service.ts b/lib/gtc-contract/service.ts
index 23cdd422..308c52bf 100644
--- a/lib/gtc-contract/service.ts
+++ b/lib/gtc-contract/service.ts
@@ -1,7 +1,7 @@
'use server'
-import { unstable_cache } from "next/cache"
-import { and, desc, asc, eq, or, ilike, count, max } from "drizzle-orm"
+import { revalidateTag, unstable_cache } from "next/cache"
+import { and, desc, asc, eq, or, ilike, count, max , inArray} from "drizzle-orm"
import db from "@/db/db"
import { gtcDocuments, gtcDocumentsView, type GtcDocument, type GtcDocumentWithRelations } from "@/db/schema/gtc"
import { projects } from "@/db/schema/projects"
@@ -164,6 +164,8 @@ export async function createGtcDocument(
})
.returning()
+ revalidateTag("gtc-documents")
+
return newDocument
}
@@ -174,6 +176,9 @@ export async function updateGtcDocument(
id: number,
data: UpdateGtcDocumentSchema & { updatedById: number }
): Promise<GtcDocument | null> {
+
+ console.log(data, "data")
+
const [updatedDocument] = await db
.update(gtcDocuments)
.set({
@@ -183,6 +188,8 @@ export async function updateGtcDocument(
.where(eq(gtcDocuments.id, id))
.returning()
+ revalidateTag("gtc-documents")
+
return updatedDocument || null
}
@@ -241,33 +248,6 @@ export async function deleteGtcDocument(
return !!updated
}
-/**
- * 프로젝트별 GTC 문서 목록 조회
- */
-export async function getGtcDocumentsByProject(projectId: number): Promise<GtcDocumentWithRelations[]> {
- return await selectGtcDocumentsWithRelations()
- .where(
- and(
- eq(gtcDocuments.projectId, projectId),
- eq(gtcDocuments.isActive, true)
- )
- )
- .orderBy(desc(gtcDocuments.revision))
-}
-
-/**
- * 표준 GTC 문서 목록 조회
- */
-export async function getStandardGtcDocuments(): Promise<GtcDocumentWithRelations[]> {
- return await selectGtcDocumentsWithRelations()
- .where(
- and(
- eq(gtcDocuments.type, "standard"),
- eq(gtcDocuments.isActive, true)
- )
- )
- .orderBy(desc(gtcDocuments.revision))
-}
// 타입 정의
export type ProjectForFilter = {
@@ -323,4 +303,31 @@ export async function getUsersForFilter(): Promise<UserForFilter[]> {
.from(users)
.where(eq(users.isActive, true)) // 활성 사용자만
.orderBy(users.name)
+}
+
+/**
+ * GTC 문서 단일 조회
+ */
+export async function getGtcDocumentById(id: number) {
+ return unstable_cache(
+ async () => {
+ try {
+ const [document] = await db
+ .select()
+ .from(gtcDocumentsView)
+ .where(eq(gtcDocumentsView.id, id))
+ .limit(1)
+
+ return document || null
+ } catch (err) {
+ console.error("Error fetching GTC document by ID:", err)
+ return null
+ }
+ },
+ [`gtc-document-${id}`],
+ {
+ revalidate: 3600,
+ tags: [`gtc-document-${id}`, "gtc-documents"],
+ }
+ )()
} \ 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 98cd249f..003e4d51 100644
--- a/lib/gtc-contract/status/create-gtc-document-dialog.tsx
+++ b/lib/gtc-contract/status/create-gtc-document-dialog.tsx
@@ -10,6 +10,7 @@ import { Textarea } from "@/components/ui/textarea"
import {
Form,
FormControl,
+ FormDescription,
FormField,
FormItem,
FormLabel,
@@ -43,14 +44,17 @@ import { createGtcDocumentSchema, type CreateGtcDocumentSchema } from "@/lib/gtc
import { createGtcDocument, getProjectsForSelect } from "@/lib/gtc-contract/service"
import { type Project } from "@/db/schema/projects"
import { useSession } from "next-auth/react"
+import { Input } from "@/components/ui/input"
+import { useRouter } from "next/navigation";
export function CreateGtcDocumentDialog() {
const [open, setOpen] = React.useState(false)
const [projects, setProjects] = React.useState<Project[]>([])
const [isCreatePending, startCreateTransition] = React.useTransition()
const { data: session } = useSession()
+ const router = useRouter();
- const currentUserId =React.useMemo(() => {
+ const currentUserId = React.useMemo(() => {
return session?.user?.id ? Number(session.user.id) : null;
}, [session]);
@@ -68,6 +72,7 @@ export function CreateGtcDocumentDialog() {
defaultValues: {
type: "standard",
projectId: null,
+ title: "",
revision: 0,
editReason: "",
},
@@ -88,7 +93,7 @@ export function CreateGtcDocumentDialog() {
...data,
createdById: currentUserId
})
-
+
if (result.error) {
toast.error(`에러: ${result.error}`)
return
@@ -96,6 +101,8 @@ export function CreateGtcDocumentDialog() {
form.reset()
setOpen(false)
+ router.refresh();
+
toast.success("GTC 문서가 생성되었습니다.")
} catch (error) {
toast.error("문서 생성 중 오류가 발생했습니다.")
@@ -241,6 +248,26 @@ export function CreateGtcDocumentDialog() {
/>
)}
+ <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}
diff --git a/lib/gtc-contract/status/gtc-documents-table-columns.tsx b/lib/gtc-contract/status/gtc-documents-table-columns.tsx
index f6eb81d0..cd02a3e5 100644
--- a/lib/gtc-contract/status/gtc-documents-table-columns.tsx
+++ b/lib/gtc-contract/status/gtc-documents-table-columns.tsx
@@ -4,8 +4,9 @@ import * as React from "react"
import { type DataTableRowAction } from "@/types/table"
import { type ColumnDef } from "@tanstack/react-table"
import { Ellipsis, Eye } from "lucide-react"
+import type { AppRouterInstance } from "next/dist/shared/lib/app-router-context.shared-runtime"
-import { formatDate, formatDateTime } from "@/lib/utils"
+import { formatDate } from "@/lib/utils"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
@@ -23,16 +24,11 @@ import { type GtcDocumentWithRelations } from "@/db/schema/gtc"
interface GetColumnsProps {
setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<GtcDocumentWithRelations> | null>>
+ router: AppRouterInstance // ← 추가
}
-/**
- * GTC Documents 테이블 컬럼 정의
- */
+/** GTC Documents 테이블 컬럼 정의 (그룹 헤더 제거) */
export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef<GtcDocumentWithRelations>[] {
-
- // ----------------------------------------------------------------
- // 1) select 컬럼 (체크박스)
- // ----------------------------------------------------------------
const selectColumn: ColumnDef<GtcDocumentWithRelations> = {
id: "select",
header: ({ table }) => (
@@ -59,165 +55,145 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef
enableHiding: false,
}
- // ----------------------------------------------------------------
- // 2) 기본 정보 그룹
- // ----------------------------------------------------------------
const basicInfoColumns: ColumnDef<GtcDocumentWithRelations>[] = [
{
accessorKey: "type",
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="구분" />,
cell: ({ row }) => {
- const type = row.getValue("type") as string;
+ const type = row.getValue("type") as string
return (
<Badge variant={type === "standard" ? "default" : "secondary"}>
{type === "standard" ? "표준" : "프로젝트"}
</Badge>
- );
+ )
},
size: 100,
enableResizing: true,
- meta: {
- excelHeader: "구분",
- },
+ meta: { excelHeader: "구분" },
},
{
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 project = row.original.project
+ if (!project) 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>
</div>
- );
+ )
},
size: 200,
enableResizing: true,
- meta: {
- excelHeader: "프로젝트",
+ meta: { excelHeader: "프로젝트" },
+ },
+ {
+ accessorKey: "title",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="GTC 제목" />,
+ cell: ({ row }) => {
+ const title = row.original.title
+ if (!title) return <span className="text-muted-foreground">-</span>
+ return (
+ <div className="flex flex-col min-w-0">
+ <span className="font-medium truncate">{title}</span>
+ </div>
+ )
},
+ size: 200,
+ enableResizing: true,
+ meta: { excelHeader: "GTC 제목" },
},
{
accessorKey: "revision",
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="Rev." />,
cell: ({ row }) => {
- const revision = row.getValue("revision") as number;
- return <span className="font-mono text-sm">v{revision}</span>;
+ const revision = row.getValue("revision") as number
+ return <span className="font-mono text-sm">v{revision}</span>
},
size: 80,
enableResizing: true,
- meta: {
- excelHeader: "Rev.",
- },
+ meta: { excelHeader: "Rev." },
},
- ];
+ ]
- // ----------------------------------------------------------------
- // 3) 등록/수정 정보 그룹
- // ----------------------------------------------------------------
const auditColumns: ColumnDef<GtcDocumentWithRelations>[] = [
{
accessorKey: "createdAt",
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="최초등록일" />,
cell: ({ row }) => {
- const date = row.getValue("createdAt") as Date;
- return date ? formatDate(date, "KR") : "-";
+ const date = row.getValue("createdAt") as Date
+ return date ? formatDate(date, "KR") : "-"
},
size: 120,
enableResizing: true,
- meta: {
- excelHeader: "최초등록일",
- },
+ meta: { excelHeader: "최초등록일" },
},
{
accessorKey: "createdBy",
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="최초등록자" />,
cell: ({ row }) => {
- const createdBy = row.original.createdBy;
- return createdBy ? (
- <span className="text-sm">{createdBy.name}</span>
- ) : (
- <span className="text-muted-foreground">-</span>
- );
+ const createdBy = row.original.createdBy
+ return createdBy ? <span className="text-sm">{createdBy.name}</span> : <span className="text-muted-foreground">-</span>
},
size: 120,
enableResizing: true,
- meta: {
- excelHeader: "최초등록자",
- },
+ meta: { excelHeader: "최초등록자" },
},
{
accessorKey: "updatedAt",
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="최종수정일" />,
cell: ({ row }) => {
- const date = row.getValue("updatedAt") as Date;
- return date ? formatDate(date, "KR") : "-";
+ const date = row.getValue("updatedAt") as Date
+ return date ? formatDate(date, "KR") : "-"
},
size: 120,
enableResizing: true,
- meta: {
- excelHeader: "최종수정일",
- },
+ meta: { excelHeader: "최종수정일" },
},
{
accessorKey: "updatedBy",
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="최종수정자" />,
cell: ({ row }) => {
- const updatedBy = row.original.updatedBy;
- return updatedBy ? (
- <span className="text-sm">{updatedBy.name}</span>
- ) : (
- <span className="text-muted-foreground">-</span>
- );
+ const updatedBy = row.original.updatedBy
+ return updatedBy ? <span className="text-sm">{updatedBy.name}</span> : <span className="text-muted-foreground">-</span>
},
size: 120,
enableResizing: true,
- meta: {
- excelHeader: "최종수정자",
- },
+ meta: { excelHeader: "최종수정자" },
},
{
accessorKey: "editReason",
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="최종 편집사유" />,
cell: ({ row }) => {
- const reason = row.getValue("editReason") as string;
+ const reason = row.getValue("editReason") as string
return reason ? (
<span className="text-sm" title={reason}>
{reason.length > 30 ? `${reason.substring(0, 30)}...` : reason}
</span>
) : (
<span className="text-muted-foreground">-</span>
- );
+ )
},
size: 200,
enableResizing: true,
- meta: {
- excelHeader: "최종 편집사유",
- },
+ meta: { excelHeader: "최종 편집사유" },
},
- ];
+ ]
- // ----------------------------------------------------------------
- // 4) actions 컬럼 (Dropdown 메뉴)
- // ----------------------------------------------------------------
const actionsColumn: ColumnDef<GtcDocumentWithRelations> = {
id: "actions",
enableHiding: false,
- cell: function Cell({ row }) {
- const [isUpdatePending, startUpdateTransition] = React.useTransition()
- const gtcDocument = row.original;
+ cell: ({ row }) => {
+ const gtcDocument = row.original
const handleViewDetails = () => {
- router.push(`/evcp/gtc-documents/${gtcDocument.id}`);
- };
+ router.push(`/evcp/basic-contract-template/gtc/${gtcDocument.id}`)
+ }
const handleCreateNewRevision = () => {
- setRowAction({ row, type: "createRevision" });
- };
+ setRowAction({ row, type: "createRevision" })
+ }
return (
<DropdownMenu>
@@ -227,32 +203,28 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef
variant="ghost"
className="flex size-8 p-0 data-[state=open]:bg-muted"
>
- <Ellipsis className="size-4" aria-hidden="true" />
+ <Ellipsis className="size-4" aria-hidden />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem onSelect={handleViewDetails}>
<Eye className="mr-2 h-4 w-4" />
- View Details
+ 상세
</DropdownMenuItem>
-
+
<DropdownMenuSeparator />
-
- <DropdownMenuItem
- onSelect={() => setRowAction({ row, type: "update" })}
- >
- Edit
+
+ <DropdownMenuItem onSelect={() => setRowAction({ row, type: "update" })}>
+ 수정
</DropdownMenuItem>
<DropdownMenuItem onSelect={handleCreateNewRevision}>
- Create New Revision
+ 새 리비전 생성
</DropdownMenuItem>
<DropdownMenuSeparator />
- <DropdownMenuItem
- onSelect={() => setRowAction({ row, type: "delete" })}
- >
- Delete
+ <DropdownMenuItem onSelect={() => setRowAction({ row, type: "delete" })}>
+ 삭제
<DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuContent>
@@ -262,28 +234,11 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef
size: 40,
}
- // ----------------------------------------------------------------
- // 5) 중첩 컬럼 그룹 생성
- // ----------------------------------------------------------------
- const nestedColumns: ColumnDef<GtcDocumentWithRelations>[] = [
- {
- id: "기본 정보",
- header: "기본 정보",
- columns: basicInfoColumns,
- },
- {
- id: "등록/수정 정보",
- header: "등록/수정 정보",
- columns: auditColumns,
- },
- ]
-
- // ----------------------------------------------------------------
- // 6) 최종 컬럼 배열
- // ----------------------------------------------------------------
+ // 그룹 없이 평평한 배열 반환
return [
selectColumn,
- ...nestedColumns,
+ ...basicInfoColumns,
+ ...auditColumns,
actionsColumn,
]
-} \ No newline at end of file
+}
diff --git a/lib/gtc-contract/status/update-gtc-document-sheet.tsx b/lib/gtc-contract/status/update-gtc-document-sheet.tsx
index 9d133ecc..6ba02a44 100644
--- a/lib/gtc-contract/status/update-gtc-document-sheet.tsx
+++ b/lib/gtc-contract/status/update-gtc-document-sheet.tsx
@@ -19,16 +19,19 @@ import { Button } from "@/components/ui/button"
import {
Form,
FormControl,
+ FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
import { Textarea } from "@/components/ui/textarea"
+import { useRouter } from "next/navigation";
import { type GtcDocumentWithRelations } from "@/db/schema/gtc"
import { updateGtcDocumentSchema, type UpdateGtcDocumentSchema } from "@/lib/gtc-contract/validations"
import { updateGtcDocument } from "@/lib/gtc-contract/service"
+import { Input } from "@/components/ui/input"
export interface UpdateGtcDocumentSheetProps
extends React.ComponentPropsWithRef<typeof Sheet> {
@@ -37,11 +40,13 @@ export interface UpdateGtcDocumentSheetProps
export function UpdateGtcDocumentSheet({ gtcDocument, ...props }: UpdateGtcDocumentSheetProps) {
const [isUpdatePending, startUpdateTransition] = React.useTransition()
-
+ const router = useRouter();
+
const form = useForm<UpdateGtcDocumentSchema>({
resolver: zodResolver(updateGtcDocumentSchema),
defaultValues: {
editReason: "",
+ title: "",
isActive: gtcDocument?.isActive ?? true,
},
})
@@ -50,7 +55,8 @@ export function UpdateGtcDocumentSheet({ gtcDocument, ...props }: UpdateGtcDocum
React.useEffect(() => {
if (gtcDocument) {
form.reset({
- editReason: "",
+ editReason: gtcDocument.editReason,
+ title:gtcDocument.title,
isActive: gtcDocument.isActive,
})
}
@@ -70,6 +76,8 @@ export function UpdateGtcDocumentSheet({ gtcDocument, ...props }: UpdateGtcDocum
form.reset()
props.onOpenChange?.(false)
+ router.refresh();
+
toast.success("GTC 문서가 업데이트되었습니다!")
} catch (error) {
toast.error("문서 업데이트 중 오류가 발생했습니다.")
@@ -104,6 +112,28 @@ export function UpdateGtcDocumentSheet({ gtcDocument, ...props }: UpdateGtcDocum
</div>
</div>
+
+ <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}
diff --git a/lib/gtc-contract/validations.ts b/lib/gtc-contract/validations.ts
index 671e25b7..d00d795b 100644
--- a/lib/gtc-contract/validations.ts
+++ b/lib/gtc-contract/validations.ts
@@ -32,6 +32,7 @@ export const createGtcDocumentSchema = z.object({
projectId: z.number().nullable().optional(),
revision: z.number().min(0).default(0),
editReason: z.string().optional(),
+ title: z.string().optional(),
}).superRefine(async (data, ctx) => {
// 프로젝트 타입인 경우 projectId 필수
if (data.type === "project" && !data.projectId) {
@@ -57,6 +58,7 @@ export const createGtcDocumentSchema = z.object({
export const updateGtcDocumentSchema = z.object({
editReason: z.string().optional(),
+ title: z.string().optional(),
isActive: z.boolean().optional(),
})