diff options
Diffstat (limited to 'lib')
| -rw-r--r-- | lib/integration-log/db-logging.ts | 295 | ||||
| -rw-r--r-- | lib/integration-log/rest-logging.ts | 188 | ||||
| -rw-r--r-- | lib/integration-log/saml-logging.ts | 232 | ||||
| -rw-r--r-- | lib/integration-log/service.ts | 317 | ||||
| -rw-r--r-- | lib/integration-log/table/integration-log-table-columns.tsx | 238 | ||||
| -rw-r--r-- | lib/integration-log/table/integration-log-table.tsx | 112 | ||||
| -rw-r--r-- | lib/integration-log/validations.ts | 36 | ||||
| -rw-r--r-- | lib/integration/service.ts | 226 | ||||
| -rw-r--r-- | lib/integration/table/delete-integration-dialog.tsx | 154 | ||||
| -rw-r--r-- | lib/integration/table/integration-add-dialog.tsx | 272 | ||||
| -rw-r--r-- | lib/integration/table/integration-delete-dialog.tsx | 122 | ||||
| -rw-r--r-- | lib/integration/table/integration-edit-dialog.tsx | 274 | ||||
| -rw-r--r-- | lib/integration/table/integration-edit-sheet.tsx | 278 | ||||
| -rw-r--r-- | lib/integration/table/integration-table-columns.tsx | 214 | ||||
| -rw-r--r-- | lib/integration/table/integration-table-toolbar.tsx | 53 | ||||
| -rw-r--r-- | lib/integration/table/integration-table.tsx | 166 | ||||
| -rw-r--r-- | lib/integration/validations.ts | 99 |
17 files changed, 3276 insertions, 0 deletions
diff --git a/lib/integration-log/db-logging.ts b/lib/integration-log/db-logging.ts new file mode 100644 index 00000000..990b1096 --- /dev/null +++ b/lib/integration-log/db-logging.ts @@ -0,0 +1,295 @@ +"use server"; + +import { logIntegrationExecution } from "./service"; + +/** + * DB 연동 로깅 래퍼 함수 + * + * @description + * 데이터베이스 작업을 자동으로 로깅하는 래퍼 함수입니다. + * 동기화, 삽입, 수정, 삭제 등 다양한 DB 작업의 실행 시간과 결과를 기록합니다. + * + * @param integrationId 인터페이스 ID (추후 매핑 필요) + * @param tableName 테이블명 + * @param operation 작업 유형 (sync, upsert, delete 등) + * @param processor 실제 DB 작업 함수 + * @returns 처리 결과 + * + * @example + * // 기본 DB 동기화 로깅 + * const syncResult = await withDbLogging( + * 1, // 인터페이스 ID + * 'users', + * 'sync', + * async () => { + * // 외부 시스템에서 사용자 데이터 가져오기 + * const externalUsers = await fetchExternalUsers(); + * + * // 로컬 DB에 동기화 + * const result = await syncUsersToLocalDb(externalUsers); + * + * return { + * totalProcessed: result.length, + * updated: result.filter(u => u.action === 'updated').length, + * created: result.filter(u => u.action === 'created').length + * }; + * } + * ); + * + * @example + * // 데이터 삽입/수정 로깅 + * const upsertResult = await withDbLogging( + * 2, + * 'products', + * 'upsert', + * async () => { + * const productData = await getProductDataFromSap(); + * + * // 기존 데이터 확인 후 삽입/수정 + * const existingProduct = await db.products.findFirst({ + * where: { sapId: productData.sapId } + * }); + * + * if (existingProduct) { + * return await db.products.update({ + * where: { id: existingProduct.id }, + * data: productData + * }); + * } else { + * return await db.products.create({ + * data: productData + * }); + * } + * } + * ); + * + * @example + * // 에러 처리와 함께 + * try { + * const deleteResult = await withDbLogging( + * 3, + * 'temp_data', + * 'cleanup', + * async () => { + * // 7일 이전 임시 데이터 삭제 + * const cutoffDate = new Date(); + * cutoffDate.setDate(cutoffDate.getDate() - 7); + * + * const result = await db.tempData.deleteMany({ + * where: { + * createdAt: { lt: cutoffDate } + * } + * }); + * + * return { deletedCount: result.count }; + * } + * ); + * + * console.log(`${deleteResult.deletedCount}개의 임시 데이터가 삭제됨`); + * } catch (error) { + * console.error('DB 정리 작업 실패:', error); + * } + * + * @example + * // 트랜잭션 내에서 사용 + * const transactionResult = await withDbLogging( + * 4, + * 'orders', + * 'bulk_update', + * async () => { + * return await db.$transaction(async (tx) => { + * // 여러 테이블 업데이트 + * const orders = await tx.orders.updateMany({ + * where: { status: 'pending' }, + * data: { status: 'processing' } + * }); + * + * const orderItems = await tx.orderItems.updateMany({ + * where: { order: { status: 'processing' } }, + * data: { processedAt: new Date() } + * }); + * + * return { ordersUpdated: orders.count, itemsUpdated: orderItems.count }; + * }); + * } + * ); + */ +export async function withDbLogging<T>( + integrationId: number, + tableName: string, + operation: string, + processor: () => Promise<T> +): Promise<T> { + const start = Date.now(); + + try { + // 실제 DB 작업 실행 + const result = await processor(); + + const duration = Date.now() - start; + + // 성공 로그 기록 + await logIntegrationExecution({ + integrationId, + status: 'success', + responseTime: duration, + requestMethod: 'DB', + requestUrl: `${operation}:${tableName}`, + correlationId: `db_${tableName}_${Date.now()}`, + }); + + return result; + + } catch (error) { + const duration = Date.now() - start; + + // 실패 로그 기록 + await logIntegrationExecution({ + integrationId, + status: 'failed', + responseTime: duration, + errorMessage: error instanceof Error ? error.message : 'Unknown error', + requestMethod: 'DB', + requestUrl: `${operation}:${tableName}`, + correlationId: `db_${tableName}_${Date.now()}`, + }); + + throw error; + } +} + +/** + * nonsap 동기화 로깅 헬퍼 함수 + * + * @description + * Non-SAP 시스템과의 데이터 동기화를 로깅하는 전용 헬퍼 함수입니다. + * 전체 동기화(full)와 증분 동기화(delta) 모두 지원합니다. + * + * @param tableName 테이블명 + * @param syncType 동기화 유형 (full, delta) + * @param processor 동기화 작업 함수 + * @returns 처리 결과 + * + * @example + * // 전체 동기화 로깅 + * const fullSyncResult = await withNonsapSyncLogging( + * 'vendors', + * 'full', + * async () => { + * // 외부 시스템에서 전체 벤더 데이터 가져오기 + * const allVendors = await fetchAllVendorsFromExternalSystem(); + * + * // 기존 데이터 모두 삭제 후 재생성 + * await db.vendors.deleteMany({}); + * + * // 새 데이터 삽입 + * const created = await db.vendors.createMany({ + * data: allVendors + * }); + * + * return { + * syncType: 'full', + * totalProcessed: allVendors.length, + * created: created.count, + * updated: 0, + * deleted: 0 + * }; + * } + * ); + * + * @example + * // 증분 동기화 로깅 + * const deltaSyncResult = await withNonsapSyncLogging( + * 'purchase_orders', + * 'delta', + * async () => { + * // 마지막 동기화 이후 변경된 데이터만 가져오기 + * const lastSync = await getLastSyncTimestamp('purchase_orders'); + * const changedOrders = await fetchChangedOrdersSince(lastSync); + * + * let created = 0, updated = 0, deleted = 0; + * + * for (const order of changedOrders) { + * if (order.isDeleted) { + * // 삭제된 데이터 처리 + * await db.purchaseOrders.delete({ where: { externalId: order.id } }); + * deleted++; + * } else { + * // 삽입/수정 데이터 처리 + * const result = await db.purchaseOrders.upsert({ + * where: { externalId: order.id }, + * create: order, + * update: order + * }); + * + * if (result.createdAt === result.updatedAt) { + * created++; + * } else { + * updated++; + * } + * } + * } + * + * // 동기화 타임스탬프 업데이트 + * await updateLastSyncTimestamp('purchase_orders', new Date()); + * + * return { + * syncType: 'delta', + * totalProcessed: changedOrders.length, + * created, + * updated, + * deleted + * }; + * } + * ); + * + * @example + * // 에러 복구가 포함된 동기화 + * const resilientSyncResult = await withNonsapSyncLogging( + * 'inventory', + * 'delta', + * async () => { + * let processedCount = 0; + * let errorCount = 0; + * const errors: string[] = []; + * + * const inventoryUpdates = await fetchInventoryUpdates(); + * + * for (const update of inventoryUpdates) { + * try { + * await db.inventory.upsert({ + * where: { productId: update.productId }, + * create: update, + * update: { quantity: update.quantity, updatedAt: new Date() } + * }); + * processedCount++; + * } catch (error) { + * errorCount++; + * errors.push(`Product ${update.productId}: ${error.message}`); + * + * // 개별 에러는 로그에 남기지만 전체 작업은 계속 진행 + * console.warn(`재고 업데이트 실패 - ${update.productId}:`, error); + * } + * } + * + * return { + * totalItems: inventoryUpdates.length, + * processedCount, + * errorCount, + * errors: errors.slice(0, 10) // 최대 10개 에러만 반환 + * }; + * } + * ); + */ +export async function withNonsapSyncLogging<T>( + tableName: string, + syncType: 'full' | 'delta', + processor: () => Promise<T> +): Promise<T> { + return withDbLogging( + 2, // nonsap 동기화 인터페이스 ID (추후 매핑 필요) + tableName, + `nonsap_${syncType}_sync`, + processor + ); +}
\ No newline at end of file diff --git a/lib/integration-log/rest-logging.ts b/lib/integration-log/rest-logging.ts new file mode 100644 index 00000000..e84d656d --- /dev/null +++ b/lib/integration-log/rest-logging.ts @@ -0,0 +1,188 @@ +"use server"; + +import { logIntegrationExecution } from "./service"; + +/** + * REST API 호출 로깅 래퍼 함수 + * + * @description + * REST API 호출을 자동으로 로깅하는 래퍼 함수입니다. + * 요청 시작부터 완료까지의 시간을 측정하고, 성공/실패 여부를 기록합니다. + * + * @param integrationId 인터페이스 ID (추후 매핑 필요) + * @param url 요청 URL + * @param method HTTP 메소드 + * @param requestData 요청 데이터 + * @param processor 실제 fetch 함수 + * @returns 처리 결과 + * + * @example + * // 기본 사용법 - 커스텀 처리 함수와 함께 + * const result = await withRestLogging( + * 1, // 인터페이스 ID + * 'https://api.example.com/users', + * 'GET', + * undefined, + * async () => { + * const response = await fetch('https://api.example.com/users'); + * return response.json(); + * } + * ); + * + * @example + * // POST 요청 with 데이터 + * const userData = { name: 'John', email: 'john@example.com' }; + * const createdUser = await withRestLogging( + * 2, + * 'https://api.example.com/users', + * 'POST', + * userData, + * async () => { + * const response = await fetch('https://api.example.com/users', { + * method: 'POST', + * headers: { 'Content-Type': 'application/json' }, + * body: JSON.stringify(userData) + * }); + * return response.json(); + * } + * ); + * + * @example + * // 에러 처리와 함께 + * try { + * const result = await withRestLogging( + * 3, + * 'https://api.example.com/data', + * 'GET', + * undefined, + * async () => { + * const response = await fetch('https://api.example.com/data'); + * if (!response.ok) { + * throw new Error(`API Error: ${response.status}`); + * } + * return response.json(); + * } + * ); + * } catch (error) { + * console.error('API 호출 실패:', error); + * } + */ +export async function withRestLogging<T>( + integrationId: number, + url: string, + method: string, + requestData?: unknown, + processor?: () => Promise<T> +): Promise<T> { + const start = Date.now(); + + try { + let result: T; + + if (processor) { + // 커스텀 처리 함수가 있는 경우 + result = await processor(); + } else { + // 기본 fetch 처리 + const response = await fetch(url, { + method, + headers: { + 'Content-Type': 'application/json', + }, + body: requestData ? JSON.stringify(requestData) : undefined, + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + result = await response.json() as T; + } + + const duration = Date.now() - start; + + // 성공 로그 기록 + await logIntegrationExecution({ + integrationId, + status: 'success', + responseTime: duration, + requestMethod: method, + requestUrl: url, + correlationId: `rest_${Date.now()}`, + }); + + return result; + + } catch (error) { + const duration = Date.now() - start; + + // 실패 로그 기록 + await logIntegrationExecution({ + integrationId, + status: 'failed', + responseTime: duration, + errorMessage: error instanceof Error ? error.message : 'Unknown error', + requestMethod: method, + requestUrl: url, + correlationId: `rest_${Date.now()}`, + }); + + throw error; + } +} + +/** + * 기존 fetch 호출을 로깅 버전으로 래핑하는 헬퍼 함수 + * + * @description + * 표준 fetch API를 사용하면서 자동으로 로깅하고 싶을 때 사용하는 헬퍼 함수입니다. + * 기존 fetch 호출을 최소한의 변경으로 로깅 기능을 추가할 수 있습니다. + * + * @param integrationId 인터페이스 ID + * @param url 요청 URL + * @param options fetch 옵션 + * @returns fetch 결과 + * + * @example + * // 기본 GET 요청 + * const response = await fetchWithLogging( + * 1, + * 'https://api.example.com/users' + * ); + * const users = await response.json(); + * + * @example + * // POST 요청 with 옵션 + * const response = await fetchWithLogging( + * 2, + * 'https://api.example.com/users', + * { + * method: 'POST', + * headers: { + * 'Content-Type': 'application/json', + * 'Authorization': 'Bearer token' + * }, + * body: JSON.stringify({ name: 'John' }) + * } + * ); + * + * @example + * // 기존 fetch 호출 대체 + * // 기존: const response = await fetch('/api/data'); + * // 새로운: const response = await fetchWithLogging(1, '/api/data'); + */ +export async function fetchWithLogging( + integrationId: number, + url: string, + options: RequestInit = {} +): Promise<Response> { + return withRestLogging( + integrationId, + url, + options.method || 'GET', + options.body, + async () => { + return fetch(url, options); + } + ); +}
\ No newline at end of file diff --git a/lib/integration-log/saml-logging.ts b/lib/integration-log/saml-logging.ts new file mode 100644 index 00000000..ba361d14 --- /dev/null +++ b/lib/integration-log/saml-logging.ts @@ -0,0 +1,232 @@ +"use server"; + +import { logIntegrationExecution } from "./service"; + +/** + * SAML 인증 로깅 래퍼 함수 + * + * @description + * SAML 인증 과정을 자동으로 로깅하는 래퍼 함수입니다. + * 로그인, 로그아웃, SSO 등 다양한 SAML 작업의 실행 시간과 결과를 기록합니다. + * + * @param integrationId 인터페이스 ID (추후 매핑 필요) + * @param operation 작업 유형 (login, logout, sso 등) + * @param userId 사용자 ID (선택사항) + * @param processor 실제 SAML 처리 함수 + * @returns 처리 결과 + * + * @example + * // 기본 SAML 작업 로깅 + * const result = await withSamlLogging( + * 3, // SAML 인터페이스 ID + * 'validate', + * 'user123', + * async () => { + * // SAML 토큰 검증 로직 + * const isValid = await validateSamlToken(token); + * return { isValid, userId: 'user123' }; + * } + * ); + * + * @example + * // 에러 처리와 함께 + * try { + * const authResult = await withSamlLogging( + * 3, + * 'authenticate', + * 'user456', + * async () => { + * const userInfo = await authenticateUser(samlResponse); + * if (!userInfo) { + * throw new Error('Authentication failed'); + * } + * return userInfo; + * } + * ); + * } catch (error) { + * console.error('SAML 인증 실패:', error); + * } + * + * @example + * // 익명 사용자 작업 + * const metadata = await withSamlLogging( + * 3, + * 'get_metadata', + * undefined, // 익명 사용자 + * async () => { + * return await getSamlMetadata(); + * } + * ); + */ +export async function withSamlLogging<T>( + integrationId: number, + operation: string, + userId?: string, + processor?: () => Promise<T> +): Promise<T> { + const start = Date.now(); + + try { + let result: T; + + if (processor) { + result = await processor(); + } else { + // 기본 처리가 없는 경우 빈 결과 반환 + result = {} as T; + } + + const duration = Date.now() - start; + + // 성공 로그 기록 + await logIntegrationExecution({ + integrationId, + status: 'success', + responseTime: duration, + requestMethod: 'SAML', + requestUrl: `saml_${operation}`, + correlationId: `saml_${operation}_${userId || 'anonymous'}_${Date.now()}`, + }); + + return result; + + } catch (error) { + const duration = Date.now() - start; + + // 실패 로그 기록 + await logIntegrationExecution({ + integrationId, + status: 'failed', + responseTime: duration, + errorMessage: error instanceof Error ? error.message : 'Unknown error', + requestMethod: 'SAML', + requestUrl: `saml_${operation}`, + correlationId: `saml_${operation}_${userId || 'anonymous'}_${Date.now()}`, + }); + + throw error; + } +} + +/** + * SAML SSO 로그인 로깅 헬퍼 함수 + * + * @description + * SAML을 통한 SSO 로그인 과정을 로깅하는 전용 헬퍼 함수입니다. + * 로그인 성공/실패 여부와 처리 시간을 자동으로 기록합니다. + * + * @param userId 사용자 ID + * @param processor 로그인 처리 함수 + * @returns 처리 결과 + * + * @example + * // 기본 SAML 로그인 로깅 + * const loginResult = await withSamlLoginLogging( + * 'user123', + * async () => { + * // SAML 응답 처리 + * const samlResponse = await parseSamlResponse(req.body); + * const userInfo = await validateAndGetUserInfo(samlResponse); + * + * // 세션 생성 + * const session = await createSession(userInfo); + * + * return { + * success: true, + * user: userInfo, + * sessionId: session.id + * }; + * } + * ); + * + * @example + * // 에러 처리와 함께 + * try { + * const result = await withSamlLoginLogging( + * 'user456', + * async () => { + * const userInfo = await authenticateWithSaml(samlToken); + * if (!userInfo.isActive) { + * throw new Error('User account is disabled'); + * } + * return userInfo; + * } + * ); + * + * // 로그인 성공 후 처리 + * redirect('/dashboard'); + * } catch (error) { + * console.error('SAML 로그인 실패:', error); + * redirect('/login?error=saml_failed'); + * } + */ +export async function withSamlLoginLogging<T>( + userId: string, + processor: () => Promise<T> +): Promise<T> { + return withSamlLogging( + 3, // SAML SSO 인터페이스 ID (추후 매핑 필요) + 'login', + userId, + processor + ); +} + +/** + * SAML SSO 로그아웃 로깅 헬퍼 함수 + * + * @description + * SAML을 통한 SSO 로그아웃 과정을 로깅하는 전용 헬퍼 함수입니다. + * 로그아웃 성공/실패 여부와 처리 시간을 자동으로 기록합니다. + * + * @param userId 사용자 ID + * @param processor 로그아웃 처리 함수 + * @returns 처리 결과 + * + * @example + * // 기본 SAML 로그아웃 로깅 + * const logoutResult = await withSamlLogoutLogging( + * 'user123', + * async () => { + * // 세션 종료 + * await destroySession(sessionId); + * + * // SAML 로그아웃 요청 생성 + * const logoutRequest = await createSamlLogoutRequest(userId); + * + * return { + * success: true, + * logoutUrl: logoutRequest.url, + * sessionDestroyed: true + * }; + * } + * ); + * + * @example + * // 강제 로그아웃 (에러 무시) + * const result = await withSamlLogoutLogging( + * 'user456', + * async () => { + * try { + * await destroySession(sessionId); + * await notifyIdpLogout(userId); + * } catch (error) { + * // 로그아웃 과정에서 에러가 발생해도 계속 진행 + * console.warn('일부 로그아웃 과정에서 에러:', error); + * } + * + * return { success: true, forced: true }; + * } + * ); + */ +export async function withSamlLogoutLogging<T>( + userId: string, + processor: () => Promise<T> +): Promise<T> { + return withSamlLogging( + 3, // SAML SSO 인터페이스 ID (추후 매핑 필요) + 'logout', + userId, + processor + ); +}
\ No newline at end of file diff --git a/lib/integration-log/service.ts b/lib/integration-log/service.ts new file mode 100644 index 00000000..e42fcfde --- /dev/null +++ b/lib/integration-log/service.ts @@ -0,0 +1,317 @@ +"use server"; + +import db from "@/db/db"; +import { integrationLogTable } from "@/db/schema/integration-log"; +import { integrations } from "@/db/schema/integration"; +import { eq } from "drizzle-orm"; +import { GetIntegrationLogsSchema } from "./validations"; +import { filterColumns } from "@/lib/filter-columns"; +import { asc, desc, ilike, and, or, count } from "drizzle-orm"; +import { NewIntegrationLog } from "@/db/schema/integration-log"; + +/* ----------------------------------------------------- + 1) 통합 로그 목록 조회 +----------------------------------------------------- */ +export async function getIntegrationLogs(input: GetIntegrationLogsSchema) { + try { + const offset = (input.page - 1) * input.perPage; + + // 1. where 절 + let advancedWhere; + try { + advancedWhere = filterColumns({ + table: integrationLogTable, + filters: input.filters, + joinOperator: input.joinOperator, + }); + } catch (whereErr) { + console.error("Error building advanced where:", whereErr); + advancedWhere = undefined; + } + + let globalWhere; + if (input.search) { + try { + const s = `%${input.search}%`; + globalWhere = or( + ilike(integrationLogTable.status, s), + ilike(integrationLogTable.errorMessage, s), + ilike(integrationLogTable.requestUrl, s), + ilike(integrationLogTable.requestMethod, s) + ); + } catch (searchErr) { + console.error("Error building search where:", searchErr); + globalWhere = undefined; + } + } + + // 2. where 결합 + let finalWhere; + const whereArr = [advancedWhere, globalWhere].filter(Boolean); + if (whereArr.length === 2) { + finalWhere = and(...whereArr); + } else if (whereArr.length === 1) { + finalWhere = whereArr[0]; + } else { + finalWhere = undefined; + } + + // 3. order by + let orderBy = [desc(integrationLogTable.executionTime)]; + try { + if (input.sort.length > 0) { + const sortItems = input.sort + .map((item) => { + if (!item || !item.id || typeof item.id !== "string") return null; + + // 기본 정렬 컬럼들만 허용 + switch (item.id) { + case "id": + return item.desc ? desc(integrationLogTable.id) : asc(integrationLogTable.id); + case "integrationId": + return item.desc ? desc(integrationLogTable.integrationId) : asc(integrationLogTable.integrationId); + case "executionTime": + return item.desc ? desc(integrationLogTable.executionTime) : asc(integrationLogTable.executionTime); + case "status": + return item.desc ? desc(integrationLogTable.status) : asc(integrationLogTable.status); + case "responseTime": + return item.desc ? desc(integrationLogTable.responseTime) : asc(integrationLogTable.responseTime); + case "errorMessage": + return item.desc ? desc(integrationLogTable.errorMessage) : asc(integrationLogTable.errorMessage); + case "httpStatusCode": + return item.desc ? desc(integrationLogTable.httpStatusCode) : asc(integrationLogTable.httpStatusCode); + case "retryCount": + return item.desc ? desc(integrationLogTable.retryCount) : asc(integrationLogTable.retryCount); + case "requestMethod": + return item.desc ? desc(integrationLogTable.requestMethod) : asc(integrationLogTable.requestMethod); + case "requestUrl": + return item.desc ? desc(integrationLogTable.requestUrl) : asc(integrationLogTable.requestUrl); + case "ipAddress": + return item.desc ? desc(integrationLogTable.ipAddress) : asc(integrationLogTable.ipAddress); + case "userAgent": + return item.desc ? desc(integrationLogTable.userAgent) : asc(integrationLogTable.userAgent); + case "sessionId": + return item.desc ? desc(integrationLogTable.sessionId) : asc(integrationLogTable.sessionId); + case "correlationId": + return item.desc ? desc(integrationLogTable.correlationId) : asc(integrationLogTable.correlationId); + case "createdAt": + return item.desc ? desc(integrationLogTable.createdAt) : asc(integrationLogTable.createdAt); + default: + return null; + } + }) + .filter((v): v is Exclude<typeof v, null> => v !== null); + + if (sortItems.length > 0) { + orderBy = sortItems; + } + } + } catch (orderErr) { + console.error("Error building order by:", orderErr); + } + + // 4. 쿼리 실행 + let data = []; + let total = 0; + + try { + const queryBuilder = db.select({ + id: integrationLogTable.id, + integrationId: integrationLogTable.integrationId, + executionTime: integrationLogTable.executionTime, + status: integrationLogTable.status, + responseTime: integrationLogTable.responseTime, + errorMessage: integrationLogTable.errorMessage, + httpStatusCode: integrationLogTable.httpStatusCode, + retryCount: integrationLogTable.retryCount, + requestMethod: integrationLogTable.requestMethod, + requestUrl: integrationLogTable.requestUrl, + ipAddress: integrationLogTable.ipAddress, + userAgent: integrationLogTable.userAgent, + sessionId: integrationLogTable.sessionId, + correlationId: integrationLogTable.correlationId, + createdAt: integrationLogTable.createdAt, + // 통합 정보 + code: integrations.code, + name: integrations.name, + type: integrations.type, + sourceSystem: integrations.sourceSystem, + targetSystem: integrations.targetSystem, + integrationStatus: integrations.status, + }) + .from(integrationLogTable) + .leftJoin(integrations, eq(integrationLogTable.integrationId, integrations.id)); + + if (finalWhere !== undefined) { + queryBuilder.where(finalWhere); + } + + if (orderBy && orderBy.length > 0) { + queryBuilder.orderBy(...orderBy); + } + if (typeof offset === "number" && !isNaN(offset)) { + queryBuilder.offset(offset); + } + if (typeof input.perPage === "number" && !isNaN(input.perPage)) { + queryBuilder.limit(input.perPage); + } + + data = await queryBuilder; + + const countBuilder = db + .select({ count: count() }) + .from(integrationLogTable); + + if (finalWhere !== undefined) { + countBuilder.where(finalWhere); + } + + const countResult = await countBuilder; + total = countResult[0]?.count || 0; + } catch (queryErr) { + console.error("Query execution failed:", queryErr); + throw queryErr; + } + + const pageCount = Math.ceil(total / input.perPage); + + return { data, pageCount }; + } catch (err) { + console.error("Error in getIntegrationLogs:", err); + if (err instanceof Error) { + console.error("Error message:", err.message); + console.error("Error stack:", err.stack); + } + return { data: [], pageCount: 0 }; + } +} + +/* ----------------------------------------------------- + 2) 통합 로그 상세 조회 +----------------------------------------------------- */ +export async function getIntegrationLogById(id: number) { + try { + const [result] = await db.select({ + id: integrationLogTable.id, + integrationId: integrationLogTable.integrationId, + executionTime: integrationLogTable.executionTime, + status: integrationLogTable.status, + responseTime: integrationLogTable.responseTime, + errorMessage: integrationLogTable.errorMessage, + httpStatusCode: integrationLogTable.httpStatusCode, + retryCount: integrationLogTable.retryCount, + requestMethod: integrationLogTable.requestMethod, + requestUrl: integrationLogTable.requestUrl, + ipAddress: integrationLogTable.ipAddress, + userAgent: integrationLogTable.userAgent, + sessionId: integrationLogTable.sessionId, + correlationId: integrationLogTable.correlationId, + createdAt: integrationLogTable.createdAt, + // 통합 정보 + code: integrations.code, + name: integrations.name, + type: integrations.type, + sourceSystem: integrations.sourceSystem, + targetSystem: integrations.targetSystem, + integrationStatus: integrations.status, + }) + .from(integrationLogTable) + .leftJoin(integrations, eq(integrationLogTable.integrationId, integrations.id)) + .where(eq(integrationLogTable.id, id)); + + return result; + } catch (err) { + console.error("Error getting integration log by id:", err); + return null; + } +} + +/* ----------------------------------------------------- + 3) 통합별 로그 조회 +----------------------------------------------------- */ +export async function getIntegrationLogsByIntegrationId(integrationId: number, limit: number = 50) { + try { + const data = await db.select({ + id: integrationLogTable.id, + integrationId: integrationLogTable.integrationId, + executionTime: integrationLogTable.executionTime, + status: integrationLogTable.status, + responseTime: integrationLogTable.responseTime, + errorMessage: integrationLogTable.errorMessage, + httpStatusCode: integrationLogTable.httpStatusCode, + retryCount: integrationLogTable.retryCount, + requestMethod: integrationLogTable.requestMethod, + requestUrl: integrationLogTable.requestUrl, + ipAddress: integrationLogTable.ipAddress, + userAgent: integrationLogTable.userAgent, + sessionId: integrationLogTable.sessionId, + correlationId: integrationLogTable.correlationId, + createdAt: integrationLogTable.createdAt, + }) + .from(integrationLogTable) + .where(eq(integrationLogTable.integrationId, integrationId)) + .orderBy(desc(integrationLogTable.executionTime)) + .limit(limit); + + return data; + } catch (err) { + console.error("Error getting integration logs by integration id:", err); + return []; + } +} + +/* ----------------------------------------------------- + 로그 저장 함수 추가 +----------------------------------------------------- */ +export async function createIntegrationLog(logData: NewIntegrationLog) { + try { + const [created] = await db.insert(integrationLogTable).values(logData).returning(); + return { data: created }; + } catch (err) { + console.error("Error creating integration log:", err); + return { error: "로그 저장 중 오류가 발생했습니다." }; + } +} + +/* ----------------------------------------------------- + Server Action: 외부에서 호출 가능한 로그 저장 함수 +----------------------------------------------------- */ + +export async function logIntegrationExecution(logData: { + integrationId: number; + status: 'success' | 'failed' | 'timeout' | 'pending'; + responseTime?: number; + errorMessage?: string; + httpStatusCode?: number; + requestMethod?: string; + requestUrl?: string; + ipAddress?: string; + userAgent?: string; + sessionId?: string; + correlationId?: string; + retryCount?: number; +}) { + try { + const logEntry: NewIntegrationLog = { + integrationId: logData.integrationId, + executionTime: new Date(), + status: logData.status, + responseTime: logData.responseTime || 0, + errorMessage: logData.errorMessage || null, + httpStatusCode: logData.httpStatusCode || null, + retryCount: logData.retryCount || 0, + requestMethod: logData.requestMethod || null, + requestUrl: logData.requestUrl || null, + ipAddress: logData.ipAddress || null, + userAgent: logData.userAgent || null, + sessionId: logData.sessionId || null, + correlationId: logData.correlationId || null, + }; + + const result = await createIntegrationLog(logEntry); + return result; + } catch (error) { + console.error("Error in logIntegrationExecution:", error); + return { error: "로그 저장에 실패했습니다." }; + } +}
\ No newline at end of file diff --git a/lib/integration-log/table/integration-log-table-columns.tsx b/lib/integration-log/table/integration-log-table-columns.tsx new file mode 100644 index 00000000..6a955287 --- /dev/null +++ b/lib/integration-log/table/integration-log-table-columns.tsx @@ -0,0 +1,238 @@ +"use client"; +import * as React from "react"; +import { type ColumnDef } from "@tanstack/react-table"; +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"; +import { Badge } from "@/components/ui/badge"; +import { Clock, Activity, Globe, Zap, AlertCircle } from "lucide-react"; +import { formatDateTime } from "@/lib/utils"; +import { integrationLogTable } from "@/db/schema/integration-log"; + +// 확장된 타입 정의 (JOIN 결과) +type IntegrationLogWithIntegration = typeof integrationLogTable.$inferSelect & { + code?: string; + name?: string; + type?: string; + sourceSystem?: string; + targetSystem?: string; + status?: string; +}; + +export function getColumns(): ColumnDef<IntegrationLogWithIntegration>[] { + return [ + { + accessorKey: "name", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="통합명" /> + ), + cell: ({ row }) => { + const name = row.getValue("name") as string; + const code = row.getValue("code") as string; + return ( + <div className="flex items-center gap-2"> + <Activity className="h-4 w-4 text-muted-foreground" /> + <div className="flex flex-col"> + <span className="font-medium">{name || "-"}</span> + {code && ( + <span className="text-xs text-muted-foreground">{code}</span> + )} + </div> + </div> + ); + }, + enableResizing: true, + minSize: 150, + size: 200, + }, + { + accessorKey: "type", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="타입" /> + ), + cell: ({ row }) => { + const type = row.getValue("type") as string; + if (!type) return <span>-</span>; + + const typeMap: Record<string, { label: string; variant: "default" | "secondary" | "outline" }> = { + rest_api: { label: "REST API", variant: "default" }, + soap: { label: "SOAP", variant: "secondary" }, + db_to_db: { label: "DB to DB", variant: "outline" }, + }; + + const config = typeMap[type] || { label: type, variant: "outline" }; + + return ( + <Badge variant={config.variant}> + {config.label} + </Badge> + ); + }, + enableResizing: true, + minSize: 100, + size: 120, + }, + { + accessorKey: "status", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="상태" /> + ), + cell: ({ row }) => { + const status = row.getValue("status") as string; + const statusMap: Record<string, { label: string; variant: "default" | "secondary" | "destructive" | "outline" }> = { + success: { label: "성공", variant: "default" }, + failed: { label: "실패", variant: "destructive" }, + timeout: { label: "타임아웃", variant: "secondary" }, + pending: { label: "대기중", variant: "outline" }, + }; + + const config = statusMap[status] || { label: status, variant: "outline" }; + + return ( + <Badge variant={config.variant}> + {config.label} + </Badge> + ); + }, + enableResizing: true, + minSize: 100, + size: 120, + }, + { + accessorKey: "requestMethod", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="메서드" /> + ), + cell: ({ row }) => { + const method = row.getValue("requestMethod") as string; + if (!method) return <span>-</span>; + + const methodColors: Record<string, string> = { + GET: "bg-blue-100 text-blue-800", + POST: "bg-green-100 text-green-800", + PUT: "bg-yellow-100 text-yellow-800", + DELETE: "bg-red-100 text-red-800", + }; + + return ( + <span className={`px-2 py-1 rounded text-xs font-mono ${methodColors[method] || "bg-gray-100 text-gray-800"}`}> + {method} + </span> + ); + }, + enableResizing: true, + minSize: 80, + size: 100, + }, + { + accessorKey: "httpStatusCode", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="상태코드" /> + ), + cell: ({ row }) => { + const statusCode = row.getValue("httpStatusCode") as number; + if (!statusCode) return <span>-</span>; + + const isClientError = statusCode >= 400 && statusCode < 500; + const isServerError = statusCode >= 500; + + let colorClass = "text-green-600"; + if (isClientError) colorClass = "text-yellow-600"; + if (isServerError) colorClass = "text-red-600"; + + return ( + <span className={`font-mono text-sm ${colorClass}`}> + {statusCode} + </span> + ); + }, + enableResizing: true, + minSize: 100, + size: 120, + }, + { + accessorKey: "responseTime", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="응답시간" /> + ), + cell: ({ row }) => { + const responseTime = row.getValue("responseTime") as number; + if (!responseTime) return <span>-</span>; + + let colorClass = "text-green-600"; + if (responseTime > 1000) colorClass = "text-yellow-600"; + if (responseTime > 5000) colorClass = "text-red-600"; + + return ( + <div className="flex items-center gap-2"> + <Zap className="h-4 w-4 text-muted-foreground" /> + <span className={`text-sm ${colorClass}`}> + {responseTime}ms + </span> + </div> + ); + }, + enableResizing: true, + minSize: 100, + size: 120, + }, + { + accessorKey: "executionTime", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="실행시간" /> + ), + cell: ({ cell }) => { + const executionTime = cell.getValue() as Date; + return ( + <div className="flex items-center gap-2"> + <Clock className="h-4 w-4 text-muted-foreground" /> + <span className="text-sm">{formatDateTime(executionTime)}</span> + </div> + ); + }, + enableResizing: true, + minSize: 150, + size: 180, + }, + { + accessorKey: "requestUrl", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="요청 URL" /> + ), + cell: ({ row }) => { + const url = row.getValue("requestUrl") as string; + return ( + <div className="flex items-center gap-2"> + <Globe className="h-4 w-4 text-muted-foreground" /> + <div className="max-w-[200px] truncate text-sm"> + {url || "-"} + </div> + </div> + ); + }, + enableResizing: true, + minSize: 150, + size: 200, + }, + { + accessorKey: "errorMessage", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="에러" /> + ), + cell: ({ row }) => { + const errorMessage = row.getValue("errorMessage") as string; + if (!errorMessage) return <span>-</span>; + + return ( + <div className="flex items-center gap-2"> + <AlertCircle className="h-4 w-4 text-red-500" /> + <div className="max-w-[150px] truncate text-sm text-red-600"> + {errorMessage} + </div> + </div> + ); + }, + enableResizing: true, + minSize: 120, + size: 150, + }, + ]; +}
\ No newline at end of file diff --git a/lib/integration-log/table/integration-log-table.tsx b/lib/integration-log/table/integration-log-table.tsx new file mode 100644 index 00000000..1b62a258 --- /dev/null +++ b/lib/integration-log/table/integration-log-table.tsx @@ -0,0 +1,112 @@ +"use client"; +import * as React from "react"; +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 { + DataTableAdvancedFilterField, +} from "@/types/table" +import { getIntegrationLogs } from "../service"; +import { getColumns } from "./integration-log-table-columns"; + +interface IntegrationLogTableProps { + promises?: Promise<[{ data: Record<string, unknown>[]; pageCount: number }]>; +} + +export function IntegrationLogTable({ promises }: IntegrationLogTableProps) { + const [rawData, setRawData] = React.useState<{ data: Record<string, unknown>[]; pageCount: number }>({ data: [], pageCount: 0 }); + + React.useEffect(() => { + if (promises) { + promises.then(([result]) => { + setRawData(result); + }); + } else { + // fallback: 클라이언트에서 직접 fetch (CSR) + (async () => { + try { + const result = await getIntegrationLogs({ + page: 1, + perPage: 10, + search: "", + sort: [{ id: "executionTime", desc: true }], + filters: [], + joinOperator: "and", + flags: ["advancedTable"], + status: "", + errorMessage: "", + requestUrl: "", + requestMethod: "", + }); + setRawData(result); + } catch (error) { + console.error("Error refreshing data:", error); + } + })(); + } + }, [promises]); + + + + // 컬럼 설정 - 외부 파일에서 가져옴 + const columns = React.useMemo( + () => getColumns() as any, + [] + ) + + // 고급 필터 필드 설정 + const advancedFilterFields: DataTableAdvancedFilterField<Record<string, unknown>>[] = [ + { id: "name", label: "통합명", type: "text" }, + { id: "type", label: "타입", type: "select", options: [ + { label: "REST API", value: "rest_api" }, + { label: "SOAP", value: "soap" }, + { label: "DB to DB", value: "db_to_db" }, + ]}, + { id: "status", label: "상태", type: "select", options: [ + { label: "성공", value: "success" }, + { label: "실패", value: "failed" }, + { label: "타임아웃", value: "timeout" }, + { label: "대기중", value: "pending" }, + ]}, + { id: "requestMethod", label: "메서드", type: "select", options: [ + { label: "GET", value: "GET" }, + { label: "POST", value: "POST" }, + { label: "PUT", value: "PUT" }, + { label: "DELETE", value: "DELETE" }, + ]}, + { id: "httpStatusCode", label: "HTTP 상태코드", type: "number" }, + { id: "responseTime", label: "응답시간", type: "number" }, + { id: "executionTime", label: "실행시간", type: "date" }, + ]; + + const { table } = useDataTable({ + data: rawData.data, + columns, + pageCount: rawData.pageCount, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "executionTime", desc: true }], + }, + getRowId: (originalRow) => String(originalRow.id), + shallow: false, + clearOnDefault: true, + }) + + return ( + <> + <DataTable table={table}> + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + > + <div className="flex items-center gap-2"> + <span className="text-sm text-muted-foreground"> + 총 {rawData.data.length}개의 이력 + </span> + </div> + </DataTableAdvancedToolbar> + </DataTable> + </> + ); +}
\ No newline at end of file diff --git a/lib/integration-log/validations.ts b/lib/integration-log/validations.ts new file mode 100644 index 00000000..41bc6860 --- /dev/null +++ b/lib/integration-log/validations.ts @@ -0,0 +1,36 @@ +import { + createSearchParamsCache, + parseAsArrayOf, + parseAsInteger, + parseAsString, + parseAsStringEnum, +} from "nuqs/server"; +import * as z from "zod"; + +import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"; +import { integrationLogTable } from "@/db/schema/integration-log"; + +export const SearchParamsCache = createSearchParamsCache({ + // UI 모드나 플래그 관련 + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), + + // 페이징 + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + + // 정렬 (executionTime 기준 내림차순) + sort: getSortingStateParser<typeof integrationLogTable>().withDefault([ + { id: "executionTime", desc: true } + ]), + + // 필터링 필드 + status: parseAsString.withDefault(""), + action: parseAsString.withDefault(""), + executedBy: parseAsString.withDefault(""), + interfaceId: parseAsInteger.withDefault(0), + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + search: parseAsString.withDefault(""), +}); + +export type GetIntegrationLogsSchema = Awaited<ReturnType<typeof SearchParamsCache.parse>>;
\ No newline at end of file diff --git a/lib/integration/service.ts b/lib/integration/service.ts new file mode 100644 index 00000000..ad644ca4 --- /dev/null +++ b/lib/integration/service.ts @@ -0,0 +1,226 @@ +"use server"; + +import db from "@/db/db"; +import { integrations } from "@/db/schema/integration"; +import { eq } from "drizzle-orm"; +import { GetIntegrationsSchema } from "./validations"; +import { filterColumns } from "@/lib/filter-columns"; +import { asc, desc, ilike, and, or, count } from "drizzle-orm"; + +/* ----------------------------------------------------- + 1) 통합 목록 조회 +----------------------------------------------------- */ +export async function getIntegrations(input: GetIntegrationsSchema) { + try { + const offset = (input.page - 1) * input.perPage; + + // 1. where 절 + let advancedWhere; + try { + advancedWhere = filterColumns({ + table: integrations, + filters: input.filters, + joinOperator: input.joinOperator, + }); + } catch (whereErr) { + console.error("Error building advanced where:", whereErr); + advancedWhere = undefined; + } + + let globalWhere; + if (input.search) { + try { + const s = `%${input.search}%`; + globalWhere = or( + ilike(integrations.code, s), + ilike(integrations.name, s), + ilike(integrations.sourceSystem, s), + ilike(integrations.targetSystem, s), + ilike(integrations.description, s) + ); + } catch (searchErr) { + console.error("Error building search where:", searchErr); + globalWhere = undefined; + } + } + + // 2. where 결합 + let finalWhere; + const whereArr = [advancedWhere, globalWhere].filter(Boolean); + if (whereArr.length === 2) { + finalWhere = and(...whereArr); + } else if (whereArr.length === 1) { + finalWhere = whereArr[0]; + } else { + finalWhere = undefined; + } + + // 3. order by + let orderBy = [asc(integrations.createdAt)]; + try { + if (input.sort.length > 0) { + const sortItems = input.sort + .map((item) => { + if (!item || !item.id || typeof item.id !== "string") return null; + + // 기본 정렬 컬럼들만 허용 + switch (item.id) { + case "id": + return item.desc ? desc(integrations.id) : asc(integrations.id); + case "code": + return item.desc ? desc(integrations.code) : asc(integrations.code); + case "name": + return item.desc ? desc(integrations.name) : asc(integrations.name); + case "type": + return item.desc ? desc(integrations.type) : asc(integrations.type); + case "status": + return item.desc ? desc(integrations.status) : asc(integrations.status); + case "sourceSystem": + return item.desc ? desc(integrations.sourceSystem) : asc(integrations.sourceSystem); + case "targetSystem": + return item.desc ? desc(integrations.targetSystem) : asc(integrations.targetSystem); + case "createdAt": + return item.desc ? desc(integrations.createdAt) : asc(integrations.createdAt); + case "updatedAt": + return item.desc ? desc(integrations.updatedAt) : asc(integrations.updatedAt); + default: + return null; + } + }) + .filter((v): v is Exclude<typeof v, null> => v !== null); + + if (sortItems.length > 0) { + orderBy = sortItems; + } + } + } catch (orderErr) { + console.error("Error building order by:", orderErr); + } + + // 4. 쿼리 실행 + let data = []; + let total = 0; + + try { + const queryBuilder = db.select().from(integrations); + + if (finalWhere !== undefined) { + queryBuilder.where(finalWhere); + } + + if (orderBy && orderBy.length > 0) { + queryBuilder.orderBy(...orderBy); + } + if (typeof offset === "number" && !isNaN(offset)) { + queryBuilder.offset(offset); + } + if (typeof input.perPage === "number" && !isNaN(input.perPage)) { + queryBuilder.limit(input.perPage); + } + + data = await queryBuilder; + + const countBuilder = db + .select({ count: count() }) + .from(integrations); + + if (finalWhere !== undefined) { + countBuilder.where(finalWhere); + } + + const countResult = await countBuilder; + total = countResult[0]?.count || 0; + } catch (queryErr) { + console.error("Query execution failed:", queryErr); + throw queryErr; + } + + const pageCount = Math.ceil(total / input.perPage); + + return { data, pageCount }; + } catch (err) { + console.error("Error in getIntegrations:", err); + if (err instanceof Error) { + console.error("Error message:", err.message); + console.error("Error stack:", err.stack); + } + return { data: [], pageCount: 0 }; + } +} + +/* ----------------------------------------------------- + 2) 통합 생성 +----------------------------------------------------- */ +export async function createIntegration(data: Omit<typeof integrations.$inferInsert, "id" | "createdAt" | "updatedAt">) { + try { + const [created] = await db.insert(integrations).values({ + ...data, + createdAt: new Date(), + updatedAt: new Date(), + }).returning(); + return { data: created }; + } catch (err) { + console.error("Error creating integration:", err); + return { error: "생성 중 오류가 발생했습니다." }; + } +} + +/* ----------------------------------------------------- + 3) 통합 수정 +----------------------------------------------------- */ +export async function updateIntegration(id: number, data: Partial<typeof integrations.$inferInsert>) { + try { + const [updated] = await db + .update(integrations) + .set({ + ...data, + updatedAt: new Date(), + }) + .where(eq(integrations.id, id)) + .returning(); + return { data: updated }; + } catch (err) { + console.error("Error updating integration:", err); + return { error: "수정 중 오류가 발생했습니다." }; + } +} + +/* ----------------------------------------------------- + 4) 통합 삭제 +----------------------------------------------------- */ +export async function deleteIntegration(id: number) { + try { + await db.delete(integrations).where(eq(integrations.id, id)); + return { success: true }; + } catch (err) { + console.error("Error deleting integration:", err); + return { error: "삭제 중 오류가 발생했습니다." }; + } +} + +// 통합 조회 (단일) +export async function getIntegration(id: number): Promise<ServiceResponse<Integration>> { + try { + const result = await db.select().from(integrations).where(eq(integrations.id, id)).limit(1); + + if (result.length === 0) { + return { error: "통합을 찾을 수 없습니다." }; + } + + return { data: result[0] }; + } catch (error) { + console.error("통합 조회 오류:", error); + return { error: "통합 조회에 실패했습니다." }; + } +} + +// 기존 함수들도 유지 (하위 호환성) +export async function getIntegrationList() { + try { + const data = await db.select().from(integrations); + return data; + } catch (error) { + console.error("통합 목록 조회 실패:", error); + return []; + } +}
\ No newline at end of file diff --git a/lib/integration/table/delete-integration-dialog.tsx b/lib/integration/table/delete-integration-dialog.tsx new file mode 100644 index 00000000..5ce9676d --- /dev/null +++ b/lib/integration/table/delete-integration-dialog.tsx @@ -0,0 +1,154 @@ +"use client" + +import * as React from "react" +import { type Row } from "@tanstack/react-table" +import { Loader, Trash } from "lucide-react" +import { toast } from "sonner" + +import { useMediaQuery } from "@/hooks/use-media-query" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "@/components/ui/drawer" + +import { deleteIntegration } from "../service" +import { integrations } from "@/db/schema/integration" + +interface DeleteIntegrationDialogProps + extends React.ComponentPropsWithoutRef<typeof Dialog> { + integrations: Row<typeof integrations.$inferSelect>["original"][] + showTrigger?: boolean + onSuccess?: () => void +} + +export function DeleteIntegrationDialog({ + integrations: integrationData = [], + showTrigger = true, + onSuccess, + ...props +}: DeleteIntegrationDialogProps) { + const [isDeletePending, startDeleteTransition] = React.useTransition() + const isDesktop = useMediaQuery("(min-width: 640px)") + + function onDelete() { + startDeleteTransition(async () => { + try { + // 각 통합을 순차적으로 삭제 + for (const integrationItem of integrationData) { + const result = await deleteIntegration(integrationItem.id) + if (!result.success) { + toast.error(`인터페이스 ${integrationItem.name} 삭제 실패: ${result.error}`) + return + } + } + + props.onOpenChange?.(false) + toast.success("인터페이스가 성공적으로 삭제되었습니다.") + onSuccess?.() + } catch (error) { + console.error("Delete error:", error) + toast.error("인터페이스 삭제 중 오류가 발생했습니다.") + } + }) + } + + if (isDesktop) { + return ( + <Dialog {...props}> + {showTrigger ? ( + <DialogTrigger asChild> + <Button variant="outline" size="sm"> + <Trash className="mr-2 size-4" aria-hidden="true" /> + Delete ({integrationData?.length ?? 0}) + </Button> + </DialogTrigger> + ) : null} + <DialogContent> + <DialogHeader> + <DialogTitle>정말로 삭제하시겠습니까?</DialogTitle> + <DialogDescription> + 이 작업은 되돌릴 수 없습니다. 선택된{" "} + <span className="font-medium">{integrationData?.length ?? 0}</span> + 개의 인터페이스를 서버에서 영구적으로 삭제합니다. + </DialogDescription> + </DialogHeader> + <DialogFooter className="gap-2 sm:space-x-0"> + <DialogClose asChild> + <Button variant="outline">취소</Button> + </DialogClose> + <Button + aria-label="선택된 행 삭제" + variant="destructive" + onClick={onDelete} + disabled={isDeletePending} + > + {isDeletePending && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + 삭제 + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) + } + + return ( + <Drawer {...props}> + {showTrigger ? ( + <DrawerTrigger asChild> + <Button variant="outline" size="sm"> + <Trash className="mr-2 size-4" aria-hidden="true" /> + Delete ({integrationData?.length ?? 0}) + </Button> + </DrawerTrigger> + ) : null} + <DrawerContent> + <DrawerHeader> + <DrawerTitle>정말로 삭제하시겠습니까?</DrawerTitle> + <DrawerDescription> + 이 작업은 되돌릴 수 없습니다. 선택된{" "} + <span className="font-medium">{integrationData?.length ?? 0}</span> + 개의 인터페이스를 서버에서 영구적으로 삭제합니다. + </DrawerDescription> + </DrawerHeader> + <DrawerFooter className="gap-2 sm:space-x-0"> + <DrawerClose asChild> + <Button variant="outline">취소</Button> + </DrawerClose> + <Button + aria-label="선택된 행 삭제" + variant="destructive" + onClick={onDelete} + disabled={isDeletePending} + > + {isDeletePending && ( + <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> + )} + 삭제 + </Button> + </DrawerFooter> + </DrawerContent> + </Drawer> + ) +}
\ No newline at end of file diff --git a/lib/integration/table/integration-add-dialog.tsx b/lib/integration/table/integration-add-dialog.tsx new file mode 100644 index 00000000..aeab2a5f --- /dev/null +++ b/lib/integration/table/integration-add-dialog.tsx @@ -0,0 +1,272 @@ +"use client"; + +import * as React from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { Plus, Loader2 } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; +import { createIntegration } from "../service"; +import { toast } from "sonner"; + +const createIntegrationSchema = z.object({ + code: z.string().min(1, "코드는 필수입니다."), + name: z.string().min(1, "이름은 필수입니다."), + type: z.enum(["rest_api", "soap", "db_to_db"], { required_error: "타입은 필수입니다." }), + description: z.string().optional(), + sourceSystem: z.string().min(1, "소스 시스템은 필수입니다."), + targetSystem: z.string().min(1, "타겟 시스템은 필수입니다."), + status: z.enum(["active", "inactive", "deprecated"]).default("active"), + metadata: z.any().optional(), +}); + +type CreateIntegrationFormValues = z.infer<typeof createIntegrationSchema>; + +interface IntegrationAddDialogProps { + onSuccess?: () => void; +} + +export function IntegrationAddDialog({ onSuccess }: IntegrationAddDialogProps) { + const [open, setOpen] = React.useState(false); + const [isLoading, setIsLoading] = React.useState(false); + + const form = useForm<CreateIntegrationFormValues>({ + resolver: zodResolver(createIntegrationSchema), + defaultValues: { + code: "", + name: "", + type: "rest_api", + description: "", + sourceSystem: "", + targetSystem: "", + status: "active", + metadata: {}, + }, + }); + + const handleOpenChange = (newOpen: boolean) => { + setOpen(newOpen); + if (!newOpen) { + form.reset(); + } + }; + + const handleCancel = () => { + form.reset(); + setOpen(false); + }; + + const onSubmit = async (data: CreateIntegrationFormValues) => { + setIsLoading(true); + try { + const result = await createIntegration(data); + if (result.data) { + toast.success("인터페이스가 성공적으로 추가되었습니다."); + form.reset(); + setOpen(false); + if (onSuccess) { + onSuccess(); + } + } else { + toast.error(result.error || "생성 중 오류가 발생했습니다."); + } + } catch (error) { + console.error("인터페이스 생성 오류:", error); + toast.error("인터페이스 생성에 실패했습니다."); + } finally { + setIsLoading(false); + } + }; + + return ( + <Dialog open={open} onOpenChange={handleOpenChange}> + <DialogTrigger asChild> + <Button variant="outline" size="sm"> + <Plus className="mr-2 h-4 w-4" /> + 인터페이스 추가 + </Button> + </DialogTrigger> + <DialogContent className="max-w-md"> + <DialogHeader> + <DialogTitle>새 인터페이스 추가</DialogTitle> + <DialogDescription> + 새로운 인터페이스를 추가합니다. 필수 정보를 입력해주세요. + <span className="text-red-500 mt-1 block text-sm">* 표시된 항목은 필수 입력사항입니다.</span> + </DialogDescription> + </DialogHeader> + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> + <FormField + control={form.control} + name="code" + render={({ field }) => ( + <FormItem> + <FormLabel> + 코드 <span className="text-red-500">*</span> + </FormLabel> + <FormControl> + <Input placeholder="INT_OPS_001" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="name" + render={({ field }) => ( + <FormItem> + <FormLabel> + 이름 <span className="text-red-500">*</span> + </FormLabel> + <FormControl> + <Input placeholder="인터페이스 이름" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="type" + render={({ field }) => ( + <FormItem> + <FormLabel> + 타입 <span className="text-red-500">*</span> + </FormLabel> + <Select onValueChange={field.onChange} defaultValue={field.value}> + <FormControl> + <SelectTrigger> + <SelectValue placeholder="타입 선택" /> + </SelectTrigger> + </FormControl> + <SelectContent> + <SelectItem value="rest_api">REST API</SelectItem> + <SelectItem value="soap">SOAP</SelectItem> + <SelectItem value="db_to_db">DB to DB</SelectItem> + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="sourceSystem" + render={({ field }) => ( + <FormItem> + <FormLabel> + 소스 시스템 <span className="text-red-500">*</span> + </FormLabel> + <FormControl> + <Input placeholder="ERP, WMS 등" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="targetSystem" + render={({ field }) => ( + <FormItem> + <FormLabel> + 타겟 시스템 <span className="text-red-500">*</span> + </FormLabel> + <FormControl> + <Input placeholder="ERP, WMS 등" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="status" + render={({ field }) => ( + <FormItem> + <FormLabel> + 상태 <span className="text-red-500">*</span> + </FormLabel> + <Select onValueChange={field.onChange} defaultValue={field.value}> + <FormControl> + <SelectTrigger> + <SelectValue placeholder="상태 선택" /> + </SelectTrigger> + </FormControl> + <SelectContent> + <SelectItem value="active">활성</SelectItem> + <SelectItem value="inactive">비활성</SelectItem> + <SelectItem value="deprecated">사용중단</SelectItem> + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="description" + render={({ field }) => ( + <FormItem> + <FormLabel>설명</FormLabel> + <FormControl> + <Textarea placeholder="인터페이스에 대한 설명" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </form> + </Form> + + <DialogFooter> + <Button + type="button" + variant="outline" + onClick={handleCancel} + disabled={isLoading} + > + 취소 + </Button> + <Button + type="submit" + onClick={form.handleSubmit(onSubmit)} + disabled={isLoading} + > + {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} + {isLoading ? "생성 중..." : "추가"} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +}
\ No newline at end of file diff --git a/lib/integration/table/integration-delete-dialog.tsx b/lib/integration/table/integration-delete-dialog.tsx new file mode 100644 index 00000000..dfabd17f --- /dev/null +++ b/lib/integration/table/integration-delete-dialog.tsx @@ -0,0 +1,122 @@ +"use client"; + +import * as React from "react"; +import { Trash2, Loader2 } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { deleteIntegration } from "../service"; +import { toast } from "sonner"; +import type { Integration } from "../validations"; + +interface IntegrationDeleteDialogProps { + integration: Integration; + onSuccess?: () => void; +} + +export function IntegrationDeleteDialog({ integration, onSuccess }: IntegrationDeleteDialogProps) { + const [open, setOpen] = React.useState(false); + const [isLoading, setIsLoading] = React.useState(false); + + const handleOpenChange = (newOpen: boolean) => { + setOpen(newOpen); + }; + + const handleCancel = () => { + setOpen(false); + }; + + const handleDelete = async () => { + setIsLoading(true); + try { + const result = await deleteIntegration(integration.id); + if (result.success) { + toast.success("인터페이스가 성공적으로 삭제되었습니다."); + setOpen(false); + if (onSuccess) { + onSuccess(); + } + } else { + toast.error(result.error || "삭제 중 오류가 발생했습니다."); + } + } catch (error) { + console.error("인터페이스 삭제 오류:", error); + toast.error("인터페이스 삭제에 실패했습니다."); + } finally { + setIsLoading(false); + } + }; + + return ( + <Dialog open={open} onOpenChange={handleOpenChange}> + <DialogTrigger asChild> + <Button variant="outline" size="sm" className="text-red-600 hover:text-red-700"> + <Trash2 className="mr-2 h-4 w-4" /> + 삭제 + </Button> + </DialogTrigger> + <DialogContent className="max-w-md"> + <DialogHeader> + <DialogTitle>인터페이스 삭제</DialogTitle> + <DialogDescription> + 정말로 이 인터페이스를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다. + </DialogDescription> + </DialogHeader> + + <div className="space-y-4"> + <div className="rounded-lg border p-4"> + <h4 className="font-medium mb-2">삭제할 인터페이스 정보</h4> + <div className="space-y-2 text-sm"> + <div> + <span className="font-medium">코드:</span> {integration.code} + </div> + <div> + <span className="font-medium">이름:</span> {integration.name} + </div> + <div> + <span className="font-medium">타입:</span> {integration.type} + </div> + <div> + <span className="font-medium">소스 시스템:</span> {integration.sourceSystem} + </div> + <div> + <span className="font-medium">타겟 시스템:</span> {integration.targetSystem} + </div> + <div> + <span className="font-medium">상태:</span> {integration.status} + </div> + </div> + </div> + </div> + + <DialogFooter> + <Button + type="button" + variant="outline" + onClick={handleCancel} + disabled={isLoading} + > + 취소 + </Button> + <Button + type="button" + variant="destructive" + onClick={handleDelete} + disabled={isLoading} + > + {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} + {isLoading ? "삭제 중..." : "삭제"} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +}
\ No newline at end of file diff --git a/lib/integration/table/integration-edit-dialog.tsx b/lib/integration/table/integration-edit-dialog.tsx new file mode 100644 index 00000000..8ded0ee9 --- /dev/null +++ b/lib/integration/table/integration-edit-dialog.tsx @@ -0,0 +1,274 @@ +"use client"; + +import * as React from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { Edit, Loader2 } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; +import { updateIntegration } from "../service"; +import { toast } from "sonner"; +import type { Integration } from "../validations"; + +const updateIntegrationSchema = z.object({ + code: z.string().min(1, "코드는 필수입니다."), + name: z.string().min(1, "이름은 필수입니다."), + type: z.enum(["rest_api", "soap", "db_to_db"], { required_error: "타입은 필수입니다." }), + description: z.string().optional(), + sourceSystem: z.string().min(1, "소스 시스템은 필수입니다."), + targetSystem: z.string().min(1, "타겟 시스템은 필수입니다."), + status: z.enum(["active", "inactive", "deprecated"]), + metadata: z.any().optional(), +}); + +type UpdateIntegrationFormValues = z.infer<typeof updateIntegrationSchema>; + +interface IntegrationEditDialogProps { + integration: Integration; + onSuccess?: () => void; +} + +export function IntegrationEditDialog({ integration, onSuccess }: IntegrationEditDialogProps) { + const [open, setOpen] = React.useState(false); + const [isLoading, setIsLoading] = React.useState(false); + + const form = useForm<UpdateIntegrationFormValues>({ + resolver: zodResolver(updateIntegrationSchema), + defaultValues: { + code: integration.code || "", + name: integration.name || "", + type: integration.type || "rest_api", + description: integration.description || "", + sourceSystem: integration.sourceSystem || "", + targetSystem: integration.targetSystem || "", + status: integration.status || "active", + metadata: integration.metadata || {}, + }, + }); + + const handleOpenChange = (newOpen: boolean) => { + setOpen(newOpen); + if (!newOpen) { + form.reset(); + } + }; + + const handleCancel = () => { + form.reset(); + setOpen(false); + }; + + const onSubmit = async (data: UpdateIntegrationFormValues) => { + setIsLoading(true); + try { + const result = await updateIntegration(integration.id, data); + if (result.data) { + toast.success("인터페이스가 성공적으로 수정되었습니다."); + form.reset(); + setOpen(false); + if (onSuccess) { + onSuccess(); + } + } else { + toast.error(result.error || "수정 중 오류가 발생했습니다."); + } + } catch (error) { + console.error("인터페이스 수정 오류:", error); + toast.error("인터페이스 수정에 실패했습니다."); + } finally { + setIsLoading(false); + } + }; + + return ( + <Dialog open={open} onOpenChange={handleOpenChange}> + <DialogTrigger asChild> + <Button variant="outline" size="sm"> + <Edit className="mr-2 h-4 w-4" /> + 수정 + </Button> + </DialogTrigger> + <DialogContent className="max-w-md"> + <DialogHeader> + <DialogTitle>인터페이스 수정</DialogTitle> + <DialogDescription> + 인터페이스 정보를 수정합니다. 필수 정보를 입력해주세요. + <span className="text-red-500 mt-1 block text-sm">* 표시된 항목은 필수 입력사항입니다.</span> + </DialogDescription> + </DialogHeader> + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> + <FormField + control={form.control} + name="code" + render={({ field }) => ( + <FormItem> + <FormLabel> + 코드 <span className="text-red-500">*</span> + </FormLabel> + <FormControl> + <Input placeholder="INT_OPS_001" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="name" + render={({ field }) => ( + <FormItem> + <FormLabel> + 이름 <span className="text-red-500">*</span> + </FormLabel> + <FormControl> + <Input placeholder="인터페이스 이름" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="type" + render={({ field }) => ( + <FormItem> + <FormLabel> + 타입 <span className="text-red-500">*</span> + </FormLabel> + <Select onValueChange={field.onChange} defaultValue={field.value}> + <FormControl> + <SelectTrigger> + <SelectValue placeholder="타입 선택" /> + </SelectTrigger> + </FormControl> + <SelectContent> + <SelectItem value="rest_api">REST API</SelectItem> + <SelectItem value="soap">SOAP</SelectItem> + <SelectItem value="db_to_db">DB to DB</SelectItem> + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="sourceSystem" + render={({ field }) => ( + <FormItem> + <FormLabel> + 소스 시스템 <span className="text-red-500">*</span> + </FormLabel> + <FormControl> + <Input placeholder="ERP, WMS 등" {...field} /> + </FormControl>ㄹ + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="targetSystem" + render={({ field }) => ( + <FormItem> + <FormLabel> + 타겟 시스템 <span className="text-red-500">*</span> + </FormLabel> + <FormControl> + <Input placeholder="ERP, WMS 등" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="status" + render={({ field }) => ( + <FormItem> + <FormLabel> + 상태 <span className="text-red-500">*</span> + </FormLabel> + <Select onValueChange={field.onChange} defaultValue={field.value}> + <FormControl> + <SelectTrigger> + <SelectValue placeholder="상태 선택" /> + </SelectTrigger> + </FormControl> + <SelectContent> + <SelectItem value="active">활성</SelectItem> + <SelectItem value="inactive">비활성</SelectItem> + <SelectItem value="deprecated">사용중단</SelectItem> + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="description" + render={({ field }) => ( + <FormItem> + <FormLabel>설명</FormLabel> + <FormControl> + <Textarea placeholder="인터페이스에 대한 설명" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </form> + </Form> + + <DialogFooter> + <Button + type="button" + variant="outline" + onClick={handleCancel} + disabled={isLoading} + > + 취소 + </Button> + <Button + type="submit" + onClick={form.handleSubmit(onSubmit)} + disabled={isLoading} + > + {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} + {isLoading ? "수정 중..." : "수정"} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +}
\ No newline at end of file diff --git a/lib/integration/table/integration-edit-sheet.tsx b/lib/integration/table/integration-edit-sheet.tsx new file mode 100644 index 00000000..553a7870 --- /dev/null +++ b/lib/integration/table/integration-edit-sheet.tsx @@ -0,0 +1,278 @@ +"use client" + +import * as React from "react" +import { zodResolver } from "@hookform/resolvers/zod" +import { useForm } from "react-hook-form" +import { toast } from "sonner" +import * as z from "zod" +import { Loader2 } from "lucide-react" + +import { Button } from "@/components/ui/button" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { + Sheet, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { Input } from "@/components/ui/input" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Textarea } from "@/components/ui/textarea" +import { updateIntegration } from "../service" +import { integrations } from "@/db/schema/integration" + +const updateIntegrationSchema = z.object({ + code: z.string().min(1, "코드는 필수입니다."), + name: z.string().min(1, "이름은 필수입니다."), + type: z.enum(["rest_api", "soap", "db_to_db"], { required_error: "타입은 필수입니다." }), + description: z.string().optional(), + sourceSystem: z.string().min(1, "소스 시스템은 필수입니다."), + targetSystem: z.string().min(1, "타겟 시스템은 필수입니다."), + status: z.enum(["active", "inactive", "deprecated"]), + metadata: z.any().optional(), +}) + +type UpdateIntegrationFormValues = z.infer<typeof updateIntegrationSchema> + +interface IntegrationEditSheetProps { + data: typeof integrations.$inferSelect | null + open?: boolean + onOpenChange?: (open: boolean) => void + onSuccess?: () => void +} + +export function IntegrationEditSheet({ data, open, onOpenChange, onSuccess }: IntegrationEditSheetProps) { + const [isLoading, setIsLoading] = React.useState(false) + + const form = useForm<UpdateIntegrationFormValues>({ + resolver: zodResolver(updateIntegrationSchema), + defaultValues: { + code: data?.code || "", + name: data?.name || "", + type: data?.type || "rest_api", + description: data?.description || "", + sourceSystem: data?.sourceSystem || "", + targetSystem: data?.targetSystem || "", + status: data?.status || "active", + metadata: data?.metadata || {}, + }, + }) + + React.useEffect(() => { + if (data) { + form.reset({ + code: data.code || "", + name: data.name || "", + type: data.type || "rest_api", + description: data.description || "", + sourceSystem: data.sourceSystem || "", + targetSystem: data.targetSystem || "", + status: data.status || "active", + metadata: data.metadata || {}, + }) + } + }, [data, form]) + + const handleCancel = () => { + form.reset() + onOpenChange?.(false) + } + + const onSubmit = async (formData: UpdateIntegrationFormValues) => { + if (!data) return + + setIsLoading(true) + try { + const result = await updateIntegration(data.id, formData) + if (result.data) { + toast.success("인터페이스가 성공적으로 수정되었습니다.") + form.reset() + onOpenChange?.(false) + if (onSuccess) { + onSuccess() + } + } else { + toast.error(result.error || "수정 중 오류가 발생했습니다.") + } + } catch (error) { + console.error("인터페이스 수정 오류:", error) + toast.error("인터페이스 수정에 실패했습니다.") + } finally { + setIsLoading(false) + } + } + + return ( + <Sheet open={open} onOpenChange={onOpenChange}> + <SheetContent className="w-[400px] sm:w-[540px]"> + <SheetHeader> + <SheetTitle>인터페이스 수정</SheetTitle> + <SheetDescription> + 인터페이스 정보를 수정합니다. 필수 정보를 입력해주세요. + <span className="text-red-500 mt-1 block text-sm">* 표시된 항목은 필수 입력사항입니다.</span> + </SheetDescription> + </SheetHeader> + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4 mt-6"> + <FormField + control={form.control} + name="code" + render={({ field }) => ( + <FormItem> + <FormLabel> + 코드 <span className="text-red-500">*</span> + </FormLabel> + <FormControl> + <Input placeholder="INT_OPS_001" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="name" + render={({ field }) => ( + <FormItem> + <FormLabel> + 이름 <span className="text-red-500">*</span> + </FormLabel> + <FormControl> + <Input placeholder="인터페이스 이름" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="type" + render={({ field }) => ( + <FormItem> + <FormLabel> + 타입 <span className="text-red-500">*</span> + </FormLabel> + <Select onValueChange={field.onChange} defaultValue={field.value}> + <FormControl> + <SelectTrigger> + <SelectValue placeholder="타입 선택" /> + </SelectTrigger> + </FormControl> + <SelectContent> + <SelectItem value="rest_api">REST API</SelectItem> + <SelectItem value="soap">SOAP</SelectItem> + <SelectItem value="db_to_db">DB to DB</SelectItem> + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="sourceSystem" + render={({ field }) => ( + <FormItem> + <FormLabel> + 소스 시스템 <span className="text-red-500">*</span> + </FormLabel> + <FormControl> + <Input placeholder="ERP, WMS 등" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="targetSystem" + render={({ field }) => ( + <FormItem> + <FormLabel> + 타겟 시스템 <span className="text-red-500">*</span> + </FormLabel> + <FormControl> + <Input placeholder="ERP, WMS 등" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="status" + render={({ field }) => ( + <FormItem> + <FormLabel> + 상태 <span className="text-red-500">*</span> + </FormLabel> + <Select onValueChange={field.onChange} defaultValue={field.value}> + <FormControl> + <SelectTrigger> + <SelectValue placeholder="상태 선택" /> + </SelectTrigger> + </FormControl> + <SelectContent> + <SelectItem value="active">활성</SelectItem> + <SelectItem value="inactive">비활성</SelectItem> + <SelectItem value="deprecated">사용중단</SelectItem> + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="description" + render={({ field }) => ( + <FormItem> + <FormLabel>설명</FormLabel> + <FormControl> + <Textarea placeholder="인터페이스에 대한 설명" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </form> + </Form> + + <SheetFooter className="mt-6"> + <Button + type="button" + variant="outline" + onClick={handleCancel} + disabled={isLoading} + > + 취소 + </Button> + <Button + type="submit" + onClick={form.handleSubmit(onSubmit)} + disabled={isLoading} + > + {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} + {isLoading ? "수정 중..." : "수정"} + </Button> + </SheetFooter> + </SheetContent> + </Sheet> + ) +}
\ No newline at end of file diff --git a/lib/integration/table/integration-table-columns.tsx b/lib/integration/table/integration-table-columns.tsx new file mode 100644 index 00000000..330b7797 --- /dev/null +++ b/lib/integration/table/integration-table-columns.tsx @@ -0,0 +1,214 @@ +"use client" + +import { type DataTableRowAction } from "@/types/table" +import { type ColumnDef } from "@tanstack/react-table" +import { MoreHorizontal } from "lucide-react" + +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { integrations } from "@/db/schema/integration" + +interface GetColumnsProps { + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<typeof integrations.$inferSelect> | null>>; +} + +/** + * tanstack table 컬럼 정의 + */ +export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<typeof integrations.$inferSelect>[] { + // ---------------------------------------------------------------- + // 1) select 컬럼 (체크박스) + // ---------------------------------------------------------------- + const selectColumn: ColumnDef<typeof integrations.$inferSelect> = { + id: "select", + header: ({ table }) => ( + <input + type="checkbox" + checked={table.getIsAllPageRowsSelected()} + onChange={(value) => table.toggleAllPageRowsSelected(!!value.target.checked)} + aria-label="Select all" + className="translate-y-[2px]" + /> + ), + cell: ({ row }) => ( + <input + type="checkbox" + checked={row.getIsSelected()} + onChange={(value) => row.toggleSelected(!!value.target.checked)} + aria-label="Select row" + className="translate-y-[2px]" + /> + ), + enableSorting: false, + enableHiding: false, + } + + // ---------------------------------------------------------------- + // 3) 데이터 컬럼들 + // ---------------------------------------------------------------- + const dataColumns: ColumnDef<typeof integrations.$inferSelect>[] = [ + { + accessorKey: "code", + header: "코드", + filterFn: "includesString", + enableSorting: true, + enableHiding: false, + cell: ({ row }) => { + const code = row.getValue("code") as string; + return <div className="font-medium">{code}</div>; + }, + minSize: 100 + }, + { + accessorKey: "name", + header: "이름", + filterFn: "includesString", + enableSorting: true, + enableHiding: false, + cell: ({ row }) => { + const name = row.getValue("name") as string; + return <div>{name}</div>; + }, + minSize: 150 + }, + { + accessorKey: "type", + header: "타입", + filterFn: "includesString", + enableSorting: true, + enableHiding: false, + cell: ({ row }) => { + const type = row.getValue("type") as string; + return getTypeBadge(type); + }, + minSize: 100 + }, + { + accessorKey: "sourceSystem", + header: "소스 시스템", + filterFn: "includesString", + enableSorting: true, + enableHiding: false, + cell: ({ row }) => { + const sourceSystem = row.getValue("sourceSystem") as string; + return <div>{sourceSystem}</div>; + }, + minSize: 120 + }, + { + accessorKey: "targetSystem", + header: "타겟 시스템", + filterFn: "includesString", + enableSorting: true, + enableHiding: false, + cell: ({ row }) => { + const targetSystem = row.getValue("targetSystem") as string; + return <div>{targetSystem}</div>; + }, + minSize: 120 + }, + { + accessorKey: "status", + header: "상태", + filterFn: "includesString", + enableSorting: true, + enableHiding: false, + cell: ({ row }) => { + const status = row.getValue("status") as string; + return getStatusBadge(status); + }, + minSize: 80 + }, + { + accessorKey: "description", + header: "설명", + filterFn: "includesString", + enableSorting: true, + enableHiding: false, + cell: ({ row }) => { + const description = row.getValue("description") as string; + return <div className="max-w-xs truncate">{description || "-"}</div>; + }, + minSize: 150 + }, + { + accessorKey: "createdAt", + header: "생성일", + filterFn: "includesString", + enableSorting: true, + enableHiding: false, + cell: ({ row }) => { + const createdAt = row.getValue("createdAt") as string; + return <div>{new Date(createdAt).toLocaleDateString()}</div>; + }, + minSize: 80 + }, + ] + + // ---------------------------------------------------------------- + // 4) 컬럼 조합 + // ---------------------------------------------------------------- + const actionsColumn: ColumnDef<typeof integrations.$inferSelect> = { + id: "actions", + header: "", + enableSorting: false, + enableHiding: false, + cell: function Cell({ row }) { + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button variant="ghost" className="h-8 w-8 p-0"> + <span className="sr-only">Open menu</span> + <MoreHorizontal className="h-4 w-4" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="w-40"> + <DropdownMenuItem onClick={() => setRowAction({ type: "update", row })}> + Edit + </DropdownMenuItem> + <DropdownMenuSeparator /> + <DropdownMenuItem onClick={() => setRowAction({ type: "delete", row })}> + Delete + <span className="ml-auto text-xs text-muted-foreground">⌘⌫</span> + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + ); + }, + } + + return [selectColumn, ...dataColumns, actionsColumn] +} + +const getStatusBadge = (status: string) => { + switch (status) { + case "active": + return <Badge variant="default">활성</Badge>; + case "inactive": + return <Badge variant="secondary">비활성</Badge>; + case "deprecated": + return <Badge variant="destructive">사용중단</Badge>; + default: + return <Badge variant="outline">{status}</Badge>; + } +}; + +const getTypeBadge = (type: string) => { + switch (type) { + case "rest_api": + return <Badge variant="outline">REST API</Badge>; + case "soap": + return <Badge variant="outline">SOAP</Badge>; + case "db_to_db": + return <Badge variant="outline">DB to DB</Badge>; + default: + return <Badge variant="outline">{type}</Badge>; + } +};
\ No newline at end of file diff --git a/lib/integration/table/integration-table-toolbar.tsx b/lib/integration/table/integration-table-toolbar.tsx new file mode 100644 index 00000000..a53eac2f --- /dev/null +++ b/lib/integration/table/integration-table-toolbar.tsx @@ -0,0 +1,53 @@ +"use client"; + +import * as React from "react"; +import { type Table } from "@tanstack/react-table"; +import { Download, Plus } from "lucide-react"; + +import { exportTableToExcel } from "@/lib/export"; +import { Button } from "@/components/ui/button"; +import { DeleteIntegrationDialog } from "./delete-integration-dialog"; +import { IntegrationAddDialog } from "./integration-add-dialog"; +import { integrationTable } from "@/db/schema/integration"; + +interface IntegrationTableToolbarActionsProps<TData> { + table: Table<TData>; + onSuccess?: () => void; +} + +export function IntegrationTableToolbarActions<TData>({ + table, + onSuccess, +}: IntegrationTableToolbarActionsProps<TData>) { + const selectedRows = table.getFilteredSelectedRowModel().rows.map((row) => row.original); + return ( + <div className="flex items-center gap-2"> + {selectedRows.length > 0 && ( + <DeleteIntegrationDialog + integrations={selectedRows} + onSuccess={() => { + table.toggleAllRowsSelected(false); + onSuccess?.(); + }} + /> + )} + <IntegrationAddDialog onSuccess={onSuccess} /> + + {/** 3) Export 버튼 */} + <Button + variant="outline" + size="sm" + onClick={() => + exportTableToExcel(table, { + filename: "interface-list", + excludeColumns: ["select", "actions"], + }) + } + className="gap-2" + > + <Download className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">Export</span> + </Button> + </div> + ); +}
\ No newline at end of file diff --git a/lib/integration/table/integration-table.tsx b/lib/integration/table/integration-table.tsx new file mode 100644 index 00000000..7a075fb4 --- /dev/null +++ b/lib/integration/table/integration-table.tsx @@ -0,0 +1,166 @@ +"use client"; +import * as React from "react"; +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 { + DataTableAdvancedFilterField, + DataTableRowAction, +} from "@/types/table" +import { getIntegrations } from "../service"; +import { getColumns } from "./integration-table-columns"; +import { DeleteIntegrationDialog } from "./delete-integration-dialog"; +import { IntegrationEditSheet } from "./integration-edit-sheet"; +import { IntegrationTableToolbarActions } from "./integration-table-toolbar"; +import { integrations } from "@/db/schema/integration"; +import { GetIntegrationsSchema } from "../validations"; + +interface IntegrationTableProps { + promises?: Promise<[{ data: typeof integrations.$inferSelect[]; pageCount: number }] >; +} + +export function IntegrationTable({ promises }: IntegrationTableProps) { + const [rawData, setRawData] = React.useState<{ data: typeof integrations.$inferSelect[]; pageCount: number }>({ data: [], pageCount: 0 }); + const [rowAction, setRowAction] = React.useState<DataTableRowAction<typeof integrations.$inferSelect> | null>(null); + + React.useEffect(() => { + if (promises) { + promises.then(([result]) => { + setRawData(result); + }); + } else { + // fallback: 클라이언트에서 직접 fetch (CSR) + (async () => { + try { + const result = await getIntegrations({ + page: 1, + perPage: 10, + search: "", + sort: [{ id: "createdAt", desc: true }], + filters: [], + joinOperator: "and", + flags: ["advancedTable"], + code: "", + name: "", + type: "", + description: "", + sourceSystem: "", + targetSystem: "", + status: "" + }); + setRawData(result); + } catch (error) { + console.error("Error refreshing data:", error); + } + })(); + } + }, [promises]); + + const fetchIntegrations = React.useCallback(async (params: Record<string, unknown>) => { + try { + const result = await getIntegrations(params as GetIntegrationsSchema); + return result; + } catch (error) { + console.error("Error fetching integrations:", error); + throw error; + } + }, []); + + const refreshData = React.useCallback(async () => { + try { + const result = await fetchIntegrations({ + page: 1, + perPage: 10, + search: "", + sort: [{ id: "createdAt", desc: true }], + filters: [], + joinOperator: "and", + flags: ["advancedTable"], + code: "", + name: "", + type: "", + description: "", + sourceSystem: "", + targetSystem: "", + status: "" + }); + setRawData(result); + } catch (error) { + console.error("Error refreshing data:", error); + } + }, [fetchIntegrations]); + + // 컬럼 설정 - 외부 파일에서 가져옴 + const columns = React.useMemo( + () => getColumns({ setRowAction }), + [setRowAction] + ) + + // 고급 필터 필드 설정 + const advancedFilterFields: DataTableAdvancedFilterField<typeof integrations.$inferSelect>[] = [ + { id: "code", label: "코드", type: "text" }, + { id: "name", label: "이름", type: "text" }, + { id: "type", label: "타입", type: "select", options: [ + { label: "REST API", value: "rest_api" }, + { label: "SOAP", value: "soap" }, + { label: "DB to DB", value: "db_to_db" }, + ]}, + { id: "description", label: "설명", type: "text" }, + { id: "sourceSystem", label: "소스 시스템", type: "text" }, + { id: "targetSystem", label: "타겟 시스템", type: "text" }, + { + id: "status", label: "상태", type: "select", options: [ + { label: "활성", value: "active" }, + { label: "비활성", value: "inactive" }, + { label: "사용중단", value: "deprecated" }, + ] + }, + { id: "createdAt", label: "생성일", type: "date" }, + ]; + + const { table } = useDataTable({ + data: rawData.data, + columns, + pageCount: rawData.pageCount, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "createdAt", desc: true }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => String(originalRow.id), + shallow: false, + clearOnDefault: true, + }) + + return ( + <> + <DataTable table={table}> + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + > + <IntegrationTableToolbarActions table={table} onSuccess={refreshData} /> + </DataTableAdvancedToolbar> + </DataTable> + + <DeleteIntegrationDialog + open={rowAction?.type === "delete"} + onOpenChange={() => setRowAction(null)} + integrations={rowAction?.row.original ? [rowAction?.row.original] : []} + showTrigger={false} + onSuccess={() => { + rowAction?.row.toggleSelected(false) + refreshData() + }} + /> + + <IntegrationEditSheet + open={rowAction?.type === "update"} + onOpenChange={() => setRowAction(null)} + data={rowAction?.row.original ?? null} + onSuccess={refreshData} + /> + </> + ); +}
\ No newline at end of file diff --git a/lib/integration/validations.ts b/lib/integration/validations.ts new file mode 100644 index 00000000..4cdf5adc --- /dev/null +++ b/lib/integration/validations.ts @@ -0,0 +1,99 @@ +import { + createSearchParamsCache, + parseAsArrayOf, + parseAsInteger, + parseAsString, + parseAsStringEnum, +} from "nuqs/server"; +import * as z from "zod"; + +import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"; +import { integrations } from "@/db/schema/integration"; + +export const SearchParamsCache = createSearchParamsCache({ + // UI 모드나 플래그 관련 + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), + + // 페이징 + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + + // 정렬 (createdAt 기준 내림차순) + sort: getSortingStateParser<typeof integrations>().withDefault([ + { id: "createdAt", desc: true } + ]), + + // 기존 필드 + code: parseAsString.withDefault(""), + name: parseAsString.withDefault(""), + type: parseAsString.withDefault(""), + description: parseAsString.withDefault(""), + sourceSystem: parseAsString.withDefault(""), + targetSystem: parseAsString.withDefault(""), + status: parseAsString.withDefault(""), + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + search: parseAsString.withDefault(""), +}); + +export type GetIntegrationsSchema = Awaited<ReturnType<typeof SearchParamsCache.parse>>; + +// 통합 타입 정의 +export const integrationTypeEnum = z.enum(["rest_api", "soap", "db_to_db"]); +export const integrationStatusEnum = z.enum(["active", "inactive", "deprecated"]); + +// 통합 생성 스키마 +export const createIntegrationSchema = z.object({ + code: z.string().min(1, "코드는 필수입니다."), + name: z.string().min(1, "이름은 필수입니다."), + type: integrationTypeEnum, + description: z.string().optional(), + sourceSystem: z.string().min(1, "소스 시스템은 필수입니다."), + targetSystem: z.string().min(1, "타겟 시스템은 필수입니다."), + status: integrationStatusEnum.default("active"), + metadata: z.any().optional(), +}); + +// 통합 수정 스키마 +export const updateIntegrationSchema = z.object({ + code: z.string().min(1, "코드는 필수입니다."), + name: z.string().min(1, "이름은 필수입니다."), + type: integrationTypeEnum, + description: z.string().optional(), + sourceSystem: z.string().min(1, "소스 시스템은 필수입니다."), + targetSystem: z.string().min(1, "타겟 시스템은 필수입니다."), + status: integrationStatusEnum, + metadata: z.any().optional(), +}); + +// 통합 조회 스키마 +export const getIntegrationsSchema = z.object({ + page: z.number().min(1).default(1), + limit: z.number().min(1).max(100).default(10), + search: z.string().optional(), + type: integrationTypeEnum.optional(), + status: integrationStatusEnum.optional(), + sourceSystem: z.string().optional(), + targetSystem: z.string().optional(), +}); + +// 통합 타입 +export type Integration = { + id: number; + code: string; + name: string; + type: "rest_api" | "soap" | "db_to_db"; + description?: string | null; + sourceSystem: string; + targetSystem: string; + status: "active" | "inactive" | "deprecated"; + metadata?: any; + createdBy?: number | null; + updatedBy?: number | null; + createdAt: Date; + updatedAt: Date; +}; + +export type CreateIntegrationInput = z.infer<typeof createIntegrationSchema>; +export type UpdateIntegrationInput = z.infer<typeof updateIntegrationSchema>; +export type GetIntegrationsInput = z.infer<typeof getIntegrationsSchema>;
\ No newline at end of file |
