diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-07-28 11:44:16 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-07-28 11:44:16 +0000 |
| commit | c228a89c2834ee63b209bad608837c39643f350e (patch) | |
| tree | 39c9a121b556af872072dd80750dedf2d2d62335 /lib/gtc-contract | |
| parent | 50ae0b8f02c034e60d4cbb504620dfa1575a836f (diff) | |
(대표님) 의존성 docx 추가, basicContract API, gtc(계약일반조건), 벤더평가 esg 평가데이터 내보내기 개선, S-EDP 피드백 대응(CLS_ID, ITEM NO 등)
Diffstat (limited to 'lib/gtc-contract')
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(), }) |
