From c228a89c2834ee63b209bad608837c39643f350e Mon Sep 17 00:00:00 2001 From: dujinkim Date: Mon, 28 Jul 2025 11:44:16 +0000 Subject: (대표님) 의존성 docx 추가, basicContract API, gtc(계약일반조건), 벤더평가 esg 평가데이터 내보내기 개선, S-EDP 피드백 대응(CLS_ID, ITEM NO 등) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/forms/services.ts | 60 ++ .../gtc-clauses/gtc-clauses-page-header.tsx | 102 +++ lib/gtc-contract/gtc-clauses/service.ts | 936 +++++++++++++++++++++ .../table/bulk-update-gtc-clauses-dialog.tsx | 276 ++++++ .../gtc-clauses/table/clause-preview-viewer.tsx | 435 ++++++++++ .../gtc-clauses/table/clause-table.tsx | 234 ++++++ .../table/clause-variable-settings-dialog.tsx | 364 ++++++++ .../gtc-clauses/table/create-gtc-clause-dialog.tsx | 442 ++++++++++ .../table/delete-gtc-clauses-dialog.tsx | 175 ++++ .../table/duplicate-gtc-clause-dialog.tsx | 372 ++++++++ .../table/generate-variable-names-dialog.tsx | 348 ++++++++ .../table/gtc-clauses-table-columns.tsx | 283 +++++++ .../table/gtc-clauses-table-floating-bar.tsx | 239 ++++++ .../table/gtc-clauses-table-toolbar-actions.tsx | 195 +++++ .../gtc-clauses/table/markdown-image-editor.tsx | 360 ++++++++ .../gtc-clauses/table/preview-document-dialog.tsx | 189 +++++ .../table/reorder-gtc-clauses-dialog.tsx | 540 ++++++++++++ .../gtc-clauses/table/update-gtc-clause-sheet.tsx | 361 ++++++++ .../table/view-clause-variables-dialog.tsx | 231 +++++ lib/gtc-contract/gtc-clauses/validations.ts | 124 +++ lib/gtc-contract/service.ts | 65 +- .../status/create-gtc-document-dialog.tsx | 31 +- .../status/gtc-documents-table-columns.tsx | 175 ++-- .../status/update-gtc-document-sheet.tsx | 34 +- lib/gtc-contract/validations.ts | 2 + lib/utils.ts | 14 + .../table/esg-evaluation-form-sheet.tsx | 115 ++- 27 files changed, 6520 insertions(+), 182 deletions(-) create mode 100644 lib/gtc-contract/gtc-clauses/gtc-clauses-page-header.tsx create mode 100644 lib/gtc-contract/gtc-clauses/service.ts create mode 100644 lib/gtc-contract/gtc-clauses/table/bulk-update-gtc-clauses-dialog.tsx create mode 100644 lib/gtc-contract/gtc-clauses/table/clause-preview-viewer.tsx create mode 100644 lib/gtc-contract/gtc-clauses/table/clause-table.tsx create mode 100644 lib/gtc-contract/gtc-clauses/table/clause-variable-settings-dialog.tsx create mode 100644 lib/gtc-contract/gtc-clauses/table/create-gtc-clause-dialog.tsx create mode 100644 lib/gtc-contract/gtc-clauses/table/delete-gtc-clauses-dialog.tsx create mode 100644 lib/gtc-contract/gtc-clauses/table/duplicate-gtc-clause-dialog.tsx create mode 100644 lib/gtc-contract/gtc-clauses/table/generate-variable-names-dialog.tsx create mode 100644 lib/gtc-contract/gtc-clauses/table/gtc-clauses-table-columns.tsx create mode 100644 lib/gtc-contract/gtc-clauses/table/gtc-clauses-table-floating-bar.tsx create mode 100644 lib/gtc-contract/gtc-clauses/table/gtc-clauses-table-toolbar-actions.tsx create mode 100644 lib/gtc-contract/gtc-clauses/table/markdown-image-editor.tsx create mode 100644 lib/gtc-contract/gtc-clauses/table/preview-document-dialog.tsx create mode 100644 lib/gtc-contract/gtc-clauses/table/reorder-gtc-clauses-dialog.tsx create mode 100644 lib/gtc-contract/gtc-clauses/table/update-gtc-clause-sheet.tsx create mode 100644 lib/gtc-contract/gtc-clauses/table/view-clause-variables-dialog.tsx create mode 100644 lib/gtc-contract/gtc-clauses/validations.ts (limited to 'lib') diff --git a/lib/forms/services.ts b/lib/forms/services.ts index 02333095..b517ff18 100644 --- a/lib/forms/services.ts +++ b/lib/forms/services.ts @@ -1049,6 +1049,7 @@ interface SEDPAttribute { interface SEDPDataItem { TAG_NO: string; TAG_DESC: string; + CLS_ID:string; ATTRIBUTES: SEDPAttribute[]; SCOPE: string; TOOLID: string; @@ -1096,12 +1097,16 @@ async function transformDataToSEDPFormat( // Cache for packageCode to avoid duplicate DB queries for same tag const packageCodeCache = new Map(); + // Cache for tagClass code to avoid duplicate DB queries for same tag + const tagClassCodeCache = new Map(); + // Transform each row const transformedItems = []; for (const row of tableData) { // Get packageCode for this specific tag let packageCode = formCode; // fallback to formCode + let tagClassCode = ""; // for CLS_ID if (row.TAG_NO && contractItemId) { // Check cache first @@ -1120,6 +1125,28 @@ async function transformDataToSEDPFormat( }); if (tagResult) { + // Get tagClass code if tagClassId exists + if (tagResult.tagClassId) { + // Check tagClass cache first + if (tagClassCodeCache.has(cacheKey)) { + tagClassCode = tagClassCodeCache.get(cacheKey)!; + } else { + const tagClassResult = await db.query.tagClasses.findFirst({ + where: eq(tagClasses.id, tagResult.tagClassId) + }); + + if (tagClassResult) { + tagClassCode = tagClassResult.code; + console.log(`Found tagClass code for tag ${row.TAG_NO}: ${tagClassCode}`); + } else { + console.warn(`No tagClass found for tagClassId: ${tagResult.tagClassId}`); + } + + // Cache the tagClass code result + tagClassCodeCache.set(cacheKey, tagClassCode); + } + } + // Get the contract item const contractItemResult = await db.query.contractItems.findFirst({ where: eq(contractItems.id, tagResult.contractItemId) @@ -1152,6 +1179,38 @@ async function transformDataToSEDPFormat( packageCodeCache.set(cacheKey, packageCode); } } + + // Get tagClass code if not already retrieved above + if (!tagClassCode && tagClassCodeCache.has(cacheKey)) { + tagClassCode = tagClassCodeCache.get(cacheKey)!; + } else if (!tagClassCode) { + try { + const tagResult = await db.query.tags.findFirst({ + where: and( + eq(tags.contractItemId, contractItemId), + eq(tags.tagNo, row.TAG_NO) + ) + }); + + if (tagResult && tagResult.tagClassId) { + const tagClassResult = await db.query.tagClasses.findFirst({ + where: eq(tagClasses.id, tagResult.tagClassId) + }); + + if (tagClassResult) { + tagClassCode = tagClassResult.code; + console.log(`Found tagClass code for tag ${row.TAG_NO}: ${tagClassCode}`); + } + } + + // Cache the tagClass code result + tagClassCodeCache.set(cacheKey, tagClassCode); + } catch (error) { + console.error(`Error fetching tagClass code for tag ${row.TAG_NO}:`, error); + // Cache empty string as fallback + tagClassCodeCache.set(cacheKey, ""); + } + } } // Create base SEDP item with required fields @@ -1169,6 +1228,7 @@ async function transformDataToSEDPFormat( CRTER_NO: designerNo, CHGER_NO: designerNo, TYPE: packageCode, // Use packageCode instead of formCode + CLS_ID: tagClassCode, // Add CLS_ID with tagClass code PROJ_NO: projectNo, REV_NO: "00", CRTE_DTM: currentTimestamp, 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 ( +
+ {/* 헤더 왼쪽 */} +
+ + +
+
+

+ GTC 조항 관리 +

+ +
+ +
+ + {document.type === "standard" ? "표준" : "프로젝트"} + + + {document.project && ( + <> + + {document.project.name} ({document.project.code}) + + )} + + + v{document.revision} + + {document.fileName && ( + <> + + {document.fileName} + + )} +
+
+
+ + {/* 헤더 오른쪽 - 액션 버튼들 */} +
+ {/* + + + + */} +
+
+ ) +} \ 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 = { + 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 = { + 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 = { + 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() + 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 { + 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({ + 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 = {} + 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 ( + + + + + + 조항 일괄 수정 + + + 선택한 {selectedClauses.length}개 조항의 공통 속성을 일괄 수정합니다. + + + + {/* 선택된 조항 요약 */} +
+
+ + 선택된 조항 정보 +
+ +
+
+
총 조항 수
+
{selectedClauses.length}개
+
+ +
+
상태
+
+ {activeCount}개 활성 + {inactiveCount > 0 && ( + {inactiveCount}개 비활성 + )} +
+
+
+ + {/* 분류별 통계 */} +
+
현재 분류 현황
+
+ {Object.entries(categoryCounts).map(([category, count]) => ( + + {category}: {count}개 + + ))} +
+
+ + {/* 조항 미리보기 (최대 5개) */} +
+
포함된 조항 (일부)
+
+ {selectedClauses.slice(0, 5).map(clause => ( +
+ {clause.itemNumber} + {clause.subtitle} +
+ ))} + {selectedClauses.length > 5 && ( +
+ ... 외 {selectedClauses.length - 5}개 조항 +
+ )} +
+
+
+ +
+ +
+ {/* 분류 수정 */} + ( + + 분류 변경 (선택사항) + + + + + 모든 선택된 조항의 분류가 동일한 값으로 변경됩니다. + + + + )} + /> + + {/* 활성 상태 변경 */} + ( + +
+ 활성 상태 + + 선택된 모든 조항의 활성 상태를 설정합니다. + +
+ + + +
+ )} + /> + + {/* 편집 사유 */} + ( + + 편집 사유 * + +