summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
author0-Zz-ang <s1998319@gmail.com>2025-07-10 15:56:13 +0900
committer0-Zz-ang <s1998319@gmail.com>2025-07-10 15:56:13 +0900
commit356929b399ef31a4de82906267df438cf29ea59d (patch)
treec353a55c076e987042f99f3dbf1eab54706f6829 /lib
parent25d569828b704a102f681a627c76c4129afa8be3 (diff)
인터페이스 관련 파일 수정
Diffstat (limited to 'lib')
-rw-r--r--lib/integration-log/db-logging.ts295
-rw-r--r--lib/integration-log/rest-logging.ts188
-rw-r--r--lib/integration-log/saml-logging.ts232
-rw-r--r--lib/integration-log/service.ts317
-rw-r--r--lib/integration-log/table/integration-log-table-columns.tsx238
-rw-r--r--lib/integration-log/table/integration-log-table.tsx112
-rw-r--r--lib/integration-log/validations.ts36
-rw-r--r--lib/integration/service.ts226
-rw-r--r--lib/integration/table/delete-integration-dialog.tsx154
-rw-r--r--lib/integration/table/integration-add-dialog.tsx272
-rw-r--r--lib/integration/table/integration-delete-dialog.tsx122
-rw-r--r--lib/integration/table/integration-edit-dialog.tsx274
-rw-r--r--lib/integration/table/integration-edit-sheet.tsx278
-rw-r--r--lib/integration/table/integration-table-columns.tsx214
-rw-r--r--lib/integration/table/integration-table-toolbar.tsx53
-rw-r--r--lib/integration/table/integration-table.tsx166
-rw-r--r--lib/integration/validations.ts99
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