diff --git a/backend-node/src/controllers/batchController.ts b/backend-node/src/controllers/batchController.ts index 638edcd2..009e30a8 100644 --- a/backend-node/src/controllers/batchController.ts +++ b/backend-node/src/controllers/batchController.ts @@ -4,6 +4,7 @@ import { Request, Response } from "express"; import { BatchService } from "../services/batchService"; import { BatchSchedulerService } from "../services/batchSchedulerService"; +import { BatchExternalDbService } from "../services/batchExternalDbService"; import { BatchConfigFilter, CreateBatchConfigRequest, @@ -63,7 +64,7 @@ export class BatchController { res: Response ) { try { - const result = await BatchService.getAvailableConnections(); + const result = await BatchExternalDbService.getAvailableConnections(); if (result.success) { res.json(result); @@ -99,8 +100,8 @@ export class BatchController { } const connectionId = type === "external" ? Number(id) : undefined; - const result = await BatchService.getTablesFromConnection( - type, + const result = await BatchService.getTables( + type as "internal" | "external", connectionId ); @@ -142,10 +143,10 @@ export class BatchController { } const connectionId = type === "external" ? Number(id) : undefined; - const result = await BatchService.getTableColumns( - type, - connectionId, - tableName + const result = await BatchService.getColumns( + tableName, + type as "internal" | "external", + connectionId ); if (result.success) { diff --git a/backend-node/src/controllers/batchManagementController.ts b/backend-node/src/controllers/batchManagementController.ts index cc91de80..05aece84 100644 --- a/backend-node/src/controllers/batchManagementController.ts +++ b/backend-node/src/controllers/batchManagementController.ts @@ -331,8 +331,11 @@ export class BatchManagementController { const duration = endTime.getTime() - startTime.getTime(); // executionLog가 정의되어 있는지 확인 - if (typeof executionLog !== "undefined") { - await BatchService.updateExecutionLog(executionLog.id, { + if (typeof executionLog !== "undefined" && executionLog) { + const { BatchExecutionLogService } = await import( + "../services/batchExecutionLogService" + ); + await BatchExecutionLogService.updateExecutionLog(executionLog.id, { execution_status: "FAILED", end_time: endTime, duration_ms: duration, diff --git a/backend-node/src/controllers/dynamicFormController.ts b/backend-node/src/controllers/dynamicFormController.ts index 738d1964..6adf8cd6 100644 --- a/backend-node/src/controllers/dynamicFormController.ts +++ b/backend-node/src/controllers/dynamicFormController.ts @@ -203,7 +203,7 @@ export const updateFormDataPartial = async ( }; const result = await dynamicFormService.updateFormDataPartial( - parseInt(id), + id, // 🔧 parseInt 제거 - UUID 문자열도 지원 tableName, originalData, newDataWithMeta diff --git a/backend-node/src/controllers/screenEmbeddingController.ts b/backend-node/src/controllers/screenEmbeddingController.ts index 43087589..497d99db 100644 --- a/backend-node/src/controllers/screenEmbeddingController.ts +++ b/backend-node/src/controllers/screenEmbeddingController.ts @@ -5,6 +5,7 @@ import { Request, Response } from "express"; import { getPool } from "../database/db"; import { logger } from "../utils/logger"; +import { AuthenticatedRequest } from "../types/auth"; const pool = getPool(); @@ -16,7 +17,7 @@ const pool = getPool(); * 화면 임베딩 목록 조회 * GET /api/screen-embedding?parentScreenId=1 */ -export async function getScreenEmbeddings(req: Request, res: Response) { +export async function getScreenEmbeddings(req: AuthenticatedRequest, res: Response) { try { const { parentScreenId } = req.query; const companyCode = req.user!.companyCode; @@ -67,7 +68,7 @@ export async function getScreenEmbeddings(req: Request, res: Response) { * 화면 임베딩 상세 조회 * GET /api/screen-embedding/:id */ -export async function getScreenEmbeddingById(req: Request, res: Response) { +export async function getScreenEmbeddingById(req: AuthenticatedRequest, res: Response) { try { const { id } = req.params; const companyCode = req.user!.companyCode; @@ -113,7 +114,7 @@ export async function getScreenEmbeddingById(req: Request, res: Response) { * 화면 임베딩 생성 * POST /api/screen-embedding */ -export async function createScreenEmbedding(req: Request, res: Response) { +export async function createScreenEmbedding(req: AuthenticatedRequest, res: Response) { try { const { parentScreenId, @@ -184,7 +185,7 @@ export async function createScreenEmbedding(req: Request, res: Response) { * 화면 임베딩 수정 * PUT /api/screen-embedding/:id */ -export async function updateScreenEmbedding(req: Request, res: Response) { +export async function updateScreenEmbedding(req: AuthenticatedRequest, res: Response) { try { const { id } = req.params; const { position, mode, config } = req.body; @@ -257,7 +258,7 @@ export async function updateScreenEmbedding(req: Request, res: Response) { * 화면 임베딩 삭제 * DELETE /api/screen-embedding/:id */ -export async function deleteScreenEmbedding(req: Request, res: Response) { +export async function deleteScreenEmbedding(req: AuthenticatedRequest, res: Response) { try { const { id } = req.params; const companyCode = req.user!.companyCode; @@ -301,7 +302,7 @@ export async function deleteScreenEmbedding(req: Request, res: Response) { * 데이터 전달 설정 조회 * GET /api/screen-data-transfer?sourceScreenId=1&targetScreenId=2 */ -export async function getScreenDataTransfer(req: Request, res: Response) { +export async function getScreenDataTransfer(req: AuthenticatedRequest, res: Response) { try { const { sourceScreenId, targetScreenId } = req.query; const companyCode = req.user!.companyCode; @@ -363,7 +364,7 @@ export async function getScreenDataTransfer(req: Request, res: Response) { * 데이터 전달 설정 생성 * POST /api/screen-data-transfer */ -export async function createScreenDataTransfer(req: Request, res: Response) { +export async function createScreenDataTransfer(req: AuthenticatedRequest, res: Response) { try { const { sourceScreenId, @@ -436,7 +437,7 @@ export async function createScreenDataTransfer(req: Request, res: Response) { * 데이터 전달 설정 수정 * PUT /api/screen-data-transfer/:id */ -export async function updateScreenDataTransfer(req: Request, res: Response) { +export async function updateScreenDataTransfer(req: AuthenticatedRequest, res: Response) { try { const { id } = req.params; const { dataReceivers, buttonConfig } = req.body; @@ -504,7 +505,7 @@ export async function updateScreenDataTransfer(req: Request, res: Response) { * 데이터 전달 설정 삭제 * DELETE /api/screen-data-transfer/:id */ -export async function deleteScreenDataTransfer(req: Request, res: Response) { +export async function deleteScreenDataTransfer(req: AuthenticatedRequest, res: Response) { try { const { id } = req.params; const companyCode = req.user!.companyCode; @@ -548,7 +549,7 @@ export async function deleteScreenDataTransfer(req: Request, res: Response) { * 분할 패널 설정 조회 * GET /api/screen-split-panel/:screenId */ -export async function getScreenSplitPanel(req: Request, res: Response) { +export async function getScreenSplitPanel(req: AuthenticatedRequest, res: Response) { try { const { screenId } = req.params; const companyCode = req.user!.companyCode; @@ -655,7 +656,7 @@ export async function getScreenSplitPanel(req: Request, res: Response) { * 분할 패널 설정 생성 * POST /api/screen-split-panel */ -export async function createScreenSplitPanel(req: Request, res: Response) { +export async function createScreenSplitPanel(req: AuthenticatedRequest, res: Response) { const client = await pool.connect(); try { @@ -792,7 +793,7 @@ export async function createScreenSplitPanel(req: Request, res: Response) { * 분할 패널 설정 수정 * PUT /api/screen-split-panel/:id */ -export async function updateScreenSplitPanel(req: Request, res: Response) { +export async function updateScreenSplitPanel(req: AuthenticatedRequest, res: Response) { try { const { id } = req.params; const { layoutConfig } = req.body; @@ -845,7 +846,7 @@ export async function updateScreenSplitPanel(req: Request, res: Response) { * 분할 패널 설정 삭제 * DELETE /api/screen-split-panel/:id */ -export async function deleteScreenSplitPanel(req: Request, res: Response) { +export async function deleteScreenSplitPanel(req: AuthenticatedRequest, res: Response) { const client = await pool.connect(); try { diff --git a/backend-node/src/routes/dynamicFormRoutes.ts b/backend-node/src/routes/dynamicFormRoutes.ts index 21140617..dae17283 100644 --- a/backend-node/src/routes/dynamicFormRoutes.ts +++ b/backend-node/src/routes/dynamicFormRoutes.ts @@ -22,9 +22,9 @@ router.use(authenticateToken); // 폼 데이터 CRUD router.post("/save", saveFormData); // 기존 버전 (레거시 지원) router.post("/save-enhanced", saveFormDataEnhanced); // 개선된 버전 +router.put("/update-field", updateFieldValue); // 특정 필드만 업데이트 (다른 테이블 지원) - /:id 보다 먼저 선언! router.put("/:id", updateFormData); router.patch("/:id/partial", updateFormDataPartial); // 부분 업데이트 -router.put("/update-field", updateFieldValue); // 특정 필드만 업데이트 (다른 테이블 지원) router.delete("/:id", deleteFormData); router.get("/:id", getFormData); diff --git a/backend-node/src/services/batchExternalDbService.ts b/backend-node/src/services/batchExternalDbService.ts index 18524085..303c2d7a 100644 --- a/backend-node/src/services/batchExternalDbService.ts +++ b/backend-node/src/services/batchExternalDbService.ts @@ -203,8 +203,7 @@ export class BatchExternalDbService { // 비밀번호 복호화 if (connection.password) { try { - const passwordEncryption = new PasswordEncryption(); - connection.password = passwordEncryption.decrypt(connection.password); + connection.password = PasswordEncryption.decrypt(connection.password); } catch (error) { console.error("비밀번호 복호화 실패:", error); // 복호화 실패 시 원본 사용 (또는 에러 처리) diff --git a/backend-node/src/services/batchSchedulerService.ts b/backend-node/src/services/batchSchedulerService.ts index 780118fb..ee849ae2 100644 --- a/backend-node/src/services/batchSchedulerService.ts +++ b/backend-node/src/services/batchSchedulerService.ts @@ -1,10 +1,10 @@ -import cron from "node-cron"; +import cron, { ScheduledTask } from "node-cron"; import { BatchService } from "./batchService"; import { BatchExecutionLogService } from "./batchExecutionLogService"; import { logger } from "../utils/logger"; export class BatchSchedulerService { - private static scheduledTasks: Map = new Map(); + private static scheduledTasks: Map = new Map(); /** * 모든 활성 배치의 스케줄링 초기화 @@ -183,7 +183,7 @@ export class BatchSchedulerService { // 실행 로그 업데이트 (실패) if (executionLog) { await BatchExecutionLogService.updateExecutionLog(executionLog.id, { - execution_status: "FAILURE", + execution_status: "FAILED", end_time: new Date(), duration_ms: Date.now() - startTime.getTime(), error_message: @@ -404,4 +404,11 @@ export class BatchSchedulerService { return { totalRecords, successRecords, failedRecords }; } + + /** + * 개별 배치 작업 스케줄링 (scheduleBatch의 별칭) + */ + static async scheduleBatchConfig(config: any) { + return this.scheduleBatch(config); + } } diff --git a/backend-node/src/services/batchService.ts b/backend-node/src/services/batchService.ts index 41f20964..2aefc98b 100644 --- a/backend-node/src/services/batchService.ts +++ b/backend-node/src/services/batchService.ts @@ -16,7 +16,6 @@ import { UpdateBatchConfigRequest, } from "../types/batchTypes"; import { BatchExternalDbService } from "./batchExternalDbService"; -import { DbConnectionManager } from "./dbConnectionManager"; export class BatchService { /** @@ -475,7 +474,13 @@ export class BatchService { try { if (connectionType === "internal") { // 내부 DB 테이블 조회 - const tables = await DbConnectionManager.getInternalTables(); + const tables = await query( + `SELECT table_name, table_type, table_schema + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_type = 'BASE TABLE' + ORDER BY table_name` + ); return { success: true, data: tables, @@ -509,7 +514,13 @@ export class BatchService { try { if (connectionType === "internal") { // 내부 DB 컬럼 조회 - const columns = await DbConnectionManager.getInternalColumns(tableName); + const columns = await query( + `SELECT column_name, data_type, is_nullable, column_default + FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = $1 + ORDER BY ordinal_position`, + [tableName] + ); return { success: true, data: columns, @@ -543,7 +554,9 @@ export class BatchService { try { if (connectionType === "internal") { // 내부 DB 데이터 조회 - const data = await DbConnectionManager.getInternalData(tableName, 10); + const data = await query( + `SELECT * FROM ${tableName} LIMIT 10` + ); return { success: true, data, diff --git a/backend-node/src/services/dynamicFormService.ts b/backend-node/src/services/dynamicFormService.ts index 11648577..e94d1ea2 100644 --- a/backend-node/src/services/dynamicFormService.ts +++ b/backend-node/src/services/dynamicFormService.ts @@ -746,7 +746,7 @@ export class DynamicFormService { * 폼 데이터 부분 업데이트 (변경된 필드만 업데이트) */ async updateFormDataPartial( - id: number, + id: string | number, // 🔧 UUID 문자열도 지원 tableName: string, originalData: Record, newData: Record @@ -1662,12 +1662,47 @@ export class DynamicFormService { companyCode, }); - // 멀티테넌시: company_code 조건 추가 (최고관리자는 제외) - let whereClause = `"${keyField}" = $1`; - const params: any[] = [keyValue, updateValue, userId]; - let paramIndex = 4; + // 테이블 컬럼 정보 조회 (updated_by, updated_at 존재 여부 확인) + const columnQuery = ` + SELECT column_name + FROM information_schema.columns + WHERE table_name = $1 AND column_name IN ('updated_by', 'updated_at', 'company_code') + `; + const columnResult = await client.query(columnQuery, [tableName]); + const existingColumns = columnResult.rows.map((row: any) => row.column_name); + + const hasUpdatedBy = existingColumns.includes('updated_by'); + const hasUpdatedAt = existingColumns.includes('updated_at'); + const hasCompanyCode = existingColumns.includes('company_code'); - if (companyCode && companyCode !== "*") { + console.log("🔍 [updateFieldValue] 테이블 컬럼 확인:", { + hasUpdatedBy, + hasUpdatedAt, + hasCompanyCode, + }); + + // 동적 SET 절 구성 + let setClause = `"${updateField}" = $1`; + const params: any[] = [updateValue]; + let paramIndex = 2; + + if (hasUpdatedBy) { + setClause += `, updated_by = $${paramIndex}`; + params.push(userId); + paramIndex++; + } + + if (hasUpdatedAt) { + setClause += `, updated_at = NOW()`; + } + + // WHERE 절 구성 + let whereClause = `"${keyField}" = $${paramIndex}`; + params.push(keyValue); + paramIndex++; + + // 멀티테넌시: company_code 조건 추가 (최고관리자는 제외, 컬럼이 있는 경우만) + if (hasCompanyCode && companyCode && companyCode !== "*") { whereClause += ` AND company_code = $${paramIndex}`; params.push(companyCode); paramIndex++; @@ -1675,9 +1710,7 @@ export class DynamicFormService { const sqlQuery = ` UPDATE "${tableName}" - SET "${updateField}" = $2, - updated_by = $3, - updated_at = NOW() + SET ${setClause} WHERE ${whereClause} `; diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index 173de022..28da136e 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -1165,12 +1165,26 @@ export class TableManagementService { paramCount: number; } | null> { try { - // 🔧 날짜 범위 문자열 "YYYY-MM-DD|YYYY-MM-DD" 체크 (최우선!) + // 🔧 파이프로 구분된 문자열 처리 (다중선택 또는 날짜 범위) if (typeof value === "string" && value.includes("|")) { const columnInfo = await this.getColumnWebTypeInfo(tableName, columnName); + + // 날짜 타입이면 날짜 범위로 처리 if (columnInfo && (columnInfo.webType === "date" || columnInfo.webType === "datetime")) { return this.buildDateRangeCondition(columnName, value, paramIndex); } + + // 그 외 타입이면 다중선택(IN 조건)으로 처리 + const multiValues = value.split("|").filter((v: string) => v.trim() !== ""); + if (multiValues.length > 0) { + const placeholders = multiValues.map((_: string, idx: number) => `$${paramIndex + idx}`).join(", "); + logger.info(`🔍 다중선택 필터 적용: ${columnName} IN (${multiValues.join(", ")})`); + return { + whereClause: `${columnName}::text IN (${placeholders})`, + values: multiValues, + paramCount: multiValues.length, + }; + } } // 🔧 날짜 범위 객체 {from, to} 체크 diff --git a/backend-node/src/types/batchTypes.ts b/backend-node/src/types/batchTypes.ts index 1cbec196..15efd003 100644 --- a/backend-node/src/types/batchTypes.ts +++ b/backend-node/src/types/batchTypes.ts @@ -1,4 +1,98 @@ -import { ApiResponse, ColumnInfo } from './batchTypes'; +// 배치관리 타입 정의 +// 작성일: 2024-12-24 + +// 공통 API 응답 타입 +export interface ApiResponse { + success: boolean; + data?: T; + message?: string; + error?: string; + pagination?: { + page: number; + limit: number; + total: number; + totalPages: number; + }; +} + +// 컬럼 정보 타입 +export interface ColumnInfo { + column_name: string; + data_type: string; + is_nullable?: string; + column_default?: string | null; +} + +// 테이블 정보 타입 +export interface TableInfo { + table_name: string; + table_type?: string; + table_schema?: string; +} + +// 연결 정보 타입 +export interface ConnectionInfo { + type: 'internal' | 'external'; + id?: number; + name: string; + db_type?: string; +} + +// 배치 설정 필터 타입 +export interface BatchConfigFilter { + page?: number; + limit?: number; + search?: string; + is_active?: string; + company_code?: string; +} + +// 배치 매핑 타입 +export interface BatchMapping { + id?: number; + batch_config_id?: number; + company_code?: string; + from_connection_type: 'internal' | 'external' | 'restapi'; + from_connection_id?: number; + from_table_name: string; + from_column_name: string; + from_column_type?: string; + from_api_url?: string; + from_api_key?: string; + from_api_method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; + from_api_param_type?: 'url' | 'query'; + from_api_param_name?: string; + from_api_param_value?: string; + from_api_param_source?: 'static' | 'dynamic'; + from_api_body?: string; + to_connection_type: 'internal' | 'external' | 'restapi'; + to_connection_id?: number; + to_table_name: string; + to_column_name: string; + to_column_type?: string; + to_api_url?: string; + to_api_key?: string; + to_api_method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; + to_api_body?: string; + mapping_order?: number; + created_by?: string; + created_date?: Date; +} + +// 배치 설정 타입 +export interface BatchConfig { + id?: number; + batch_name: string; + description?: string; + cron_schedule: string; + is_active: 'Y' | 'N'; + company_code?: string; + created_by?: string; + created_date?: Date; + updated_by?: string; + updated_date?: Date; + batch_mappings?: BatchMapping[]; +} export interface BatchConnectionInfo { type: 'internal' | 'external'; @@ -27,7 +121,7 @@ export interface BatchMappingRequest { from_api_param_name?: string; // API 파라미터명 from_api_param_value?: string; // API 파라미터 값 또는 템플릿 from_api_param_source?: 'static' | 'dynamic'; // 파라미터 소스 타입 - // 👇 REST API Body 추가 (FROM - REST API에서 POST 요청 시 필요) + // REST API Body 추가 (FROM - REST API에서 POST 요청 시 필요) from_api_body?: string; to_connection_type: 'internal' | 'external' | 'restapi'; to_connection_id?: number; diff --git a/frontend/components/common/ScreenModal.tsx b/frontend/components/common/ScreenModal.tsx index 3e0f1a61..53fd0852 100644 --- a/frontend/components/common/ScreenModal.tsx +++ b/frontend/components/common/ScreenModal.tsx @@ -57,6 +57,9 @@ export const ScreenModal: React.FC = ({ className }) => { // 폼 데이터 상태 추가 const [formData, setFormData] = useState>({}); + // 🆕 원본 데이터 상태 (수정 모드에서 UPDATE 판단용) + const [originalData, setOriginalData] = useState | null>(null); + // 연속 등록 모드 상태 (state로 변경 - 체크박스 UI 업데이트를 위해) const [continuousMode, setContinuousMode] = useState(false); @@ -143,10 +146,13 @@ export const ScreenModal: React.FC = ({ className }) => { console.log("✅ URL 파라미터 추가:", urlParams); } - // 🆕 editData가 있으면 formData로 설정 (수정 모드) + // 🆕 editData가 있으면 formData와 originalData로 설정 (수정 모드) if (editData) { console.log("📝 [ScreenModal] 수정 데이터 설정:", editData); setFormData(editData); + setOriginalData(editData); // 🆕 원본 데이터 저장 (UPDATE 판단용) + } else { + setOriginalData(null); // 신규 등록 모드 } setModalState({ @@ -177,7 +183,7 @@ export const ScreenModal: React.FC = ({ className }) => { }); setScreenData(null); setFormData({}); - setSelectedData([]); // 🆕 선택된 데이터 초기화 + setOriginalData(null); // 🆕 원본 데이터 초기화 setContinuousMode(false); localStorage.setItem("screenModal_continuousMode", "false"); // localStorage에 저장 console.log("🔄 연속 모드 초기화: false"); @@ -365,12 +371,15 @@ export const ScreenModal: React.FC = ({ className }) => { "⚠️ [ScreenModal] 그룹 레코드(배열)는 formData로 설정하지 않음. SelectedItemsDetailInput만 사용합니다.", ); setFormData(normalizedData); // SelectedItemsDetailInput이 직접 사용 + setOriginalData(normalizedData[0] || null); // 🆕 첫 번째 레코드를 원본으로 저장 } else { setFormData(normalizedData); + setOriginalData(normalizedData); // 🆕 원본 데이터 저장 (UPDATE 판단용) } // setFormData 직후 확인 console.log("🔄 setFormData 호출 완료 (날짜 정규화됨)"); + console.log("🔄 setOriginalData 호출 완료 (UPDATE 판단용)"); } else { console.error("❌ 수정 데이터 로드 실패:", response.error); toast.error("데이터를 불러올 수 없습니다."); @@ -619,11 +628,17 @@ export const ScreenModal: React.FC = ({ className }) => { component={adjustedComponent} allComponents={screenData.components} formData={formData} + originalData={originalData} // 🆕 원본 데이터 전달 (UPDATE 판단용) onFormDataChange={(fieldName, value) => { - setFormData((prev) => ({ - ...prev, - [fieldName]: value, - })); + console.log("🔧 [ScreenModal] onFormDataChange 호출:", { fieldName, value }); + setFormData((prev) => { + const newFormData = { + ...prev, + [fieldName]: value, + }; + console.log("🔧 [ScreenModal] formData 업데이트:", { prev, newFormData }); + return newFormData; + }); }} onRefresh={() => { // 부모 화면의 테이블 새로고침 이벤트 발송 @@ -637,8 +652,6 @@ export const ScreenModal: React.FC = ({ className }) => { userId={userId} userName={userName} companyCode={user?.companyCode} - // 🆕 선택된 데이터 전달 (RepeatScreenModal 등에서 사용) - groupedData={selectedData.length > 0 ? selectedData : undefined} /> ); })} diff --git a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx index e351b68c..c9535285 100644 --- a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx +++ b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx @@ -53,6 +53,8 @@ interface InteractiveScreenViewerProps { disabledFields?: string[]; // 🆕 EditModal 내부인지 여부 (button-primary가 EditModal의 handleSave 사용하도록) isInModal?: boolean; + // 🆕 원본 데이터 (수정 모드에서 UPDATE 판단용) + originalData?: Record | null; } export const InteractiveScreenViewerDynamic: React.FC = ({ @@ -72,6 +74,7 @@ export const InteractiveScreenViewerDynamic: React.FC { const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인 const { userName: authUserName, user: authUser } = useAuth(); @@ -331,6 +334,7 @@ export const InteractiveScreenViewerDynamic: React.FC = ({ /> + {/* 첫 번째 추가 테이블 설정 (위치정보와 함께 상태 변경) */} +
+
+
+ +

위치정보와 함께 다른 테이블의 필드 값을 변경합니다

+
+ onUpdateProperty("componentConfig.action.geolocationUpdateField", checked)} + /> +
+ + {config.action?.geolocationUpdateField && ( +
+
+ + +
+
+
+ + onUpdateProperty("componentConfig.action.geolocationExtraField", e.target.value)} + className="h-8 text-xs" + /> +
+
+ + onUpdateProperty("componentConfig.action.geolocationExtraValue", e.target.value)} + className="h-8 text-xs" + /> +
+
+
+
+ + onUpdateProperty("componentConfig.action.geolocationExtraKeyField", e.target.value)} + className="h-8 text-xs" + /> +
+
+ + +
+
+
+ )} +
+ + {/* 두 번째 추가 테이블 설정 */} +
+
+
+ +

두 번째 테이블의 필드 값도 함께 변경합니다

+
+ onUpdateProperty("componentConfig.action.geolocationSecondTableEnabled", checked)} + /> +
+ + {config.action?.geolocationSecondTableEnabled && ( +
+
+
+ + +
+
+ + +
+
+
+
+ + onUpdateProperty("componentConfig.action.geolocationSecondField", e.target.value)} + className="h-8 text-xs" + /> +
+
+ + onUpdateProperty("componentConfig.action.geolocationSecondValue", e.target.value)} + className="h-8 text-xs" + /> +
+
+
+
+ + onUpdateProperty("componentConfig.action.geolocationSecondKeyField", e.target.value)} + className="h-8 text-xs" + /> +
+
+ + +
+
+ + {config.action?.geolocationSecondMode === "insert" && ( +
+
+ +

위도/경도를 이 테이블에도 저장

+
+ onUpdateProperty("componentConfig.action.geolocationSecondInsertFields", { + ...config.action?.geolocationSecondInsertFields, + includeLocation: checked + })} + /> +
+ )} + +

+ {config.action?.geolocationSecondMode === "insert" + ? "새 레코드를 생성합니다. 연결 필드로 현재 폼 데이터와 연결됩니다." + : "기존 레코드를 수정합니다. 키 필드로 레코드를 찾아 값을 변경합니다."} +

+
+ )} +
+

사용 방법: @@ -1784,6 +2033,11 @@ export const ButtonConfigPanel: React.FC = ({
3. 위도/경도가 지정된 필드에 자동으로 입력됩니다
+ 4. 추가 테이블 설정이 있으면 해당 테이블의 필드도 함께 변경됩니다 +
+
+ 예시: 위치정보 저장 + vehicles.status를 inactive로 변경 +

참고: HTTPS 환경에서만 위치정보가 작동합니다.

@@ -1852,6 +2106,62 @@ export const ButtonConfigPanel: React.FC = ({
+ {/* 🆕 키 필드 설정 (레코드 식별용) */} +
+
레코드 식별 설정
+
+
+ + onUpdateProperty("componentConfig.action.updateKeyField", e.target.value)} + className="h-8 text-xs" + /> +

레코드를 찾을 DB 컬럼명

+
+
+ + +

키 값을 가져올 소스

+
+
+
+
@@ -1899,15 +2209,78 @@ export const ButtonConfigPanel: React.FC = ({
+ {/* 위치정보 수집 옵션 */} +
+
+
+ +

상태 변경과 함께 현재 GPS 좌표를 수집합니다

+
+ onUpdateProperty("componentConfig.action.updateWithGeolocation", checked)} + /> +
+ + {config.action?.updateWithGeolocation && ( +
+
+
+ + onUpdateProperty("componentConfig.action.updateGeolocationLatField", e.target.value)} + className="h-8 text-xs" + /> +
+
+ + onUpdateProperty("componentConfig.action.updateGeolocationLngField", e.target.value)} + className="h-8 text-xs" + /> +
+
+
+
+ + onUpdateProperty("componentConfig.action.updateGeolocationAccuracyField", e.target.value)} + className="h-8 text-xs" + /> +
+
+ + onUpdateProperty("componentConfig.action.updateGeolocationTimestampField", e.target.value)} + className="h-8 text-xs" + /> +
+
+

+ 버튼 클릭 시 GPS 위치를 수집하여 위 필드에 저장합니다. +

+
+ )} +
+

사용 예시:
- - 운행알림 버튼: status 필드를 "active"로 변경 + - 운행알림 버튼: status를 "active"로 + 위치정보 수집
- - 승인 버튼: approval_status 필드를 "approved"로 변경 + - 출발 버튼: status를 "inactive"로 + 위치정보 수집
- - 완료 버튼: is_completed 필드를 "Y"로 변경 + - 완료 버튼: is_completed를 "Y"로 변경

diff --git a/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx b/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx index 964196ba..2264c99f 100644 --- a/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx +++ b/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx @@ -360,6 +360,7 @@ export const UnifiedPropertiesPanel: React.FC = ({ SplitPanelPosition | null; + + // 🆕 우측에 추가된 항목 ID 관리 (좌측 테이블에서 필터링용) + addedItemIds: Set; + addItemIds: (ids: string[]) => void; + removeItemIds: (ids: string[]) => void; + clearItemIds: () => void; } const SplitPanelContext = createContext(null); @@ -74,6 +80,9 @@ export function SplitPanelProvider({ // 강제 리렌더링용 상태 const [, forceUpdate] = useState(0); + + // 🆕 우측에 추가된 항목 ID 상태 + const [addedItemIds, setAddedItemIds] = useState>(new Set()); /** * 데이터 수신자 등록 @@ -191,6 +200,38 @@ export function SplitPanelProvider({ [leftScreenId, rightScreenId] ); + /** + * 🆕 추가된 항목 ID 등록 + */ + const addItemIds = useCallback((ids: string[]) => { + setAddedItemIds((prev) => { + const newSet = new Set(prev); + ids.forEach((id) => newSet.add(id)); + logger.debug(`[SplitPanelContext] 항목 ID 추가: ${ids.length}개`, { ids }); + return newSet; + }); + }, []); + + /** + * 🆕 추가된 항목 ID 제거 + */ + const removeItemIds = useCallback((ids: string[]) => { + setAddedItemIds((prev) => { + const newSet = new Set(prev); + ids.forEach((id) => newSet.delete(id)); + logger.debug(`[SplitPanelContext] 항목 ID 제거: ${ids.length}개`, { ids }); + return newSet; + }); + }, []); + + /** + * 🆕 모든 항목 ID 초기화 + */ + const clearItemIds = useCallback(() => { + setAddedItemIds(new Set()); + logger.debug(`[SplitPanelContext] 항목 ID 초기화`); + }, []); + // 🆕 useMemo로 value 객체 메모이제이션 (무한 루프 방지) const value = React.useMemo(() => ({ splitPanelId, @@ -202,6 +243,10 @@ export function SplitPanelProvider({ getOtherSideReceivers, isInSplitPanel: true, getPositionByScreenId, + addedItemIds, + addItemIds, + removeItemIds, + clearItemIds, }), [ splitPanelId, leftScreenId, @@ -211,6 +256,10 @@ export function SplitPanelProvider({ transferToOtherSide, getOtherSideReceivers, getPositionByScreenId, + addedItemIds, + addItemIds, + removeItemIds, + clearItemIds, ]); return ( diff --git a/frontend/lib/api/dynamicForm.ts b/frontend/lib/api/dynamicForm.ts index 455ab5eb..a66d8f99 100644 --- a/frontend/lib/api/dynamicForm.ts +++ b/frontend/lib/api/dynamicForm.ts @@ -124,7 +124,7 @@ export class DynamicFormApi { * @returns 업데이트 결과 */ static async updateFormDataPartial( - id: number, + id: string | number, // 🔧 UUID 문자열도 지원 originalData: Record, newData: Record, tableName: string, diff --git a/frontend/lib/registry/DynamicComponentRenderer.tsx b/frontend/lib/registry/DynamicComponentRenderer.tsx index fe93f4af..0ea687bf 100644 --- a/frontend/lib/registry/DynamicComponentRenderer.tsx +++ b/frontend/lib/registry/DynamicComponentRenderer.tsx @@ -337,6 +337,11 @@ export const DynamicComponentRenderer: React.FC = // onChange 핸들러 - 컴포넌트 타입에 따라 다르게 처리 const handleChange = (value: any) => { + // autocomplete-search-input, entity-search-input은 자체적으로 onFormDataChange를 호출하므로 중복 저장 방지 + if (componentType === "autocomplete-search-input" || componentType === "entity-search-input") { + return; + } + // React 이벤트 객체인 경우 값 추출 let actualValue = value; if (value && typeof value === "object" && value.nativeEvent && value.target) { diff --git a/frontend/lib/registry/components/autocomplete-search-input/AutocompleteSearchInputComponent.tsx b/frontend/lib/registry/components/autocomplete-search-input/AutocompleteSearchInputComponent.tsx index e3572e33..1c5920f0 100644 --- a/frontend/lib/registry/components/autocomplete-search-input/AutocompleteSearchInputComponent.tsx +++ b/frontend/lib/registry/components/autocomplete-search-input/AutocompleteSearchInputComponent.tsx @@ -57,20 +57,42 @@ export function AutocompleteSearchInputComponent({ filterCondition, }); + // 선택된 데이터를 ref로도 유지 (리렌더링 시 초기화 방지) + const selectedDataRef = useRef(null); + const inputValueRef = useRef(""); + // formData에서 현재 값 가져오기 (isInteractive 모드) const currentValue = isInteractive && formData && component?.columnName ? formData[component.columnName] : value; - // value가 변경되면 표시값 업데이트 + // selectedData 변경 시 ref도 업데이트 useEffect(() => { - if (currentValue && selectedData) { - setInputValue(selectedData[displayField] || ""); - } else if (!currentValue) { - setInputValue(""); - setSelectedData(null); + if (selectedData) { + selectedDataRef.current = selectedData; + inputValueRef.current = inputValue; } - }, [currentValue, displayField, selectedData]); + }, [selectedData, inputValue]); + + // 리렌더링 시 ref에서 값 복원 + useEffect(() => { + if (!selectedData && selectedDataRef.current) { + setSelectedData(selectedDataRef.current); + setInputValue(inputValueRef.current); + } + }, []); + + // value가 변경되면 표시값 업데이트 - 단, selectedData가 있으면 유지 + useEffect(() => { + // selectedData가 있으면 표시값 유지 (사용자가 방금 선택한 경우) + if (selectedData || selectedDataRef.current) { + return; + } + + if (!currentValue) { + setInputValue(""); + } + }, [currentValue, selectedData]); // 외부 클릭 감지 useEffect(() => { diff --git a/frontend/lib/registry/components/autocomplete-search-input/AutocompleteSearchInputConfigPanel.tsx b/frontend/lib/registry/components/autocomplete-search-input/AutocompleteSearchInputConfigPanel.tsx index e6942704..d2290c2f 100644 --- a/frontend/lib/registry/components/autocomplete-search-input/AutocompleteSearchInputConfigPanel.tsx +++ b/frontend/lib/registry/components/autocomplete-search-input/AutocompleteSearchInputConfigPanel.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useRef } from "react"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; @@ -21,7 +21,9 @@ export function AutocompleteSearchInputConfigPanel({ config, onConfigChange, }: AutocompleteSearchInputConfigPanelProps) { - const [localConfig, setLocalConfig] = useState(config); + // 초기화 여부 추적 (첫 마운트 시에만 config로 초기화) + const isInitialized = useRef(false); + const [localConfig, setLocalConfig] = useState(config); const [allTables, setAllTables] = useState([]); const [sourceTableColumns, setSourceTableColumns] = useState([]); const [targetTableColumns, setTargetTableColumns] = useState([]); @@ -32,12 +34,21 @@ export function AutocompleteSearchInputConfigPanel({ const [openTargetTableCombo, setOpenTargetTableCombo] = useState(false); const [openDisplayFieldCombo, setOpenDisplayFieldCombo] = useState(false); + // 첫 마운트 시에만 config로 초기화 (이후에는 localConfig 유지) useEffect(() => { - setLocalConfig(config); + if (!isInitialized.current && config) { + setLocalConfig(config); + isInitialized.current = true; + } }, [config]); const updateConfig = (updates: Partial) => { const newConfig = { ...localConfig, ...updates }; + console.log("🔧 [AutocompleteConfigPanel] updateConfig:", { + updates, + localConfig, + newConfig, + }); setLocalConfig(newConfig); onConfigChange(newConfig); }; @@ -325,10 +336,11 @@ export function AutocompleteSearchInputConfigPanel({
- updateFieldMapping(index, { targetField: value }) - } + value={mapping.targetField || undefined} + onValueChange={(value) => { + console.log("🔧 [Select] targetField 변경:", value); + updateFieldMapping(index, { targetField: value }); + }} disabled={!localConfig.targetTable || isLoadingTargetColumns} > diff --git a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx index 1e00442f..180dacaa 100644 --- a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx +++ b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx @@ -694,7 +694,7 @@ export const ButtonPrimaryComponent: React.FC = ({ const context: ButtonActionContext = { formData: formData || {}, - originalData: originalData || {}, // 부분 업데이트용 원본 데이터 추가 + originalData: originalData, // 🔧 빈 객체 대신 undefined 유지 (UPDATE 판단에 사용) screenId: effectiveScreenId, // 🆕 ScreenContext에서 가져온 값 사용 tableName: effectiveTableName, // 🆕 ScreenContext에서 가져온 값 사용 userId, // 🆕 사용자 ID diff --git a/frontend/lib/registry/components/divider-line/DividerLineComponent.tsx b/frontend/lib/registry/components/divider-line/DividerLineComponent.tsx index 5cc4fcfd..d2b61c90 100644 --- a/frontend/lib/registry/components/divider-line/DividerLineComponent.tsx +++ b/frontend/lib/registry/components/divider-line/DividerLineComponent.tsx @@ -53,6 +53,7 @@ export const DividerLineComponent: React.FC = ({ }; // DOM에 전달하면 안 되는 React-specific props 필터링 + // eslint-disable-next-line @typescript-eslint/no-unused-vars const { selectedScreen, onZoneComponentDrop, @@ -70,8 +71,40 @@ export const DividerLineComponent: React.FC = ({ tableName: _tableName, onRefresh: _onRefresh, onClose: _onClose, + // 추가된 props 필터링 + webType: _webType, + autoGeneration: _autoGeneration, + isInteractive: _isInteractive, + formData: _formData, + onFormDataChange: _onFormDataChange, + menuId: _menuId, + menuObjid: _menuObjid, + onSave: _onSave, + userId: _userId, + userName: _userName, + companyCode: _companyCode, + isInModal: _isInModal, + readonly: _readonly, + originalData: _originalData, + allComponents: _allComponents, + onUpdateLayout: _onUpdateLayout, + selectedRows: _selectedRows, + selectedRowsData: _selectedRowsData, + onSelectedRowsChange: _onSelectedRowsChange, + sortBy: _sortBy, + sortOrder: _sortOrder, + tableDisplayData: _tableDisplayData, + flowSelectedData: _flowSelectedData, + flowSelectedStepId: _flowSelectedStepId, + onFlowSelectedDataChange: _onFlowSelectedDataChange, + onConfigChange: _onConfigChange, + refreshKey: _refreshKey, + flowRefreshKey: _flowRefreshKey, + onFlowRefresh: _onFlowRefresh, + isPreview: _isPreview, + groupedData: _groupedData, ...domProps - } = props; + } = props as any; return (
diff --git a/frontend/lib/registry/components/location-swap-selector/LocationSwapSelectorComponent.tsx b/frontend/lib/registry/components/location-swap-selector/LocationSwapSelectorComponent.tsx index 045d62bd..5dc4a165 100644 --- a/frontend/lib/registry/components/location-swap-selector/LocationSwapSelectorComponent.tsx +++ b/frontend/lib/registry/components/location-swap-selector/LocationSwapSelectorComponent.tsx @@ -94,20 +94,51 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps) const showSwapButton = config.showSwapButton !== false && props.showSwapButton !== false; const variant = config.variant || props.variant || "card"; + // 기본 옵션 (포항/광양) + const DEFAULT_OPTIONS: LocationOption[] = [ + { value: "pohang", label: "포항" }, + { value: "gwangyang", label: "광양" }, + ]; + // 상태 - const [options, setOptions] = useState([]); + const [options, setOptions] = useState(DEFAULT_OPTIONS); const [loading, setLoading] = useState(false); const [isSwapping, setIsSwapping] = useState(false); - - // 현재 선택된 값 - const departureValue = formData[departureField] || ""; - const destinationValue = formData[destinationField] || ""; + + // 로컬 선택 상태 (Select 컴포넌트용) + const [localDeparture, setLocalDeparture] = useState(""); + const [localDestination, setLocalDestination] = useState(""); // 옵션 로드 useEffect(() => { const loadOptions = async () => { - if (dataSource.type === "static") { - setOptions(dataSource.staticOptions || []); + console.log("[LocationSwapSelector] 옵션 로드 시작:", { dataSource, isDesignMode }); + + // 정적 옵션 처리 (기본값) + // type이 없거나 static이거나, table인데 tableName이 없는 경우 + const shouldUseStatic = + !dataSource.type || + dataSource.type === "static" || + (dataSource.type === "table" && !dataSource.tableName) || + (dataSource.type === "code" && !dataSource.codeCategory); + + if (shouldUseStatic) { + const staticOpts = dataSource.staticOptions || []; + // 정적 옵션이 설정되어 있고, value가 유효한 경우 사용 + // (value가 필드명과 같으면 잘못 설정된 것이므로 기본값 사용) + const isValidOptions = staticOpts.length > 0 && + staticOpts[0]?.value && + staticOpts[0].value !== departureField && + staticOpts[0].value !== destinationField; + + if (isValidOptions) { + console.log("[LocationSwapSelector] 정적 옵션 사용:", staticOpts); + setOptions(staticOpts); + } else { + // 기본값 (포항/광양) + console.log("[LocationSwapSelector] 기본 옵션 사용 (잘못된 설정 감지):", { staticOpts, DEFAULT_OPTIONS }); + setOptions(DEFAULT_OPTIONS); + } return; } @@ -115,11 +146,13 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps) // 코드 관리에서 가져오기 setLoading(true); try { - const response = await apiClient.get(`/api/codes/${dataSource.codeCategory}`); + const response = await apiClient.get(`/code-management/codes`, { + params: { categoryCode: dataSource.codeCategory }, + }); if (response.data.success && response.data.data) { const codeOptions = response.data.data.map((code: any) => ({ - value: code.code_value || code.codeValue, - label: code.code_name || code.codeName, + value: code.code_value || code.codeValue || code.code, + label: code.code_name || code.codeName || code.name, })); setOptions(codeOptions); } @@ -135,13 +168,17 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps) // 테이블에서 가져오기 setLoading(true); try { - const response = await apiClient.get(`/api/dynamic/${dataSource.tableName}`, { - params: { pageSize: 1000 }, + const response = await apiClient.get(`/dynamic-form/list/${dataSource.tableName}`, { + params: { page: 1, pageSize: 1000 }, }); if (response.data.success && response.data.data) { - const tableOptions = response.data.data.map((row: any) => ({ - value: row[dataSource.valueField || "id"], - label: row[dataSource.labelField || "name"], + // data가 배열인지 또는 data.rows인지 확인 + const rows = Array.isArray(response.data.data) + ? response.data.data + : response.data.data.rows || []; + const tableOptions = rows.map((row: any) => ({ + value: String(row[dataSource.valueField || "id"] || ""), + label: String(row[dataSource.labelField || "name"] || ""), })); setOptions(tableOptions); } @@ -153,82 +190,130 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps) } }; - if (!isDesignMode) { - loadOptions(); - } else { - // 디자인 모드에서는 샘플 데이터 - setOptions([ - { value: "seoul", label: "서울" }, - { value: "busan", label: "부산" }, - { value: "pohang", label: "포항" }, - { value: "gwangyang", label: "광양" }, - ]); - } + loadOptions(); }, [dataSource, isDesignMode]); + // formData에서 초기값 동기화 + useEffect(() => { + const depVal = formData[departureField]; + const destVal = formData[destinationField]; + + if (depVal && options.some(o => o.value === depVal)) { + setLocalDeparture(depVal); + } + if (destVal && options.some(o => o.value === destVal)) { + setLocalDestination(destVal); + } + }, [formData, departureField, destinationField, options]); + // 출발지 변경 - const handleDepartureChange = (value: string) => { + const handleDepartureChange = (selectedValue: string) => { + console.log("[LocationSwapSelector] 출발지 변경:", { + selectedValue, + departureField, + hasOnFormDataChange: !!onFormDataChange, + options + }); + + // 로컬 상태 업데이트 + setLocalDeparture(selectedValue); + + // 부모에게 전달 if (onFormDataChange) { - onFormDataChange(departureField, value); + console.log(`[LocationSwapSelector] onFormDataChange 호출: ${departureField} = ${selectedValue}`); + onFormDataChange(departureField, selectedValue); // 라벨 필드도 업데이트 if (departureLabelField) { - const selectedOption = options.find((opt) => opt.value === value); - onFormDataChange(departureLabelField, selectedOption?.label || ""); + const selectedOption = options.find((opt) => opt.value === selectedValue); + if (selectedOption) { + console.log(`[LocationSwapSelector] onFormDataChange 호출: ${departureLabelField} = ${selectedOption.label}`); + onFormDataChange(departureLabelField, selectedOption.label); + } } + } else { + console.warn("[LocationSwapSelector] ⚠️ onFormDataChange가 없습니다!"); } }; // 도착지 변경 - const handleDestinationChange = (value: string) => { + const handleDestinationChange = (selectedValue: string) => { + console.log("[LocationSwapSelector] 도착지 변경:", { + selectedValue, + destinationField, + hasOnFormDataChange: !!onFormDataChange, + options + }); + + // 로컬 상태 업데이트 + setLocalDestination(selectedValue); + + // 부모에게 전달 if (onFormDataChange) { - onFormDataChange(destinationField, value); + console.log(`[LocationSwapSelector] onFormDataChange 호출: ${destinationField} = ${selectedValue}`); + onFormDataChange(destinationField, selectedValue); // 라벨 필드도 업데이트 if (destinationLabelField) { - const selectedOption = options.find((opt) => opt.value === value); - onFormDataChange(destinationLabelField, selectedOption?.label || ""); + const selectedOption = options.find((opt) => opt.value === selectedValue); + if (selectedOption) { + console.log(`[LocationSwapSelector] onFormDataChange 호출: ${destinationLabelField} = ${selectedOption.label}`); + onFormDataChange(destinationLabelField, selectedOption.label); + } } + } else { + console.warn("[LocationSwapSelector] ⚠️ onFormDataChange가 없습니다!"); } }; // 출발지/도착지 교환 const handleSwap = () => { - if (!onFormDataChange) return; - setIsSwapping(true); - // 값 교환 - const tempDeparture = departureValue; - const tempDestination = destinationValue; + // 로컬 상태 교환 + const tempDeparture = localDeparture; + const tempDestination = localDestination; + + setLocalDeparture(tempDestination); + setLocalDestination(tempDeparture); - onFormDataChange(departureField, tempDestination); - onFormDataChange(destinationField, tempDeparture); + // 부모에게 전달 + if (onFormDataChange) { + onFormDataChange(departureField, tempDestination); + onFormDataChange(destinationField, tempDeparture); - // 라벨도 교환 - if (departureLabelField && destinationLabelField) { - const tempDepartureLabel = formData[departureLabelField]; - const tempDestinationLabel = formData[destinationLabelField]; - onFormDataChange(departureLabelField, tempDestinationLabel); - onFormDataChange(destinationLabelField, tempDepartureLabel); + // 라벨도 교환 + if (departureLabelField && destinationLabelField) { + const depOption = options.find(o => o.value === tempDestination); + const destOption = options.find(o => o.value === tempDeparture); + onFormDataChange(departureLabelField, depOption?.label || ""); + onFormDataChange(destinationLabelField, destOption?.label || ""); + } } // 애니메이션 효과 setTimeout(() => setIsSwapping(false), 300); }; - // 선택된 라벨 가져오기 - const getDepartureLabel = () => { - const option = options.find((opt) => opt.value === departureValue); - return option?.label || "선택"; - }; - - const getDestinationLabel = () => { - const option = options.find((opt) => opt.value === destinationValue); - return option?.label || "선택"; - }; - // 스타일에서 width, height 추출 const { width, height, ...restStyle } = style || {}; + // 선택된 라벨 가져오기 + const getDepartureLabel = () => { + const opt = options.find(o => o.value === localDeparture); + return opt?.label || ""; + }; + + const getDestinationLabel = () => { + const opt = options.find(o => o.value === localDestination); + return opt?.label || ""; + }; + + // 디버그 로그 + console.log("[LocationSwapSelector] 렌더:", { + localDeparture, + localDestination, + options: options.map(o => `${o.value}:${o.label}`), + }); + // Card 스타일 (이미지 참고) if (variant === "card") { return ( @@ -242,18 +327,21 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
{departureLabel} - - - - {getDestinationLabel()} - - + + {localDestination ? ( + {getDestinationLabel()} + ) : ( + 선택 + )} - + {options.map((option) => ( {option.label} @@ -320,14 +410,14 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
- + {localDestination ? getDestinationLabel() : 선택} - + {options.map((option) => ( {option.label} @@ -381,14 +470,14 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps) style={restStyle} > - + {localDestination ? getDestinationLabel() : {destinationLabel}} - + {options.map((option) => ( {option.label} diff --git a/frontend/lib/registry/components/location-swap-selector/LocationSwapSelectorConfigPanel.tsx b/frontend/lib/registry/components/location-swap-selector/LocationSwapSelectorConfigPanel.tsx index c18f6514..4e21cddf 100644 --- a/frontend/lib/registry/components/location-swap-selector/LocationSwapSelectorConfigPanel.tsx +++ b/frontend/lib/registry/components/location-swap-selector/LocationSwapSelectorConfigPanel.tsx @@ -90,7 +90,7 @@ export function LocationSwapSelectorConfigPanel({ } }, [config?.dataSource?.tableName, config?.dataSource?.type]); - // 코드 카테고리 로드 + // 코드 카테고리 로드 (API가 없을 수 있으므로 에러 무시) useEffect(() => { const loadCodeCategories = async () => { try { @@ -103,8 +103,11 @@ export function LocationSwapSelectorConfigPanel({ })) ); } - } catch (error) { - console.error("코드 카테고리 로드 실패:", error); + } catch (error: any) { + // 404는 API가 없는 것이므로 무시 + if (error?.response?.status !== 404) { + console.error("코드 카테고리 로드 실패:", error); + } } }; loadCodeCategories(); @@ -139,13 +142,83 @@ export function LocationSwapSelectorConfigPanel({ - 정적 옵션 (하드코딩) - 테이블 - 코드 관리 + 고정 옵션 (포항/광양 등) + 테이블에서 가져오기 + 코드 관리에서 가져오기
+ {/* 고정 옵션 설정 (type이 static일 때) */} + {(!config?.dataSource?.type || config?.dataSource?.type === "static") && ( +
+

고정 옵션 설정

+
+
+ + { + const options = config?.dataSource?.staticOptions || []; + const newOptions = [...options]; + newOptions[0] = { ...newOptions[0], value: e.target.value }; + handleChange("dataSource.staticOptions", newOptions); + }} + placeholder="예: pohang" + className="h-8 text-xs" + /> +
+
+ + { + const options = config?.dataSource?.staticOptions || []; + const newOptions = [...options]; + newOptions[0] = { ...newOptions[0], label: e.target.value }; + handleChange("dataSource.staticOptions", newOptions); + }} + placeholder="예: 포항" + className="h-8 text-xs" + /> +
+
+
+
+ + { + const options = config?.dataSource?.staticOptions || []; + const newOptions = [...options]; + newOptions[1] = { ...newOptions[1], value: e.target.value }; + handleChange("dataSource.staticOptions", newOptions); + }} + placeholder="예: gwangyang" + className="h-8 text-xs" + /> +
+
+ + { + const options = config?.dataSource?.staticOptions || []; + const newOptions = [...options]; + newOptions[1] = { ...newOptions[1], label: e.target.value }; + handleChange("dataSource.staticOptions", newOptions); + }} + placeholder="예: 광양" + className="h-8 text-xs" + /> +
+
+

+ 고정된 2개 장소만 사용할 때 설정하세요. (예: 포항 ↔ 광양) +

+
+ )} + {/* 테이블 선택 (type이 table일 때) */} {config?.dataSource?.type === "table" && ( <> @@ -298,14 +371,14 @@ export function LocationSwapSelectorConfigPanel({ {tableColumns.length > 0 ? ( handleChange("destinationLabelField", value)} + value={config?.destinationLabelField || "__none__"} + onValueChange={(value) => handleChange("destinationLabelField", value === "__none__" ? "" : value)} > - 없음 + 없음 {tableColumns.map((col) => ( {col.columnLabel || col.columnName} diff --git a/frontend/lib/registry/components/location-swap-selector/LocationSwapSelectorRenderer.tsx b/frontend/lib/registry/components/location-swap-selector/LocationSwapSelectorRenderer.tsx index 8e3fe5f7..6adc4724 100644 --- a/frontend/lib/registry/components/location-swap-selector/LocationSwapSelectorRenderer.tsx +++ b/frontend/lib/registry/components/location-swap-selector/LocationSwapSelectorRenderer.tsx @@ -12,7 +12,28 @@ export class LocationSwapSelectorRenderer extends AutoRegisteringComponentRender static componentDefinition = LocationSwapSelectorDefinition; render(): React.ReactElement { - return ; + const { component, formData, onFormDataChange, isDesignMode, style, ...restProps } = this.props; + + // component.componentConfig에서 설정 가져오기 + const componentConfig = component?.componentConfig || {}; + + console.log("[LocationSwapSelectorRenderer] render:", { + componentConfig, + formData, + isDesignMode + }); + + return ( + + ); } } diff --git a/frontend/lib/registry/components/location-swap-selector/index.ts b/frontend/lib/registry/components/location-swap-selector/index.ts index 60b62008..c4c30418 100644 --- a/frontend/lib/registry/components/location-swap-selector/index.ts +++ b/frontend/lib/registry/components/location-swap-selector/index.ts @@ -20,12 +20,15 @@ export const LocationSwapSelectorDefinition = createComponentDefinition({ defaultConfig: { // 데이터 소스 설정 dataSource: { - type: "table", // "table" | "code" | "static" + type: "static", // "table" | "code" | "static" tableName: "", // 장소 테이블명 valueField: "location_code", // 값 필드 labelField: "location_name", // 표시 필드 codeCategory: "", // 코드 관리 카테고리 (type이 "code"일 때) - staticOptions: [], // 정적 옵션 (type이 "static"일 때) + staticOptions: [ + { value: "pohang", label: "포항" }, + { value: "gwangyang", label: "광양" }, + ], // 정적 옵션 (type이 "static"일 때) }, // 필드 매핑 departureField: "departure", // 출발지 저장 필드 diff --git a/frontend/lib/registry/components/repeater-field-group/RepeaterFieldGroupRenderer.tsx b/frontend/lib/registry/components/repeater-field-group/RepeaterFieldGroupRenderer.tsx index a4dbd157..c47ff3c9 100644 --- a/frontend/lib/registry/components/repeater-field-group/RepeaterFieldGroupRenderer.tsx +++ b/frontend/lib/registry/components/repeater-field-group/RepeaterFieldGroupRenderer.tsx @@ -120,10 +120,15 @@ const RepeaterFieldGroupComponent: React.FC = (props) => setGroupedData(items); // 🆕 원본 데이터 ID 목록 저장 (삭제 추적용) - const itemIds = items.map((item: any) => item.id).filter(Boolean); + const itemIds = items.map((item: any) => String(item.id || item.po_item_id || item.item_id)).filter(Boolean); setOriginalItemIds(itemIds); console.log("📋 [RepeaterFieldGroup] 원본 데이터 ID 목록 저장:", itemIds); + // 🆕 SplitPanelContext에 기존 항목 ID 등록 (좌측 테이블 필터링용) + if (splitPanelContext?.addItemIds && itemIds.length > 0) { + splitPanelContext.addItemIds(itemIds); + } + // onChange 호출하여 부모에게 알림 if (onChange && items.length > 0) { const dataWithMeta = items.map((item: any) => ({ @@ -244,11 +249,54 @@ const RepeaterFieldGroupComponent: React.FC = (props) => const currentValue = parsedValueRef.current; // mode가 "replace"인 경우 기존 데이터 대체, 그 외에는 추가 - // 🆕 필터링된 데이터 사용 const mode = typeof mappingRulesOrMode === "string" ? mappingRulesOrMode : "append"; - const newItems = mode === "replace" ? filteredData : [...currentValue, ...filteredData]; + + let newItems: any[]; + let addedCount = 0; + let duplicateCount = 0; + + if (mode === "replace") { + newItems = filteredData; + addedCount = filteredData.length; + } else { + // 🆕 중복 체크: id 또는 고유 식별자를 기준으로 이미 존재하는 항목 제외 + const existingIds = new Set( + currentValue + .map((item: any) => item.id || item.po_item_id || item.item_id) + .filter(Boolean) + ); + + const uniqueNewItems = filteredData.filter((item: any) => { + const itemId = item.id || item.po_item_id || item.item_id; + if (itemId && existingIds.has(itemId)) { + duplicateCount++; + return false; // 중복 항목 제외 + } + return true; + }); + + newItems = [...currentValue, ...uniqueNewItems]; + addedCount = uniqueNewItems.length; + } - console.log("📥 [RepeaterFieldGroup] 최종 데이터:", { currentValue, newItems, mode }); + console.log("📥 [RepeaterFieldGroup] 최종 데이터:", { + currentValue, + newItems, + mode, + addedCount, + duplicateCount, + }); + + // 🆕 groupedData 상태도 직접 업데이트 (UI 즉시 반영) + setGroupedData(newItems); + + // 🆕 SplitPanelContext에 추가된 항목 ID 등록 (좌측 테이블 필터링용) + if (splitPanelContext?.addItemIds && addedCount > 0) { + const newItemIds = newItems + .map((item: any) => String(item.id || item.po_item_id || item.item_id)) + .filter(Boolean); + splitPanelContext.addItemIds(newItemIds); + } // JSON 문자열로 변환하여 저장 const jsonValue = JSON.stringify(newItems); @@ -268,7 +316,16 @@ const RepeaterFieldGroupComponent: React.FC = (props) => onChangeRef.current(jsonValue); } - toast.success(`${filteredData.length}개 항목이 추가되었습니다`); + // 결과 메시지 표시 + if (addedCount > 0) { + if (duplicateCount > 0) { + toast.success(`${addedCount}개 항목이 추가되었습니다 (${duplicateCount}개 중복 제외)`); + } else { + toast.success(`${addedCount}개 항목이 추가되었습니다`); + } + } else if (duplicateCount > 0) { + toast.warning(`${duplicateCount}개 항목이 이미 추가되어 있습니다`); + } }, []); // DataReceivable 인터페이스 구현 @@ -311,14 +368,69 @@ const RepeaterFieldGroupComponent: React.FC = (props) => } }, [splitPanelContext, screenContext?.splitPanelPosition, component.id, dataReceiver]); + // 🆕 전역 이벤트 리스너 (splitPanelDataTransfer) + useEffect(() => { + const handleSplitPanelDataTransfer = (event: CustomEvent) => { + const { data, mode, mappingRules } = event.detail; + + console.log("📥 [RepeaterFieldGroup] splitPanelDataTransfer 이벤트 수신:", { + dataCount: data?.length, + mode, + componentId: component.id, + }); + + // 우측 패널의 리피터 필드 그룹만 데이터를 수신 + const splitPanelPosition = screenContext?.splitPanelPosition; + if (splitPanelPosition === "right" && data && data.length > 0) { + handleReceiveData(data, mappingRules || mode || "append"); + } + }; + + window.addEventListener("splitPanelDataTransfer", handleSplitPanelDataTransfer as EventListener); + + return () => { + window.removeEventListener("splitPanelDataTransfer", handleSplitPanelDataTransfer as EventListener); + }; + }, [screenContext?.splitPanelPosition, handleReceiveData, component.id]); + + // 🆕 RepeaterInput에서 항목 변경 시 SplitPanelContext의 addedItemIds 동기화 + const handleRepeaterChange = useCallback((newValue: any[]) => { + // 배열을 JSON 문자열로 변환하여 저장 + const jsonValue = JSON.stringify(newValue); + onChange?.(jsonValue); + + // 🆕 groupedData 상태도 업데이트 + setGroupedData(newValue); + + // 🆕 SplitPanelContext의 addedItemIds 동기화 + if (splitPanelContext?.isInSplitPanel && screenContext?.splitPanelPosition === "right") { + // 현재 항목들의 ID 목록 + const currentIds = newValue + .map((item: any) => String(item.id || item.po_item_id || item.item_id)) + .filter(Boolean); + + // 기존 addedItemIds와 비교하여 삭제된 ID 찾기 + const addedIds = splitPanelContext.addedItemIds; + const removedIds = Array.from(addedIds).filter(id => !currentIds.includes(id)); + + if (removedIds.length > 0) { + console.log("🗑️ [RepeaterFieldGroup] 삭제된 항목 ID 제거:", removedIds); + splitPanelContext.removeItemIds(removedIds); + } + + // 새로 추가된 ID가 있으면 등록 + const newIds = currentIds.filter((id: string) => !addedIds.has(id)); + if (newIds.length > 0) { + console.log("➕ [RepeaterFieldGroup] 새 항목 ID 추가:", newIds); + splitPanelContext.addItemIds(newIds); + } + } + }, [onChange, splitPanelContext, screenContext?.splitPanelPosition]); + return ( { - // 배열을 JSON 문자열로 변환하여 저장 - const jsonValue = JSON.stringify(newValue); - onChange?.(jsonValue); - }} + onChange={handleRepeaterChange} config={config} disabled={disabled} readonly={readonly} diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index a0f01727..841e6f0a 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -330,6 +330,25 @@ export const TableListComponent: React.FC = ({ const [data, setData] = useState[]>([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + + // 🆕 분할 패널에서 우측에 이미 추가된 항목 필터링 (좌측 테이블에만 적용) + const filteredData = useMemo(() => { + // 분할 패널 좌측에 있고, 우측에 추가된 항목이 있는 경우에만 필터링 + if (splitPanelPosition === "left" && splitPanelContext?.addedItemIds && splitPanelContext.addedItemIds.size > 0) { + const addedIds = splitPanelContext.addedItemIds; + const filtered = data.filter((row) => { + const rowId = String(row.id || row.po_item_id || row.item_id || ""); + return !addedIds.has(rowId); + }); + console.log("🔍 [TableList] 우측 추가 항목 필터링:", { + originalCount: data.length, + filteredCount: filtered.length, + addedIdsCount: addedIds.size, + }); + return filtered; + } + return data; + }, [data, splitPanelPosition, splitPanelContext?.addedItemIds]); const [currentPage, setCurrentPage] = useState(1); const [totalPages, setTotalPages] = useState(0); const [totalItems, setTotalItems] = useState(0); @@ -438,8 +457,8 @@ export const TableListComponent: React.FC = ({ componentType: "table-list", getSelectedData: () => { - // 선택된 행의 실제 데이터 반환 - const selectedData = data.filter((row) => { + // 🆕 필터링된 데이터에서 선택된 행만 반환 (우측에 추가된 항목 제외) + const selectedData = filteredData.filter((row) => { const rowId = String(row.id || row[tableConfig.selectedTable + "_id"] || ""); return selectedRows.has(rowId); }); @@ -447,7 +466,8 @@ export const TableListComponent: React.FC = ({ }, getAllData: () => { - return data; + // 🆕 필터링된 데이터 반환 + return filteredData; }, clearSelection: () => { @@ -1375,31 +1395,31 @@ export const TableListComponent: React.FC = ({ }); } - const allRowsSelected = data.every((row, index) => newSelectedRows.has(getRowKey(row, index))); - setIsAllSelected(allRowsSelected && data.length > 0); + const allRowsSelected = filteredData.every((row, index) => newSelectedRows.has(getRowKey(row, index))); + setIsAllSelected(allRowsSelected && filteredData.length > 0); }; const handleSelectAll = (checked: boolean) => { if (checked) { - const allKeys = data.map((row, index) => getRowKey(row, index)); + const allKeys = filteredData.map((row, index) => getRowKey(row, index)); const newSelectedRows = new Set(allKeys); setSelectedRows(newSelectedRows); setIsAllSelected(true); if (onSelectedRowsChange) { - onSelectedRowsChange(Array.from(newSelectedRows), data, sortColumn || undefined, sortDirection); + onSelectedRowsChange(Array.from(newSelectedRows), filteredData, sortColumn || undefined, sortDirection); } if (onFormDataChange) { onFormDataChange({ selectedRows: Array.from(newSelectedRows), - selectedRowsData: data, + selectedRowsData: filteredData, }); } // 🆕 modalDataStore에 전체 데이터 저장 - if (tableConfig.selectedTable && data.length > 0) { + if (tableConfig.selectedTable && filteredData.length > 0) { import("@/stores/modalDataStore").then(({ useModalDataStore }) => { - const modalItems = data.map((row, idx) => ({ + const modalItems = filteredData.map((row, idx) => ({ id: getRowKey(row, idx), originalData: row, additionalData: {}, @@ -2003,11 +2023,11 @@ export const TableListComponent: React.FC = ({ // 데이터 그룹화 const groupedData = useMemo((): GroupedData[] => { - if (groupByColumns.length === 0 || data.length === 0) return []; + if (groupByColumns.length === 0 || filteredData.length === 0) return []; const grouped = new Map(); - data.forEach((item) => { + filteredData.forEach((item) => { // 그룹 키 생성: "통화:KRW > 단위:EA" const keyParts = groupByColumns.map((col) => { // 카테고리/엔티티 타입인 경우 _name 필드 사용 @@ -2334,7 +2354,7 @@ export const TableListComponent: React.FC = ({
)} -
+
= ({
= ({ className="sticky z-50" style={{ position: "sticky", - top: "-2px", + top: 0, zIndex: 50, backgroundColor: "hsl(var(--background))", }} @@ -2706,7 +2725,7 @@ export const TableListComponent: React.FC = ({ }) ) : ( // 일반 렌더링 (그룹 없음) - data.map((row, index) => ( + filteredData.map((row, index) => ( opt.value === value)) { - const savedLabel = selectedLabels[filter.columnName] || value; - options = [{ value, label: savedLabel }, ...options]; - } - // 중복 제거 (value 기준) const uniqueOptions = options.reduce( (acc, option) => { @@ -360,39 +364,86 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table [] as Array<{ value: string; label: string }>, ); + // 항상 다중선택 모드 + const selectedValues: string[] = Array.isArray(value) ? value : (value ? [value] : []); + + // 선택된 값들의 라벨 표시 + const getDisplayText = () => { + if (selectedValues.length === 0) return column?.columnLabel || "선택"; + if (selectedValues.length === 1) { + const opt = uniqueOptions.find(o => o.value === selectedValues[0]); + return opt?.label || selectedValues[0]; + } + return `${selectedValues.length}개 선택됨`; + }; + + const handleMultiSelectChange = (optionValue: string, checked: boolean) => { + let newValues: string[]; + if (checked) { + newValues = [...selectedValues, optionValue]; + } else { + newValues = selectedValues.filter(v => v !== optionValue); + } + handleFilterChange(filter.columnName, newValues.length > 0 ? newValues : ""); + }; + return ( - + + ); } diff --git a/frontend/lib/registry/components/table-search-widget/TableSearchWidgetConfigPanel.tsx b/frontend/lib/registry/components/table-search-widget/TableSearchWidgetConfigPanel.tsx index 8c4ab6a1..3424abb9 100644 --- a/frontend/lib/registry/components/table-search-widget/TableSearchWidgetConfigPanel.tsx +++ b/frontend/lib/registry/components/table-search-widget/TableSearchWidgetConfigPanel.tsx @@ -29,6 +29,7 @@ interface PresetFilter { columnLabel: string; filterType: "text" | "number" | "date" | "select"; width?: number; + multiSelect?: boolean; // 다중선택 여부 (select 타입에서만 사용) } export function TableSearchWidgetConfigPanel({ diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index cf53a490..953e7c24 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -110,6 +110,16 @@ export interface ButtonActionConfig { geolocationExtraValue?: string | number | boolean; // 추가로 변경할 값 (예: "active") geolocationExtraKeyField?: string; // 다른 테이블의 키 필드 (예: "vehicle_id") geolocationExtraKeySourceField?: string; // 현재 폼에서 키 값을 가져올 필드 (예: "vehicle_id") + + // 🆕 두 번째 테이블 설정 (위치정보 + 상태변경을 각각 다른 테이블에) + geolocationSecondTableEnabled?: boolean; // 두 번째 테이블 사용 여부 + geolocationSecondTableName?: string; // 두 번째 테이블명 (예: "vehicles") + geolocationSecondMode?: "update" | "insert"; // 작업 모드 (기본: update) + geolocationSecondField?: string; // 두 번째 테이블에서 변경할 필드명 (예: "status") + geolocationSecondValue?: string | number | boolean; // 두 번째 테이블에서 변경할 값 (예: "inactive") + geolocationSecondKeyField?: string; // 두 번째 테이블의 키 필드 (예: "id") - UPDATE 모드에서만 사용 + geolocationSecondKeySourceField?: string; // 현재 폼에서 키 값을 가져올 필드 (예: "vehicle_id") - UPDATE 모드에서만 사용 + geolocationSecondInsertFields?: Record; // INSERT 모드에서 추가로 넣을 필드들 // 필드 값 교환 관련 (출발지 ↔ 목적지) swapFieldA?: string; // 교환할 첫 번째 필드명 (예: "departure") @@ -121,6 +131,13 @@ export interface ButtonActionConfig { updateTargetValue?: string | number | boolean; // 변경할 값 (예: "active") updateAutoSave?: boolean; // 변경 후 자동 저장 여부 (기본: true) updateMultipleFields?: Array<{ field: string; value: string | number | boolean }>; // 여러 필드 동시 변경 + + // 🆕 필드 값 변경 + 위치정보 수집 (update_field 액션에서 사용) + updateWithGeolocation?: boolean; // 위치정보도 함께 수집할지 여부 + updateGeolocationLatField?: string; // 위도 저장 필드 + updateGeolocationLngField?: string; // 경도 저장 필드 + updateGeolocationAccuracyField?: string; // 정확도 저장 필드 (선택) + updateGeolocationTimestampField?: string; // 타임스탬프 저장 필드 (선택) // 편집 관련 (수주관리 등 그룹별 다중 레코드 편집) editMode?: "modal" | "navigate" | "inline"; // 편집 모드 @@ -131,37 +148,37 @@ export interface ButtonActionConfig { // 데이터 전달 관련 (transferData 액션용) dataTransfer?: { // 소스 설정 - sourceComponentId: string; // 데이터를 가져올 컴포넌트 ID (테이블 등) - sourceComponentType?: string; // 소스 컴포넌트 타입 - + sourceComponentId: string; // 데이터를 가져올 컴포넌트 ID (테이블 등) + sourceComponentType?: string; // 소스 컴포넌트 타입 + // 타겟 설정 - targetType: "component" | "screen"; // 타겟 타입 (같은 화면의 컴포넌트 or 다른 화면) - + targetType: "component" | "screen"; // 타겟 타입 (같은 화면의 컴포넌트 or 다른 화면) + // 타겟이 컴포넌트인 경우 - targetComponentId?: string; // 타겟 컴포넌트 ID - + targetComponentId?: string; // 타겟 컴포넌트 ID + // 타겟이 화면인 경우 - targetScreenId?: number; // 타겟 화면 ID - + targetScreenId?: number; // 타겟 화면 ID + // 데이터 매핑 규칙 mappingRules: Array<{ - sourceField: string; // 소스 필드명 - targetField: string; // 타겟 필드명 + sourceField: string; // 소스 필드명 + targetField: string; // 타겟 필드명 transform?: "sum" | "average" | "concat" | "first" | "last" | "count"; // 변환 함수 - defaultValue?: any; // 기본값 + defaultValue?: any; // 기본값 }>; - + // 전달 옵션 mode?: "append" | "replace" | "merge"; // 수신 모드 (기본: append) - clearAfterTransfer?: boolean; // 전달 후 소스 데이터 초기화 - confirmBeforeTransfer?: boolean; // 전달 전 확인 메시지 - confirmMessage?: string; // 확인 메시지 내용 - + clearAfterTransfer?: boolean; // 전달 후 소스 데이터 초기화 + confirmBeforeTransfer?: boolean; // 전달 전 확인 메시지 + confirmMessage?: string; // 확인 메시지 내용 + // 검증 validation?: { - requireSelection?: boolean; // 선택 필수 (기본: true) - minSelection?: number; // 최소 선택 개수 - maxSelection?: number; // 최대 선택 개수 + requireSelection?: boolean; // 선택 필수 (기본: true) + minSelection?: number; // 최소 선택 개수 + maxSelection?: number; // 최대 선택 개수 }; }; } @@ -190,7 +207,7 @@ export interface ButtonActionContext { // 플로우 선택된 데이터 정보 (플로우 위젯 선택 액션용) flowSelectedData?: any[]; flowSelectedStepId?: number | null; - + // 🆕 같은 화면의 모든 컴포넌트 (TableList 자동 감지용) allComponents?: any[]; @@ -217,6 +234,44 @@ export interface ButtonActionContext { componentConfigs?: Record; // 컴포넌트 ID → 컴포넌트 설정 } +/** + * 🆕 특수 키워드를 실제 값으로 변환하는 헬퍼 함수 + * 지원하는 키워드: + * - __userId__ : 로그인한 사용자 ID + * - __userName__ : 로그인한 사용자 이름 + * - __companyCode__ : 로그인한 사용자의 회사 코드 + * - __screenId__ : 현재 화면 ID + * - __tableName__ : 현재 테이블명 + */ +export function resolveSpecialKeyword( + sourceField: string | undefined, + context: ButtonActionContext +): any { + if (!sourceField) return undefined; + + // 특수 키워드 처리 + switch (sourceField) { + case "__userId__": + console.log("🔑 특수 키워드 변환: __userId__ →", context.userId); + return context.userId; + case "__userName__": + console.log("🔑 특수 키워드 변환: __userName__ →", context.userName); + return context.userName; + case "__companyCode__": + console.log("🔑 특수 키워드 변환: __companyCode__ →", context.companyCode); + return context.companyCode; + case "__screenId__": + console.log("🔑 특수 키워드 변환: __screenId__ →", context.screenId); + return context.screenId; + case "__tableName__": + console.log("🔑 특수 키워드 변환: __tableName__ →", context.tableName); + return context.tableName; + default: + // 일반 폼 데이터에서 가져오기 + return context.formData?.[sourceField]; + } +} + /** * 버튼 액션 실행기 */ @@ -268,9 +323,15 @@ export class ButtonActionExecutor { case "code_merge": return await this.handleCodeMerge(config, context); + case "transferData": + return await this.handleTransferData(config, context); + case "geolocation": return await this.handleGeolocation(config, context); + case "swap_fields": + return await this.handleSwapFields(config, context); + case "update_field": return await this.handleUpdateField(config, context); @@ -292,7 +353,7 @@ export class ButtonActionExecutor { const { formData, originalData, tableName, screenId, onSave } = context; console.log("💾 [handleSave] 저장 시작:", { formData, tableName, screenId, hasOnSave: !!onSave }); - + // 🆕 EditModal 등에서 전달된 onSave 콜백이 있으면 우선 사용 if (onSave) { console.log("✅ [handleSave] onSave 콜백 발견 - 콜백 실행"); @@ -304,20 +365,22 @@ export class ButtonActionExecutor { throw error; } } - + console.log("⚠️ [handleSave] onSave 콜백 없음 - 기본 저장 로직 실행"); // 🆕 저장 전 이벤트 발생 (SelectedItemsDetailInput 등에서 최신 데이터 수집) // context.formData를 이벤트 detail에 포함하여 직접 수정 가능하게 함 - window.dispatchEvent(new CustomEvent("beforeFormSave", { - detail: { - formData: context.formData - } - })); - + window.dispatchEvent( + new CustomEvent("beforeFormSave", { + detail: { + formData: context.formData, + }, + }), + ); + // 약간의 대기 시간을 주어 이벤트 핸들러가 formData를 업데이트할 수 있도록 함 - await new Promise(resolve => setTimeout(resolve, 100)); - + await new Promise((resolve) => setTimeout(resolve, 100)); + console.log("📦 [handleSave] beforeFormSave 이벤트 후 formData:", context.formData); // 🆕 SelectedItemsDetailInput 배치 저장 처리 (fieldGroups 구조) @@ -328,33 +391,41 @@ export class ButtonActionExecutor { key, isArray: Array.isArray(value), length: Array.isArray(value) ? value.length : 0, - firstItem: Array.isArray(value) && value.length > 0 ? { - hasOriginalData: !!value[0]?.originalData, - hasFieldGroups: !!value[0]?.fieldGroups, - keys: Object.keys(value[0] || {}) - } : null - })) + firstItem: + Array.isArray(value) && value.length > 0 + ? { + hasOriginalData: !!value[0]?.originalData, + hasFieldGroups: !!value[0]?.fieldGroups, + keys: Object.keys(value[0] || {}), + } + : null, + })), }); // 🔧 formData 자체가 배열인 경우 (ScreenModal의 그룹 레코드 수정) if (Array.isArray(context.formData)) { - console.log("⚠️ [handleSave] formData가 배열입니다 - SelectedItemsDetailInput이 이미 처리했으므로 일반 저장 건너뜀"); + console.log( + "⚠️ [handleSave] formData가 배열입니다 - SelectedItemsDetailInput이 이미 처리했으므로 일반 저장 건너뜀", + ); console.log("⚠️ [handleSave] formData 배열:", context.formData); // ✅ SelectedItemsDetailInput이 이미 UPSERT를 실행했으므로 일반 저장을 건너뜀 return true; // 성공으로 반환 } - const selectedItemsKeys = Object.keys(context.formData).filter(key => { + const selectedItemsKeys = Object.keys(context.formData).filter((key) => { const value = context.formData[key]; console.log(`🔍 [handleSave] 필터링 체크 - ${key}:`, { isArray: Array.isArray(value), length: Array.isArray(value) ? value.length : 0, - firstItem: Array.isArray(value) && value.length > 0 ? { - keys: Object.keys(value[0] || {}), - hasOriginalData: !!value[0]?.originalData, - hasFieldGroups: !!value[0]?.fieldGroups, - actualValue: value[0], - } : null + firstItem: + Array.isArray(value) && value.length > 0 + ? { + keys: Object.keys(value[0] || {}), + hasOriginalData: !!value[0]?.originalData, + hasFieldGroups: !!value[0]?.fieldGroups, + actualValue: value[0], + } + : null, }); return Array.isArray(value) && value.length > 0 && value[0]?.originalData && value[0]?.fieldGroups; }); @@ -401,9 +472,20 @@ export class ButtonActionExecutor { const primaryKeys = primaryKeyResult.data || []; const primaryKeyValue = this.extractPrimaryKeyValueFromDB(formData, primaryKeys); - // 단순히 기본키 값 존재 여부로 판단 (임시) - // TODO: 실제 테이블에서 기본키로 레코드 존재 여부 확인하는 API 필요 - const isUpdate = false; // 현재는 항상 INSERT로 처리 + // 🔧 수정: originalData가 있고 실제 데이터가 있으면 UPDATE 모드로 처리 + // originalData는 수정 버튼 클릭 시 editData로 전달되어 context.originalData로 설정됨 + // 빈 객체 {}도 truthy이므로 Object.keys로 실제 데이터 유무 확인 + const hasRealOriginalData = originalData && Object.keys(originalData).length > 0; + const isUpdate = hasRealOriginalData && !!primaryKeyValue; + + console.log("🔍 [handleSave] INSERT/UPDATE 판단:", { + hasOriginalData: !!originalData, + hasRealOriginalData, + originalDataKeys: originalData ? Object.keys(originalData) : [], + primaryKeyValue, + isUpdate, + primaryKeys, + }); let saveResult; @@ -452,9 +534,9 @@ export class ButtonActionExecutor { // 🎯 채번 규칙 할당 처리 (저장 시점에 실제 순번 증가) // console.log("🔍 채번 규칙 할당 체크 시작"); // console.log("📦 현재 formData:", JSON.stringify(formData, null, 2)); - + const fieldsWithNumbering: Record = {}; - + // formData에서 채번 규칙이 설정된 필드 찾기 for (const [key, value] of Object.entries(formData)) { if (key.endsWith("_numberingRuleId") && value) { @@ -474,7 +556,7 @@ export class ButtonActionExecutor { console.log("ℹ️ 채번 규칙 필드 감지:", Object.keys(fieldsWithNumbering)); console.log("ℹ️ 사용자 입력 값 유지 (재할당 하지 않음)"); } - + // console.log("✅ 채번 규칙 할당 완료"); // console.log("📦 최종 formData:", JSON.stringify(formData, null, 2)); @@ -496,7 +578,7 @@ export class ButtonActionExecutor { // 🆕 반복 필드 그룹에서 삭제된 항목 처리 // formData의 각 필드에서 _deletedItemIds가 있는지 확인 console.log("🔍 [handleSave] 삭제 항목 검색 시작 - dataWithUserInfo 키:", Object.keys(dataWithUserInfo)); - + for (const [key, value] of Object.entries(dataWithUserInfo)) { console.log(`🔍 [handleSave] 필드 검사: ${key}`, { type: typeof value, @@ -504,9 +586,9 @@ export class ButtonActionExecutor { isString: typeof value === "string", valuePreview: typeof value === "string" ? value.substring(0, 100) : value, }); - + let parsedValue = value; - + // JSON 문자열인 경우 파싱 시도 if (typeof value === "string" && value.startsWith("[")) { try { @@ -516,25 +598,25 @@ export class ButtonActionExecutor { // 파싱 실패하면 원본 값 유지 } } - + if (Array.isArray(parsedValue) && parsedValue.length > 0) { const firstItem = parsedValue[0]; const deletedItemIds = firstItem?._deletedItemIds; const targetTable = firstItem?._targetTable; - + console.log(`🔍 [handleSave] 배열 필드 분석: ${key}`, { firstItemKeys: firstItem ? Object.keys(firstItem) : [], deletedItemIds, targetTable, }); - + if (deletedItemIds && deletedItemIds.length > 0 && targetTable) { console.log("🗑️ [handleSave] 삭제할 항목 발견:", { fieldKey: key, targetTable, deletedItemIds, }); - + // 삭제 API 호출 for (const itemId of deletedItemIds) { try { @@ -552,7 +634,7 @@ export class ButtonActionExecutor { } } } - + saveResult = await DynamicFormApi.saveFormData({ screenId, tableName, @@ -665,12 +747,12 @@ export class ButtonActionExecutor { * ItemData[] → 각 품목의 details 배열을 개별 레코드로 저장 */ private static async handleBatchSave( - config: ButtonActionConfig, + config: ButtonActionConfig, context: ButtonActionContext, - selectedItemsKeys: string[] + selectedItemsKeys: string[], ): Promise { const { formData, tableName, screenId, selectedRowsData, originalData } = context; - + console.log(`🔍 [handleBatchSave] context 확인:`, { hasSelectedRowsData: !!selectedRowsData, selectedRowsCount: selectedRowsData?.length || 0, @@ -691,39 +773,38 @@ export class ButtonActionExecutor { // 🆕 부모 화면 데이터 준비 (parentDataMapping용) // selectedRowsData 또는 originalData를 parentData로 사용 const parentData = selectedRowsData?.[0] || originalData || {}; - + // 🆕 modalDataStore에서 누적된 모든 테이블 데이터 가져오기 // (여러 단계 모달에서 전달된 데이터 접근용) let modalDataStoreRegistry: Record = {}; - if (typeof window !== 'undefined') { + if (typeof window !== "undefined") { try { // Zustand store에서 데이터 가져오기 - const { useModalDataStore } = await import('@/stores/modalDataStore'); + const { useModalDataStore } = await import("@/stores/modalDataStore"); modalDataStoreRegistry = useModalDataStore.getState().dataRegistry; } catch (error) { console.warn("⚠️ modalDataStore 로드 실패:", error); } } - + // 각 테이블의 첫 번째 항목을 modalDataStore로 변환 const modalDataStore: Record = {}; Object.entries(modalDataStoreRegistry).forEach(([key, items]) => { if (Array.isArray(items) && items.length > 0) { // ModalDataItem[] → originalData 추출 - modalDataStore[key] = items.map(item => item.originalData || item); + modalDataStore[key] = items.map((item) => item.originalData || item); } }); - // 각 SelectedItemsDetailInput 컴포넌트의 데이터 처리 for (const key of selectedItemsKeys) { // 🆕 새로운 데이터 구조: ItemData[] with fieldGroups - const items = formData[key] as Array<{ - id: string; - originalData: any; - fieldGroups: Record>; + const items = formData[key] as Array<{ + id: string; + originalData: any; + fieldGroups: Record>; }>; - + // 🆕 이 컴포넌트의 parentDataMapping 설정 가져오기 const componentConfig = context.componentConfigs?.[key]; const parentDataMapping = componentConfig?.parentDataMapping || []; @@ -731,44 +812,42 @@ export class ButtonActionExecutor { // 🆕 각 품목의 그룹 간 조합(카티션 곱) 생성 for (const item of items) { const groupKeys = Object.keys(item.fieldGroups); - + // 각 그룹의 항목 배열 가져오기 - const groupArrays = groupKeys.map(groupKey => ({ + const groupArrays = groupKeys.map((groupKey) => ({ groupKey, - entries: item.fieldGroups[groupKey] || [] + entries: item.fieldGroups[groupKey] || [], })); - + // 카티션 곱 계산 함수 const cartesianProduct = (arrays: any[][]): any[][] => { if (arrays.length === 0) return [[]]; - if (arrays.length === 1) return arrays[0].map(item => [item]); - + if (arrays.length === 1) return arrays[0].map((item) => [item]); + const [first, ...rest] = arrays; const restProduct = cartesianProduct(rest); - - return first.flatMap(item => - restProduct.map(combination => [item, ...combination]) - ); + + return first.flatMap((item) => restProduct.map((combination) => [item, ...combination])); }; - + // 모든 그룹의 카티션 곱 생성 - const entryArrays = groupArrays.map(g => g.entries); + const entryArrays = groupArrays.map((g) => g.entries); const combinations = cartesianProduct(entryArrays); - + // 각 조합을 개별 레코드로 저장 for (let i = 0; i < combinations.length; i++) { const combination = combinations[i]; try { // 🆕 부모 데이터 매핑 적용 const mappedData: any = {}; - + // 1. parentDataMapping 설정이 있으면 적용 if (parentDataMapping.length > 0) { for (const mapping of parentDataMapping) { let sourceData: any; const sourceTableName = mapping.sourceTable; const selectedItemTable = componentConfig?.sourceTable; - + if (sourceTableName === selectedItemTable) { sourceData = item.originalData; } else { @@ -779,9 +858,9 @@ export class ButtonActionExecutor { sourceData = parentData; } } - + const sourceValue = sourceData[mapping.sourceField]; - + if (sourceValue !== undefined && sourceValue !== null) { mappedData[mapping.targetField] = sourceValue; } else if (mapping.defaultValue !== undefined) { @@ -793,12 +872,12 @@ export class ButtonActionExecutor { if (item.originalData.id) { mappedData.item_id = item.originalData.id; } - + if (parentData.id || parentData.customer_id) { mappedData.customer_id = parentData.customer_id || parentData.id; } } - + // 공통 필드 복사 (company_code, currency_code 등) if (item.originalData.company_code && !mappedData.company_code) { mappedData.company_code = item.originalData.company_code; @@ -806,10 +885,10 @@ export class ButtonActionExecutor { if (item.originalData.currency_code && !mappedData.currency_code) { mappedData.currency_code = item.originalData.currency_code; } - + // 원본 데이터로 시작 (매핑된 데이터 사용) let mergedData = { ...mappedData }; - + // 각 그룹의 항목 데이터를 순차적으로 병합 for (let j = 0; j < combination.length; j++) { const entry = combination[j]; @@ -1137,13 +1216,13 @@ export class ButtonActionExecutor { // 🆕 1. 현재 화면의 TableList 또는 SplitPanelLayout 자동 감지 let dataSourceId = config.dataSourceId; - + if (!dataSourceId && context.allComponents) { // TableList 우선 감지 const tableListComponent = context.allComponents.find( - (comp: any) => comp.componentType === "table-list" && comp.componentConfig?.tableName + (comp: any) => comp.componentType === "table-list" && comp.componentConfig?.tableName, ); - + if (tableListComponent) { dataSourceId = tableListComponent.componentConfig.tableName; console.log("✨ TableList 자동 감지:", { @@ -1153,9 +1232,9 @@ export class ButtonActionExecutor { } else { // TableList가 없으면 SplitPanelLayout의 좌측 패널 감지 const splitPanelComponent = context.allComponents.find( - (comp: any) => comp.componentType === "split-panel-layout" && comp.componentConfig?.leftPanel?.tableName + (comp: any) => comp.componentType === "split-panel-layout" && comp.componentConfig?.leftPanel?.tableName, ); - + if (splitPanelComponent) { dataSourceId = splitPanelComponent.componentConfig.leftPanel.tableName; console.log("✨ 분할 패널 좌측 테이블 자동 감지:", { @@ -1165,7 +1244,7 @@ export class ButtonActionExecutor { } } } - + // 여전히 없으면 context.tableName 또는 "default" 사용 if (!dataSourceId) { dataSourceId = context.tableName || "default"; @@ -1175,7 +1254,7 @@ export class ButtonActionExecutor { try { const { useModalDataStore } = await import("@/stores/modalDataStore"); const dataRegistry = useModalDataStore.getState().dataRegistry; - + const modalData = dataRegistry[dataSourceId] || []; console.log("📊 현재 화면 데이터 확인:", { @@ -1205,13 +1284,13 @@ export class ButtonActionExecutor { // 6. 동적 모달 제목 생성 const { useModalDataStore } = await import("@/stores/modalDataStore"); const dataRegistry = useModalDataStore.getState().dataRegistry; - + let finalTitle = "데이터 입력"; - + // 🆕 블록 기반 제목 (우선순위 1) if (config.modalTitleBlocks && config.modalTitleBlocks.length > 0) { const titleParts: string[] = []; - + config.modalTitleBlocks.forEach((block) => { if (block.type === "text") { // 텍스트 블록: 그대로 추가 @@ -1220,13 +1299,13 @@ export class ButtonActionExecutor { // 필드 블록: 데이터에서 값 가져오기 const tableName = block.tableName; const columnName = block.value; - + if (tableName && columnName) { const tableData = dataRegistry[tableName]; if (tableData && tableData.length > 0) { const firstItem = tableData[0].originalData || tableData[0]; const value = firstItem[columnName]; - + if (value !== undefined && value !== null) { titleParts.push(String(value)); console.log(`✨ 동적 필드: ${tableName}.${columnName} → ${value}`); @@ -1241,28 +1320,28 @@ export class ButtonActionExecutor { } } }); - + finalTitle = titleParts.join(""); console.log("📋 블록 기반 제목 생성:", finalTitle); } // 기존 방식: {tableName.columnName} 패턴 (우선순위 2) else if (config.modalTitle) { finalTitle = config.modalTitle; - + if (finalTitle.includes("{")) { const matches = finalTitle.match(/\{([^}]+)\}/g); - + if (matches) { matches.forEach((match) => { const path = match.slice(1, -1); // {item_info.item_name} → item_info.item_name const [tableName, columnName] = path.split("."); - + if (tableName && columnName) { const tableData = dataRegistry[tableName]; if (tableData && tableData.length > 0) { const firstItem = tableData[0].originalData || tableData[0]; const value = firstItem[columnName]; - + if (value !== undefined && value !== null) { finalTitle = finalTitle.replace(match, String(value)); console.log(`✨ 동적 제목: ${match} → ${value}`); @@ -1273,7 +1352,7 @@ export class ButtonActionExecutor { } } } - + // 7. 모달 열기 + URL 파라미터로 dataSourceId 전달 if (config.targetScreenId) { // config에 modalDescription이 있으면 우선 사용 @@ -1301,10 +1380,10 @@ export class ButtonActionExecutor { }); window.dispatchEvent(modalEvent); - + // 성공 메시지 (간단하게) toast.success(config.successMessage || "다음 단계로 진행합니다."); - + return true; } else { console.error("모달로 열 화면이 지정되지 않았습니다."); @@ -1507,15 +1586,15 @@ export class ButtonActionExecutor { const layoutData = await screenApi.getLayout(config.targetScreenId); if (layoutData?.components) { hasSplitPanel = layoutData.components.some( - (comp: any) => - comp.type === "screen-split-panel" || + (comp: any) => + comp.type === "screen-split-panel" || comp.componentType === "screen-split-panel" || - comp.type === "split-panel-layout" || - comp.componentType === "split-panel-layout" + comp.type === "split-panel-layout" || + comp.componentType === "split-panel-layout", ); } - console.log("🔍 [openEditModal] 분할 패널 확인:", { - targetScreenId: config.targetScreenId, + console.log("🔍 [openEditModal] 분할 패널 확인:", { + targetScreenId: config.targetScreenId, hasSplitPanel, componentTypes: layoutData?.components?.map((c: any) => c.type || c.componentType) || [], }); @@ -1666,7 +1745,8 @@ export class ButtonActionExecutor { if (copiedData[field] !== undefined) { const originalValue = copiedData[field]; const ruleIdKey = `${field}_numberingRuleId`; - const hasNumberingRule = rowData[ruleIdKey] !== undefined && rowData[ruleIdKey] !== null && rowData[ruleIdKey] !== ""; + const hasNumberingRule = + rowData[ruleIdKey] !== undefined && rowData[ruleIdKey] !== null && rowData[ruleIdKey] !== ""; // 품목코드를 무조건 공백으로 초기화 copiedData[field] = ""; @@ -1855,7 +1935,7 @@ export class ButtonActionExecutor { // flowConfig가 있으면 controlMode가 명시되지 않아도 플로우 모드로 간주 const hasFlowConfig = config.dataflowConfig?.flowConfig && config.dataflowConfig.flowConfig.flowId; const isFlowMode = config.dataflowConfig?.controlMode === "flow" || hasFlowConfig; - + if (isFlowMode && config.dataflowConfig?.flowConfig) { console.log("🎯 노드 플로우 실행:", config.dataflowConfig.flowConfig); @@ -2638,14 +2718,14 @@ export class ButtonActionExecutor { if (context.tableName) { const { tableDisplayStore } = await import("@/stores/tableDisplayStore"); const storedData = tableDisplayStore.getTableData(context.tableName); - + // 필터 조건은 저장소 또는 context에서 가져오기 const filterConditions = storedData?.filterConditions || context.filterConditions; const searchTerm = storedData?.searchTerm || context.searchTerm; try { const { entityJoinApi } = await import("@/lib/api/entityJoin"); - + const apiParams = { page: 1, size: 10000, // 최대 10,000개 @@ -2655,7 +2735,7 @@ export class ButtonActionExecutor { enableEntityJoin: true, // ✅ Entity 조인 // autoFilter는 entityJoinApi.getTableDataWithJoins 내부에서 자동으로 적용됨 }; - + // 🔒 멀티테넌시 준수: autoFilter로 company_code 자동 적용 const response = await entityJoinApi.getTableDataWithJoins(context.tableName, apiParams); @@ -2671,7 +2751,7 @@ export class ButtonActionExecutor { if (Array.isArray(response)) { // 배열로 직접 반환된 경우 dataToExport = response; - } else if (response && 'data' in response) { + } else if (response && "data" in response) { // EntityJoinResponse 객체인 경우 dataToExport = response.data; } else { @@ -2712,7 +2792,7 @@ export class ButtonActionExecutor { // 파일명 생성 (메뉴 이름 우선 사용) let defaultFileName = context.tableName || "데이터"; - + // localStorage에서 메뉴 이름 가져오기 if (typeof window !== "undefined") { const menuName = localStorage.getItem("currentMenuName"); @@ -2720,107 +2800,104 @@ export class ButtonActionExecutor { defaultFileName = menuName; } } - + const fileName = config.excelFileName || `${defaultFileName}_${new Date().toISOString().split("T")[0]}.xlsx`; const sheetName = config.excelSheetName || "Sheet1"; const includeHeaders = config.excelIncludeHeaders !== false; - // 🎨 화면 레이아웃에서 테이블 리스트 컴포넌트의 컬럼 설정 가져오기 - let visibleColumns: string[] | undefined = undefined; - let columnLabels: Record | undefined = undefined; - + // 🎨 화면 레이아웃에서 테이블 리스트 컴포넌트의 컬럼 설정 가져오기 + let visibleColumns: string[] | undefined = undefined; + let columnLabels: Record | undefined = undefined; + + try { + // 화면 레이아웃 데이터 가져오기 (별도 API 사용) + const { apiClient } = await import("@/lib/api/client"); + const layoutResponse = await apiClient.get(`/screen-management/screens/${context.screenId}/layout`); + + if (layoutResponse.data?.success && layoutResponse.data?.data) { + let layoutData = layoutResponse.data.data; + + // components가 문자열이면 파싱 + if (typeof layoutData.components === "string") { + layoutData.components = JSON.parse(layoutData.components); + } + + // 테이블 리스트 컴포넌트 찾기 + const findTableListComponent = (components: any[]): any => { + if (!Array.isArray(components)) return null; + + for (const comp of components) { + // componentType이 'table-list'인지 확인 + const isTableList = comp.componentType === "table-list"; + + // componentConfig 안에서 테이블명 확인 + const matchesTable = + comp.componentConfig?.selectedTable === context.tableName || + comp.componentConfig?.tableName === context.tableName; + + if (isTableList && matchesTable) { + return comp; + } + if (comp.children && comp.children.length > 0) { + const found = findTableListComponent(comp.children); + if (found) return found; + } + } + return null; + }; + + const tableListComponent = findTableListComponent(layoutData.components || []); + + if (tableListComponent && tableListComponent.componentConfig?.columns) { + const columns = tableListComponent.componentConfig.columns; + + // visible이 true인 컬럼만 추출 + visibleColumns = columns.filter((col: any) => col.visible !== false).map((col: any) => col.columnName); + + // 🎯 column_labels 테이블에서 실제 라벨 가져오기 try { - // 화면 레이아웃 데이터 가져오기 (별도 API 사용) - const { apiClient } = await import("@/lib/api/client"); - const layoutResponse = await apiClient.get(`/screen-management/screens/${context.screenId}/layout`); - - if (layoutResponse.data?.success && layoutResponse.data?.data) { - let layoutData = layoutResponse.data.data; - - // components가 문자열이면 파싱 - if (typeof layoutData.components === 'string') { - layoutData.components = JSON.parse(layoutData.components); + const columnsResponse = await apiClient.get(`/table-management/tables/${context.tableName}/columns`, { + params: { page: 1, size: 9999 }, + }); + + if (columnsResponse.data?.success && columnsResponse.data?.data) { + let columnData = columnsResponse.data.data; + + // data가 객체이고 columns 필드가 있으면 추출 + if (columnData.columns && Array.isArray(columnData.columns)) { + columnData = columnData.columns; } - - // 테이블 리스트 컴포넌트 찾기 - const findTableListComponent = (components: any[]): any => { - if (!Array.isArray(components)) return null; - - for (const comp of components) { - // componentType이 'table-list'인지 확인 - const isTableList = comp.componentType === 'table-list'; - - // componentConfig 안에서 테이블명 확인 - const matchesTable = - comp.componentConfig?.selectedTable === context.tableName || - comp.componentConfig?.tableName === context.tableName; - - if (isTableList && matchesTable) { - return comp; + + if (Array.isArray(columnData)) { + columnLabels = {}; + + // API에서 가져온 라벨로 매핑 + columnData.forEach((colData: any) => { + const colName = colData.column_name || colData.columnName; + // 우선순위: column_label > label > displayName > columnName + const labelValue = colData.column_label || colData.label || colData.displayName || colName; + if (colName && labelValue) { + columnLabels![colName] = labelValue; } - if (comp.children && comp.children.length > 0) { - const found = findTableListComponent(comp.children); - if (found) return found; - } - } - return null; - }; - - const tableListComponent = findTableListComponent(layoutData.components || []); - - if (tableListComponent && tableListComponent.componentConfig?.columns) { - const columns = tableListComponent.componentConfig.columns; - - // visible이 true인 컬럼만 추출 - visibleColumns = columns - .filter((col: any) => col.visible !== false) - .map((col: any) => col.columnName); - - // 🎯 column_labels 테이블에서 실제 라벨 가져오기 - try { - const columnsResponse = await apiClient.get(`/table-management/tables/${context.tableName}/columns`, { - params: { page: 1, size: 9999 } - }); - - if (columnsResponse.data?.success && columnsResponse.data?.data) { - let columnData = columnsResponse.data.data; - - // data가 객체이고 columns 필드가 있으면 추출 - if (columnData.columns && Array.isArray(columnData.columns)) { - columnData = columnData.columns; - } - - if (Array.isArray(columnData)) { - columnLabels = {}; - - // API에서 가져온 라벨로 매핑 - columnData.forEach((colData: any) => { - const colName = colData.column_name || colData.columnName; - // 우선순위: column_label > label > displayName > columnName - const labelValue = colData.column_label || colData.label || colData.displayName || colName; - if (colName && labelValue) { - columnLabels![colName] = labelValue; - } - }); - } - } - } catch (error) { - // 실패 시 컴포넌트 설정의 displayName 사용 - columnLabels = {}; - columns.forEach((col: any) => { - if (col.columnName) { - columnLabels![col.columnName] = col.displayName || col.label || col.columnName; - } - }); - } - } else { - console.warn("⚠️ 화면 레이아웃에서 테이블 리스트 컴포넌트를 찾을 수 없습니다."); + }); } } } catch (error) { - console.error("❌ 화면 레이아웃 조회 실패:", error); + // 실패 시 컴포넌트 설정의 displayName 사용 + columnLabels = {}; + columns.forEach((col: any) => { + if (col.columnName) { + columnLabels![col.columnName] = col.displayName || col.label || col.columnName; + } + }); } - + } else { + console.warn("⚠️ 화면 레이아웃에서 테이블 리스트 컴포넌트를 찾을 수 없습니다."); + } + } + } catch (error) { + console.error("❌ 화면 레이아웃 조회 실패:", error); + } // 🎨 카테고리 값들 조회 (한 번만) const categoryMap: Record> = {}; @@ -2830,20 +2907,20 @@ export class ButtonActionExecutor { if (context.tableName) { try { const { getCategoryColumns, getCategoryValues } = await import("@/lib/api/tableCategoryValue"); - + const categoryColumnsResponse = await getCategoryColumns(context.tableName); - + if (categoryColumnsResponse.success && categoryColumnsResponse.data) { // 백엔드에서 정의된 카테고리 컬럼들 - categoryColumns = categoryColumnsResponse.data.map((col: any) => - col.column_name || col.columnName || col.name - ).filter(Boolean); // undefined 제거 - + categoryColumns = categoryColumnsResponse.data + .map((col: any) => col.column_name || col.columnName || col.name) + .filter(Boolean); // undefined 제거 + // 각 카테고리 컬럼의 값들 조회 for (const columnName of categoryColumns) { try { const valuesResponse = await getCategoryValues(context.tableName, columnName, false); - + if (valuesResponse.success && valuesResponse.data) { // valueCode → valueLabel 매핑 categoryMap[columnName] = {}; @@ -2854,7 +2931,6 @@ export class ButtonActionExecutor { categoryMap[columnName][code] = label; } }); - } } catch (error) { console.error(`❌ 카테고리 "${columnName}" 조회 실패:`, error); @@ -2874,34 +2950,33 @@ export class ButtonActionExecutor { visibleColumns.forEach((columnName: string) => { // __checkbox__ 컬럼은 제외 if (columnName === "__checkbox__") return; - + if (columnName in row) { // 라벨 우선 사용, 없으면 컬럼명 사용 const label = columnLabels?.[columnName] || columnName; - + // 🎯 Entity 조인된 값 우선 사용 let value = row[columnName]; - + // writer → writer_name 사용 - if (columnName === 'writer' && row['writer_name']) { - value = row['writer_name']; + if (columnName === "writer" && row["writer_name"]) { + value = row["writer_name"]; } // 다른 엔티티 필드들도 _name 우선 사용 else if (row[`${columnName}_name`]) { value = row[`${columnName}_name`]; } // 카테고리 타입 필드는 라벨로 변환 (백엔드에서 정의된 컬럼만) - else if (categoryMap[columnName] && typeof value === 'string' && categoryMap[columnName][value]) { + else if (categoryMap[columnName] && typeof value === "string" && categoryMap[columnName][value]) { value = categoryMap[columnName][value]; } - + filteredRow[label] = value; } }); return filteredRow; }); - } // 최대 행 수 제한 @@ -2928,8 +3003,8 @@ export class ButtonActionExecutor { */ private static async handleExcelUpload(config: ButtonActionConfig, context: ButtonActionContext): Promise { try { - console.log("📤 엑셀 업로드 모달 열기:", { - config, + console.log("📤 엑셀 업로드 모달 열기:", { + config, context, userId: context.userId, tableName: context.tableName, @@ -3027,7 +3102,7 @@ export class ButtonActionExecutor { userId: context.userId, onScanSuccess: (barcode: string) => { console.log("✅ 바코드 스캔 성공:", barcode); - + // 대상 필드에 값 입력 if (config.barcodeTargetField && context.onFormDataChange) { context.onFormDataChange({ @@ -3037,7 +3112,7 @@ export class ButtonActionExecutor { } toast.success(`바코드 스캔 완료: ${barcode}`); - + // 자동 제출 옵션이 켜져있으면 저장 if (config.barcodeAutoSubmit) { this.handleSave(config, context); @@ -3164,7 +3239,7 @@ export class ButtonActionExecutor { // 미리보기 표시 (옵션) if (config.mergeShowPreview !== false) { const { apiClient } = await import("@/lib/api/client"); - + const previewResponse = await apiClient.post("/code-merge/preview", { columnName, oldValue, @@ -3176,12 +3251,12 @@ export class ButtonActionExecutor { const confirmMerge = confirm( `⚠️ 코드 병합 확인\n\n` + - `${oldValue} → ${newValue}\n\n` + - `영향받는 데이터:\n` + - `- 테이블 수: ${preview.preview.length}개\n` + - `- 총 행 수: ${totalRows}개\n\n` + - `데이터는 삭제되지 않고, "${columnName}" 컬럼 값만 변경됩니다.\n\n` + - `계속하시겠습니까?` + `${oldValue} → ${newValue}\n\n` + + `영향받는 데이터:\n` + + `- 테이블 수: ${preview.preview.length}개\n` + + `- 총 행 수: ${totalRows}개\n\n` + + `데이터는 삭제되지 않고, "${columnName}" 컬럼 값만 변경됩니다.\n\n` + + `계속하시겠습니까?`, ); if (!confirmMerge) { @@ -3194,7 +3269,7 @@ export class ButtonActionExecutor { toast.loading("코드 병합 중...", { duration: Infinity }); const { apiClient } = await import("@/lib/api/client"); - + const response = await apiClient.post("/code-merge/merge-all-tables", { columnName, oldValue, @@ -3206,8 +3281,7 @@ export class ButtonActionExecutor { if (response.data.success) { const data = response.data.data; toast.success( - `코드 병합 완료!\n` + - `${data.affectedTables.length}개 테이블, ${data.totalRowsUpdated}개 행 업데이트` + `코드 병합 완료!\n` + `${data.affectedTables.length}개 테이블, ${data.totalRowsUpdated}개 행 업데이트`, ); // 화면 새로고침 @@ -3227,12 +3301,116 @@ export class ButtonActionExecutor { } } + /** + * 데이터 전달 액션 처리 (분할 패널에서 좌측 → 우측 데이터 전달) + */ + private static async handleTransferData(config: ButtonActionConfig, context: ButtonActionContext): Promise { + try { + console.log("📤 [handleTransferData] 데이터 전달 시작:", { config, context }); + + // 선택된 행 데이터 확인 + const selectedRows = context.selectedRowsData || context.flowSelectedData || []; + + if (!selectedRows || selectedRows.length === 0) { + toast.error("전달할 데이터를 선택해주세요."); + return false; + } + + console.log("📤 [handleTransferData] 선택된 데이터:", selectedRows); + + // dataTransfer 설정 확인 + const dataTransfer = config.dataTransfer; + + if (!dataTransfer) { + // dataTransfer 설정이 없으면 기본 동작: 전역 이벤트로 데이터 전달 + console.log("📤 [handleTransferData] dataTransfer 설정 없음 - 전역 이벤트 발생"); + + const transferEvent = new CustomEvent("splitPanelDataTransfer", { + detail: { + data: selectedRows, + mode: "append", + sourcePosition: "left", + }, + }); + window.dispatchEvent(transferEvent); + + toast.success(`${selectedRows.length}개 항목이 전달되었습니다.`); + return true; + } + + // dataTransfer 설정이 있는 경우 + const { targetType, targetComponentId, targetScreenId, mappingRules, receiveMode } = dataTransfer; + + if (targetType === "component" && targetComponentId) { + // 같은 화면 내 컴포넌트로 전달 + console.log("📤 [handleTransferData] 컴포넌트로 전달:", targetComponentId); + + const transferEvent = new CustomEvent("componentDataTransfer", { + detail: { + targetComponentId, + data: selectedRows, + mappingRules, + mode: receiveMode || "append", + }, + }); + window.dispatchEvent(transferEvent); + + toast.success(`${selectedRows.length}개 항목이 전달되었습니다.`); + return true; + } else if (targetType === "screen" && targetScreenId) { + // 다른 화면으로 전달 (분할 패널 등) + console.log("📤 [handleTransferData] 화면으로 전달:", targetScreenId); + + const transferEvent = new CustomEvent("screenDataTransfer", { + detail: { + targetScreenId, + data: selectedRows, + mappingRules, + mode: receiveMode || "append", + }, + }); + window.dispatchEvent(transferEvent); + + toast.success(`${selectedRows.length}개 항목이 전달되었습니다.`); + return true; + } else { + // 기본: 분할 패널 데이터 전달 이벤트 + console.log("📤 [handleTransferData] 기본 분할 패널 전달"); + + const transferEvent = new CustomEvent("splitPanelDataTransfer", { + detail: { + data: selectedRows, + mappingRules, + mode: receiveMode || "append", + sourcePosition: "left", + }, + }); + window.dispatchEvent(transferEvent); + + toast.success(`${selectedRows.length}개 항목이 전달되었습니다.`); + return true; + } + } catch (error: any) { + console.error("❌ 데이터 전달 실패:", error); + toast.error(error.message || "데이터 전달 중 오류가 발생했습니다."); + return false; + } + } + /** * 위치정보 가져오기 액션 처리 */ private static async handleGeolocation(config: ButtonActionConfig, context: ButtonActionContext): Promise { try { console.log("📍 위치정보 가져오기 액션 실행:", { config, context }); + console.log("📍 [디버그] 추가 필드 설정값:", { + geolocationUpdateField: config.geolocationUpdateField, + geolocationExtraField: config.geolocationExtraField, + geolocationExtraValue: config.geolocationExtraValue, + geolocationExtraTableName: config.geolocationExtraTableName, + geolocationExtraKeyField: config.geolocationExtraKeyField, + geolocationExtraKeySourceField: config.geolocationExtraKeySourceField, + }); // 브라우저 Geolocation API 지원 확인 if (!navigator.geolocation) { @@ -3293,26 +3471,35 @@ export class ButtonActionExecutor { // 🆕 추가 필드 변경 (위치정보 + 상태변경) let extraTableUpdated = false; + let secondTableUpdated = false; + if (config.geolocationUpdateField && config.geolocationExtraField && config.geolocationExtraValue !== undefined) { - const extraTableName = config.geolocationExtraTableName; + const extraTableName = config.geolocationExtraTableName || context.tableName; const currentTableName = config.geolocationTableName || context.tableName; + const keySourceField = config.geolocationExtraKeySourceField; - // 다른 테이블에 UPDATE하는 경우 - if (extraTableName && extraTableName !== currentTableName) { - console.log("📍 다른 테이블 필드 변경:", { + // 🆕 특수 키워드가 설정되어 있으면 바로 DB UPDATE (같은 테이블이어도) + const hasSpecialKeyword = keySourceField?.startsWith("__") && keySourceField?.endsWith("__"); + const isDifferentTable = extraTableName && extraTableName !== currentTableName; + + // 다른 테이블이거나 특수 키워드가 설정된 경우 → 바로 DB UPDATE + if (isDifferentTable || hasSpecialKeyword) { + console.log("📍 DB 직접 UPDATE:", { targetTable: extraTableName, field: config.geolocationExtraField, value: config.geolocationExtraValue, keyField: config.geolocationExtraKeyField, - keySourceField: config.geolocationExtraKeySourceField, + keySourceField: keySourceField, + hasSpecialKeyword, + isDifferentTable, }); - // 키 값 가져오기 - const keyValue = context.formData?.[config.geolocationExtraKeySourceField || ""]; + // 키 값 가져오기 (특수 키워드 지원) + const keyValue = resolveSpecialKeyword(keySourceField, context); if (keyValue && config.geolocationExtraKeyField) { try { - // 다른 테이블 UPDATE API 호출 + // DB UPDATE API 호출 const { apiClient } = await import("@/lib/api/client"); const response = await apiClient.put(`/dynamic-form/update-field`, { tableName: extraTableName, @@ -3321,33 +3508,134 @@ export class ButtonActionExecutor { updateField: config.geolocationExtraField, updateValue: config.geolocationExtraValue, }); - + if (response.data?.success) { extraTableUpdated = true; - console.log("✅ 다른 테이블 UPDATE 성공:", response.data); + console.log("✅ DB UPDATE 성공:", response.data); } else { - console.error("❌ 다른 테이블 UPDATE 실패:", response.data); + console.error("❌ DB UPDATE 실패:", response.data); toast.error(`${extraTableName} 테이블 업데이트에 실패했습니다.`); } } catch (apiError) { - console.error("❌ 다른 테이블 UPDATE API 오류:", apiError); + console.error("❌ DB UPDATE API 오류:", apiError); toast.error(`${extraTableName} 테이블 업데이트 중 오류가 발생했습니다.`); } } else { - console.warn("⚠️ 키 값이 없어서 다른 테이블 UPDATE를 건너뜁니다:", { - keySourceField: config.geolocationExtraKeySourceField, + console.warn("⚠️ 키 값이 없어서 DB UPDATE를 건너뜁니다:", { + keySourceField: keySourceField, keyValue, }); } } else { - // 같은 테이블 (현재 폼 데이터에 추가) + // 같은 테이블이고 특수 키워드가 없는 경우 (현재 폼 데이터에 추가) updates[config.geolocationExtraField] = config.geolocationExtraValue; - console.log("📍 같은 테이블 추가 필드 변경:", { + console.log("📍 같은 테이블 추가 필드 변경 (폼 데이터):", { field: config.geolocationExtraField, value: config.geolocationExtraValue, }); } } + + // 🆕 두 번째 테이블 INSERT 또는 UPDATE + if (config.geolocationSecondTableEnabled && + config.geolocationSecondTableName) { + + const secondMode = config.geolocationSecondMode || "update"; + + console.log("📍 두 번째 테이블 작업:", { + mode: secondMode, + targetTable: config.geolocationSecondTableName, + field: config.geolocationSecondField, + value: config.geolocationSecondValue, + keyField: config.geolocationSecondKeyField, + keySourceField: config.geolocationSecondKeySourceField, + }); + + try { + const { apiClient } = await import("@/lib/api/client"); + + if (secondMode === "insert") { + // INSERT 모드: 새 레코드 생성 + const insertData: Record = { + // 위치정보 포함 (선택적) + ...(config.geolocationSecondInsertFields || {}), + }; + + // 기본 필드 추가 + if (config.geolocationSecondField && config.geolocationSecondValue !== undefined) { + insertData[config.geolocationSecondField] = config.geolocationSecondValue; + } + + // 위치정보도 두 번째 테이블에 저장하려면 추가 + // (선택적으로 위도/경도도 저장) + if (config.geolocationSecondInsertFields?.includeLocation) { + insertData[latField] = latitude; + insertData[lngField] = longitude; + if (config.geolocationAccuracyField) { + insertData[config.geolocationAccuracyField] = accuracy; + } + if (config.geolocationTimestampField) { + insertData[config.geolocationTimestampField] = timestamp.toISOString(); + } + } + + // 현재 폼에서 키 값 가져와서 연결 (외래키) - 특수 키워드 지원 + if (config.geolocationSecondKeySourceField && config.geolocationSecondKeyField) { + const keyValue = resolveSpecialKeyword(config.geolocationSecondKeySourceField, context); + if (keyValue) { + insertData[config.geolocationSecondKeyField] = keyValue; + } + } + + console.log("📍 두 번째 테이블 INSERT 데이터:", insertData); + + const response = await apiClient.post(`/dynamic-form/save`, { + tableName: config.geolocationSecondTableName, + data: insertData, + }); + + if (response.data?.success) { + secondTableUpdated = true; + console.log("✅ 두 번째 테이블 INSERT 성공:", response.data); + } else { + console.error("❌ 두 번째 테이블 INSERT 실패:", response.data); + toast.error(`${config.geolocationSecondTableName} 테이블 저장에 실패했습니다.`); + } + } else { + // UPDATE 모드: 기존 레코드 수정 + if (config.geolocationSecondField && config.geolocationSecondValue !== undefined) { + // 특수 키워드 지원 + const secondKeyValue = resolveSpecialKeyword(config.geolocationSecondKeySourceField, context); + + if (secondKeyValue && config.geolocationSecondKeyField) { + const response = await apiClient.put(`/dynamic-form/update-field`, { + tableName: config.geolocationSecondTableName, + keyField: config.geolocationSecondKeyField, + keyValue: secondKeyValue, + updateField: config.geolocationSecondField, + updateValue: config.geolocationSecondValue, + }); + + if (response.data?.success) { + secondTableUpdated = true; + console.log("✅ 두 번째 테이블 UPDATE 성공:", response.data); + } else { + console.error("❌ 두 번째 테이블 UPDATE 실패:", response.data); + toast.error(`${config.geolocationSecondTableName} 테이블 업데이트에 실패했습니다.`); + } + } else { + console.warn("⚠️ 두 번째 테이블 키 값이 없어서 UPDATE를 건너뜁니다:", { + keySourceField: config.geolocationSecondKeySourceField, + keyValue: secondKeyValue, + }); + } + } + } + } catch (apiError) { + console.error("❌ 두 번째 테이블 API 오류:", apiError); + toast.error(`${config.geolocationSecondTableName} 테이블 작업 중 오류가 발생했습니다.`); + } + } // formData 업데이트 if (context.onFormDataChange) { @@ -3357,30 +3645,112 @@ export class ButtonActionExecutor { } // 성공 메시지 생성 - let successMsg = config.successMessage || + let successMsg = + config.successMessage || `위치 정보를 가져왔습니다.\n위도: ${latitude.toFixed(6)}, 경도: ${longitude.toFixed(6)}`; - + // 추가 필드 변경이 있으면 메시지에 포함 if (config.geolocationUpdateField && config.geolocationExtraField) { if (extraTableUpdated) { successMsg += `\n[${config.geolocationExtraTableName}] ${config.geolocationExtraField}: ${config.geolocationExtraValue}`; - } else if (!config.geolocationExtraTableName || config.geolocationExtraTableName === (config.geolocationTableName || context.tableName)) { + } else if ( + !config.geolocationExtraTableName || + config.geolocationExtraTableName === (config.geolocationTableName || context.tableName) + ) { successMsg += `\n${config.geolocationExtraField}: ${config.geolocationExtraValue}`; } } + + // 두 번째 테이블 변경이 있으면 메시지에 포함 + if (secondTableUpdated && config.geolocationSecondTableName) { + successMsg += `\n[${config.geolocationSecondTableName}] ${config.geolocationSecondField}: ${config.geolocationSecondValue}`; + } // 성공 메시지 표시 toast.success(successMsg); // 자동 저장 옵션이 활성화된 경우 - if (config.geolocationAutoSave && context.onSave) { + if (config.geolocationAutoSave) { console.log("📍 위치정보 자동 저장 실행"); - try { - await context.onSave(); - toast.success("위치 정보가 저장되었습니다."); - } catch (saveError) { - console.error("❌ 위치정보 자동 저장 실패:", saveError); - toast.error("위치 정보 저장에 실패했습니다."); + + // onSave 콜백이 있으면 사용 + if (context.onSave) { + try { + await context.onSave(); + toast.success("위치 정보가 저장되었습니다."); + } catch (saveError) { + console.error("❌ 위치정보 자동 저장 실패:", saveError); + toast.error("위치 정보 저장에 실패했습니다."); + } + } else if (context.tableName && context.formData) { + // onSave가 없으면 직접 API 호출 + // 키 필드 설정이 있으면 update-field API 사용 (더 안전) + const keyField = config.geolocationExtraKeyField; + const keySourceField = config.geolocationExtraKeySourceField; + + if (keyField && keySourceField) { + try { + const { apiClient } = await import("@/lib/api/client"); + const keyValue = resolveSpecialKeyword(keySourceField, context); + + if (keyValue) { + // formData에서 저장할 필드들 추출 (위치정보 + 출발지/도착지 등) + const fieldsToSave = { ...updates }; + + // formData에서 추가로 저장할 필드들 (테이블에 존재할 가능성이 높은 필드만) + // departure, arrival은 location-swap-selector에서 설정한 필드명 사용 + const additionalFields = ['departure', 'arrival']; + additionalFields.forEach(field => { + if (context.formData?.[field] !== undefined && context.formData[field] !== '') { + fieldsToSave[field] = context.formData[field]; + } + }); + + console.log("📍 개별 필드 UPDATE:", { + tableName: context.tableName, + keyField, + keyValue, + fieldsToSave, + }); + + // 각 필드를 개별적으로 UPDATE (에러가 나도 다른 필드 계속 저장) + let successCount = 0; + let failCount = 0; + + for (const [field, value] of Object.entries(fieldsToSave)) { + try { + console.log(`🔄 UPDATE: ${context.tableName}.${field} = ${value}`); + + const response = await apiClient.put(`/dynamic-form/update-field`, { + tableName: context.tableName, + keyField: keyField, + keyValue: keyValue, + updateField: field, + updateValue: value, + }); + + if (response.data?.success) { + successCount++; + console.log(`✅ ${field} 업데이트 성공`); + } else { + failCount++; + console.warn(`⚠️ ${field} 업데이트 실패:`, response.data); + } + } catch (fieldError) { + failCount++; + console.warn(`⚠️ ${field} 업데이트 오류 (컬럼이 없을 수 있음):`, fieldError); + } + } + + console.log(`✅ 필드 저장 완료: 성공 ${successCount}개, 실패 ${failCount}개`); + } + } catch (saveError) { + console.error("❌ 위치정보 자동 저장 실패:", saveError); + toast.error("위치 정보 저장에 실패했습니다."); + } + } else { + console.warn("⚠️ 키 필드가 설정되지 않아 자동 저장을 건너뜁니다."); + } } } @@ -3412,8 +3782,62 @@ export class ButtonActionExecutor { } } + /** + * 필드 값 교환 액션 처리 (예: 출발지 ↔ 도착지) + */ + private static async handleSwapFields(config: ButtonActionConfig, context: ButtonActionContext): Promise { + try { + console.log("🔄 필드 값 교환 액션 실행:", { config, context }); + + const { formData, onFormDataChange } = context; + + // 교환할 필드 확인 + const fieldA = config.swapFieldA; + const fieldB = config.swapFieldB; + + if (!fieldA || !fieldB) { + toast.error("교환할 필드가 설정되지 않았습니다."); + return false; + } + + // 현재 값 가져오기 + const valueA = formData?.[fieldA]; + const valueB = formData?.[fieldB]; + + console.log("🔄 교환 전:", { [fieldA]: valueA, [fieldB]: valueB }); + + // 값 교환 + if (onFormDataChange) { + onFormDataChange(fieldA, valueB); + onFormDataChange(fieldB, valueA); + } + + // 관련 필드도 함께 교환 (예: 위도/경도) + if (config.swapRelatedFields && config.swapRelatedFields.length > 0) { + for (const related of config.swapRelatedFields) { + const relatedValueA = formData?.[related.fieldA]; + const relatedValueB = formData?.[related.fieldB]; + if (onFormDataChange) { + onFormDataChange(related.fieldA, relatedValueB); + onFormDataChange(related.fieldB, relatedValueA); + } + } + } + + console.log("🔄 교환 후:", { [fieldA]: valueB, [fieldB]: valueA }); + + toast.success(config.successMessage || "값이 교환되었습니다."); + return true; + } catch (error) { + console.error("❌ 필드 값 교환 오류:", error); + toast.error(config.errorMessage || "값 교환 중 오류가 발생했습니다."); + return false; + } + } + /** * 필드 값 변경 액션 처리 (예: status를 active로 변경) + * 🆕 위치정보 수집 기능 추가 */ private static async handleUpdateField(config: ButtonActionConfig, context: ButtonActionContext): Promise { try { @@ -3427,7 +3851,7 @@ export class ButtonActionExecutor { const multipleFields = config.updateMultipleFields || []; // 단일 필드 변경이나 다중 필드 변경 중 하나는 있어야 함 - if (!targetField && multipleFields.length === 0) { + if (!targetField && multipleFields.length === 0 && !config.updateWithGeolocation) { toast.error("변경할 필드가 설정되지 않았습니다."); return false; } @@ -3454,6 +3878,69 @@ export class ButtonActionExecutor { updates[field] = value; }); + // 🆕 위치정보 수집 (updateWithGeolocation이 true인 경우) + if (config.updateWithGeolocation) { + const latField = config.updateGeolocationLatField; + const lngField = config.updateGeolocationLngField; + + if (!latField || !lngField) { + toast.error("위도/경도 저장 필드가 설정되지 않았습니다."); + return false; + } + + // 브라우저 Geolocation API 지원 확인 + if (!navigator.geolocation) { + toast.error("이 브라우저는 위치정보를 지원하지 않습니다."); + return false; + } + + // 로딩 토스트 표시 + const loadingToastId = toast.loading("위치 정보를 가져오는 중..."); + + try { + // 위치 정보 가져오기 + const position = await new Promise((resolve, reject) => { + navigator.geolocation.getCurrentPosition(resolve, reject, { + enableHighAccuracy: true, + timeout: 10000, + maximumAge: 0, + }); + }); + + toast.dismiss(loadingToastId); + + const { latitude, longitude, accuracy } = position.coords; + const timestamp = new Date(position.timestamp); + + console.log("📍 위치정보 획득:", { latitude, longitude, accuracy }); + + // 위치정보를 updates에 추가 + updates[latField] = latitude; + updates[lngField] = longitude; + + if (config.updateGeolocationAccuracyField && accuracy !== null) { + updates[config.updateGeolocationAccuracyField] = accuracy; + } + if (config.updateGeolocationTimestampField) { + updates[config.updateGeolocationTimestampField] = timestamp.toISOString(); + } + } catch (geoError: any) { + toast.dismiss(loadingToastId); + + // GeolocationPositionError 처리 + if (geoError.code === 1) { + toast.error("위치 정보 접근이 거부되었습니다."); + } else if (geoError.code === 2) { + toast.error("위치 정보를 사용할 수 없습니다."); + } else if (geoError.code === 3) { + toast.error("위치 정보 요청 시간이 초과되었습니다."); + } else { + toast.error("위치 정보를 가져오는 중 오류가 발생했습니다."); + } + return false; + } + } + console.log("🔄 변경할 필드들:", updates); // formData 업데이트 @@ -3467,6 +3954,67 @@ export class ButtonActionExecutor { const autoSave = config.updateAutoSave !== false; if (autoSave) { + // 🆕 키 필드 설정이 있는 경우 (특수 키워드 지원) - 직접 DB UPDATE + const keyField = config.updateKeyField; + const keySourceField = config.updateKeySourceField; + const targetTableName = config.updateTableName || tableName; + + if (keyField && keySourceField) { + // 특수 키워드 변환 (예: __userId__ → 실제 사용자 ID) + const keyValue = resolveSpecialKeyword(keySourceField, context); + + console.log("🔄 필드 값 변경 - 키 필드 사용:", { + targetTable: targetTableName, + keyField, + keySourceField, + keyValue, + updates, + }); + + if (!keyValue) { + console.warn("⚠️ 키 값이 없어서 업데이트를 건너뜁니다:", { keySourceField, keyValue }); + toast.error("레코드를 식별할 키 값이 없습니다."); + return false; + } + + try { + // 각 필드에 대해 개별 UPDATE 호출 + const { apiClient } = await import("@/lib/api/client"); + + for (const [field, value] of Object.entries(updates)) { + console.log(`🔄 DB UPDATE: ${targetTableName}.${field} = ${value} WHERE ${keyField} = ${keyValue}`); + + const response = await apiClient.put(`/dynamic-form/update-field`, { + tableName: targetTableName, + keyField: keyField, + keyValue: keyValue, + updateField: field, + updateValue: value, + }); + + if (!response.data?.success) { + console.error(`❌ ${field} 업데이트 실패:`, response.data); + toast.error(`${field} 업데이트에 실패했습니다.`); + return false; + } + } + + console.log("✅ 모든 필드 업데이트 성공"); + toast.success(config.successMessage || "상태가 변경되었습니다."); + + // 테이블 새로고침 이벤트 발생 + window.dispatchEvent(new CustomEvent("refreshTableData", { + detail: { tableName: targetTableName } + })); + + return true; + } catch (apiError) { + console.error("❌ 필드 값 변경 API 호출 실패:", apiError); + toast.error(config.errorMessage || "상태 변경 중 오류가 발생했습니다."); + return false; + } + } + // onSave 콜백이 있으면 사용 if (onSave) { console.log("🔄 필드 값 변경 후 자동 저장 (onSave 콜백)"); @@ -3481,7 +4029,7 @@ export class ButtonActionExecutor { } } - // API를 통한 직접 저장 + // API를 통한 직접 저장 (기존 방식: formData에 PK가 있는 경우) if (tableName && formData) { console.log("🔄 필드 값 변경 후 자동 저장 (API 직접 호출)"); try { @@ -3490,7 +4038,7 @@ export class ButtonActionExecutor { const pkValue = formData[pkField] || formData.id; if (!pkValue) { - toast.error("레코드 ID를 찾을 수 없습니다."); + toast.error("레코드 ID를 찾을 수 없습니다. 키 필드를 설정해주세요."); return false; } @@ -3504,11 +4052,13 @@ export class ButtonActionExecutor { if (response.success) { toast.success(config.successMessage || "상태가 변경되었습니다."); - + // 테이블 새로고침 이벤트 발생 - window.dispatchEvent(new CustomEvent("refreshTableData", { - detail: { tableName } - })); + window.dispatchEvent( + new CustomEvent("refreshTableData", { + detail: { tableName }, + }), + ); return true; } else { @@ -3636,6 +4186,11 @@ export const DEFAULT_BUTTON_ACTIONS: Record 0; }" -- } -- } - + -- 멀티테넌시 company_code VARCHAR(20) NOT NULL, - + -- 메타데이터 created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(), created_by VARCHAR(50), - - CONSTRAINT fk_source_screen FOREIGN KEY (source_screen_id) + + CONSTRAINT fk_source_screen FOREIGN KEY (source_screen_id) REFERENCES screen_info(screen_id) ON DELETE CASCADE, - CONSTRAINT fk_target_screen FOREIGN KEY (target_screen_id) + CONSTRAINT fk_target_screen FOREIGN KEY (target_screen_id) REFERENCES screen_info(screen_id) ON DELETE CASCADE ); @@ -246,19 +255,19 @@ CREATE INDEX idx_screen_data_transfer_target ON screen_data_transfer(target_scre ```sql CREATE TABLE screen_split_panel ( id SERIAL PRIMARY KEY, - + -- 부모 화면 (분할 패널 컨테이너) screen_id INTEGER NOT NULL REFERENCES screen_info(screen_id), - + -- 좌측 화면 임베딩 left_embedding_id INTEGER REFERENCES screen_embedding(id), - + -- 우측 화면 임베딩 right_embedding_id INTEGER REFERENCES screen_embedding(id), - + -- 데이터 전달 설정 data_transfer_id INTEGER REFERENCES screen_data_transfer(id), - + -- 레이아웃 설정 layout_config JSONB, -- { @@ -268,21 +277,21 @@ CREATE TABLE screen_split_panel ( -- "minRightWidth": 400, -- "orientation": "horizontal" // 'horizontal' | 'vertical' -- } - + -- 멀티테넌시 company_code VARCHAR(20) NOT NULL, - + -- 메타데이터 created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(), - - CONSTRAINT fk_screen FOREIGN KEY (screen_id) + + CONSTRAINT fk_screen FOREIGN KEY (screen_id) REFERENCES screen_info(screen_id) ON DELETE CASCADE, - CONSTRAINT fk_left_embedding FOREIGN KEY (left_embedding_id) + CONSTRAINT fk_left_embedding FOREIGN KEY (left_embedding_id) REFERENCES screen_embedding(id) ON DELETE SET NULL, - CONSTRAINT fk_right_embedding FOREIGN KEY (right_embedding_id) + CONSTRAINT fk_right_embedding FOREIGN KEY (right_embedding_id) REFERENCES screen_embedding(id) ON DELETE SET NULL, - CONSTRAINT fk_data_transfer FOREIGN KEY (data_transfer_id) + CONSTRAINT fk_data_transfer FOREIGN KEY (data_transfer_id) REFERENCES screen_data_transfer(id) ON DELETE SET NULL ); @@ -298,19 +307,14 @@ CREATE INDEX idx_screen_split_panel_screen ON screen_split_panel(screen_id, comp ```typescript // 임베딩 모드 -type EmbeddingMode = - | "view" // 읽기 전용 - | "select" // 선택 모드 (체크박스) - | "form" // 폼 입력 모드 - | "edit"; // 편집 모드 +type EmbeddingMode = + | "view" // 읽기 전용 + | "select" // 선택 모드 (체크박스) + | "form" // 폼 입력 모드 + | "edit"; // 편집 모드 // 임베딩 위치 -type EmbeddingPosition = - | "left" - | "right" - | "top" - | "bottom" - | "center"; +type EmbeddingPosition = "left" | "right" | "top" | "bottom" | "center"; // 화면 임베딩 설정 interface ScreenEmbedding { @@ -320,8 +324,8 @@ interface ScreenEmbedding { position: EmbeddingPosition; mode: EmbeddingMode; config: { - width?: string; // "50%", "400px" - height?: string; // "100%", "600px" + width?: string; // "50%", "400px" + height?: string; // "100%", "600px" resizable?: boolean; multiSelect?: boolean; showToolbar?: boolean; @@ -336,40 +340,40 @@ interface ScreenEmbedding { ```typescript // 컴포넌트 타입 -type ComponentType = - | "table" // 테이블 - | "input" // 입력 필드 - | "select" // 셀렉트 박스 - | "textarea" // 텍스트 영역 - | "checkbox" // 체크박스 - | "radio" // 라디오 버튼 - | "date" // 날짜 선택 - | "repeater" // 리피터 (반복 그룹) - | "form-group" // 폼 그룹 - | "hidden"; // 히든 필드 +type ComponentType = + | "table" // 테이블 + | "input" // 입력 필드 + | "select" // 셀렉트 박스 + | "textarea" // 텍스트 영역 + | "checkbox" // 체크박스 + | "radio" // 라디오 버튼 + | "date" // 날짜 선택 + | "repeater" // 리피터 (반복 그룹) + | "form-group" // 폼 그룹 + | "hidden"; // 히든 필드 // 데이터 수신 모드 -type DataReceiveMode = - | "append" // 기존 데이터에 추가 - | "replace" // 기존 데이터 덮어쓰기 - | "merge"; // 기존 데이터와 병합 (키 기준) +type DataReceiveMode = + | "append" // 기존 데이터에 추가 + | "replace" // 기존 데이터 덮어쓰기 + | "merge"; // 기존 데이터와 병합 (키 기준) // 변환 함수 -type TransformFunction = - | "none" // 변환 없음 - | "sum" // 합계 - | "average" // 평균 - | "count" // 개수 - | "min" // 최소값 - | "max" // 최대값 - | "first" // 첫 번째 값 - | "last" // 마지막 값 - | "concat" // 문자열 결합 - | "join" // 배열 결합 - | "custom"; // 커스텀 함수 +type TransformFunction = + | "none" // 변환 없음 + | "sum" // 합계 + | "average" // 평균 + | "count" // 개수 + | "min" // 최소값 + | "max" // 최대값 + | "first" // 첫 번째 값 + | "last" // 마지막 값 + | "concat" // 문자열 결합 + | "join" // 배열 결합 + | "custom"; // 커스텀 함수 // 조건 연산자 -type ConditionOperator = +type ConditionOperator = | "equals" | "notEquals" | "contains" @@ -383,12 +387,12 @@ type ConditionOperator = // 매핑 규칙 interface MappingRule { - sourceField: string; // 소스 필드명 - targetField: string; // 타겟 필드명 + sourceField: string; // 소스 필드명 + targetField: string; // 타겟 필드명 transform?: TransformFunction; // 변환 함수 - transformConfig?: any; // 변환 함수 설정 - defaultValue?: any; // 기본값 - required?: boolean; // 필수 여부 + transformConfig?: any; // 변환 함수 설정 + defaultValue?: any; // 기본값 + required?: boolean; // 필수 여부 } // 조건 @@ -400,16 +404,16 @@ interface Condition { // 데이터 수신자 interface DataReceiver { - targetComponentId: string; // 타겟 컴포넌트 ID + targetComponentId: string; // 타겟 컴포넌트 ID targetComponentType: ComponentType; mode: DataReceiveMode; mappingRules: MappingRule[]; - condition?: Condition; // 조건부 전달 + condition?: Condition; // 조건부 전달 validation?: { required?: boolean; minRows?: number; maxRows?: number; - customValidation?: string; // JavaScript 함수 문자열 + customValidation?: string; // JavaScript 함수 문자열 }; } @@ -447,10 +451,10 @@ interface ScreenDataTransfer { ```typescript // 레이아웃 설정 interface LayoutConfig { - splitRatio: number; // 0-100 (좌측 비율) + splitRatio: number; // 0-100 (좌측 비율) resizable: boolean; - minLeftWidth?: number; // 최소 좌측 너비 (px) - minRightWidth?: number; // 최소 우측 너비 (px) + minLeftWidth?: number; // 최소 좌측 너비 (px) + minRightWidth?: number; // 최소 우측 너비 (px) orientation: "horizontal" | "vertical"; } @@ -473,22 +477,22 @@ interface ScreenSplitPanel { interface DataReceivable { // 컴포넌트 ID componentId: string; - + // 컴포넌트 타입 componentType: ComponentType; - + // 데이터 수신 receiveData(data: any[], mode: DataReceiveMode): Promise; - + // 현재 데이터 가져오기 getData(): any; - + // 데이터 초기화 clearData(): void; - + // 검증 validate(): boolean; - + // 이벤트 리스너 onDataReceived?: (data: any[]) => void; onDataCleared?: () => void; @@ -498,13 +502,13 @@ interface DataReceivable { interface Selectable { // 선택된 행/항목 가져오기 getSelectedRows(): any[]; - + // 선택 초기화 clearSelection(): void; - + // 전체 선택 selectAll(): void; - + // 선택 이벤트 onSelectionChanged?: (selectedRows: any[]) => void; } @@ -522,51 +526,62 @@ interface ScreenSplitPanelProps { onDataTransferred?: (data: any[]) => void; } -export function ScreenSplitPanel({ config, onDataTransferred }: ScreenSplitPanelProps) { +export function ScreenSplitPanel({ + config, + onDataTransferred, +}: ScreenSplitPanelProps) { const leftScreenRef = useRef(null); const rightScreenRef = useRef(null); const [splitRatio, setSplitRatio] = useState(config.layoutConfig.splitRatio); - + // 데이터 전달 핸들러 const handleTransferData = async () => { // 1. 좌측 화면에서 선택된 데이터 가져오기 const selectedRows = leftScreenRef.current?.getSelectedRows() || []; - + if (selectedRows.length === 0) { toast.error("선택된 항목이 없습니다."); return; } - + // 2. 검증 if (config.dataTransfer.buttonConfig.validation) { const validation = config.dataTransfer.buttonConfig.validation; - - if (validation.minSelection && selectedRows.length < validation.minSelection) { + + if ( + validation.minSelection && + selectedRows.length < validation.minSelection + ) { toast.error(`최소 ${validation.minSelection}개 이상 선택해야 합니다.`); return; } - - if (validation.maxSelection && selectedRows.length > validation.maxSelection) { - toast.error(`최대 ${validation.maxSelection}개까지만 선택할 수 있습니다.`); + + if ( + validation.maxSelection && + selectedRows.length > validation.maxSelection + ) { + toast.error( + `최대 ${validation.maxSelection}개까지만 선택할 수 있습니다.` + ); return; } - + if (validation.confirmMessage) { const confirmed = await confirm(validation.confirmMessage); if (!confirmed) return; } } - + // 3. 데이터 전달 try { await rightScreenRef.current?.receiveData( selectedRows, config.dataTransfer.dataReceivers ); - + toast.success("데이터가 전달되었습니다."); onDataTransferred?.(selectedRows); - + // 4. 좌측 선택 초기화 (옵션) if (config.dataTransfer.buttonConfig.clearAfterTransfer) { leftScreenRef.current?.clearSelection(); @@ -576,24 +591,19 @@ export function ScreenSplitPanel({ config, onDataTransferred }: ScreenSplitPanel console.error(error); } }; - + return (
{/* 좌측 패널 */}
- +
- + {/* 리사이저 */} {config.layoutConfig.resizable && ( - setSplitRatio(newRatio)} - /> + setSplitRatio(newRatio)} /> )} - + {/* 전달 버튼 */}
- + {/* 우측 패널 */}
( - ({ embedding }, ref) => { - const [screenData, setScreenData] = useState(null); - const [selectedRows, setSelectedRows] = useState([]); - const componentRefs = useRef>(new Map()); - - // 화면 데이터 로드 - useEffect(() => { - loadScreenData(embedding.childScreenId); - }, [embedding.childScreenId]); - - // 외부에서 호출 가능한 메서드 - useImperativeHandle(ref, () => ({ - getSelectedRows: () => selectedRows, - - clearSelection: () => { - setSelectedRows([]); - }, - - receiveData: async (data: any[], receivers: DataReceiver[]) => { - // 각 데이터 수신자에게 데이터 전달 - for (const receiver of receivers) { - const component = componentRefs.current.get(receiver.targetComponentId); - - if (!component) { - console.warn(`컴포넌트를 찾을 수 없습니다: ${receiver.targetComponentId}`); - continue; - } - - // 조건 확인 - let filteredData = data; - if (receiver.condition) { - filteredData = filterData(data, receiver.condition); - } - - // 매핑 적용 - const mappedData = applyMappingRules(filteredData, receiver.mappingRules); - - // 데이터 전달 - await component.receiveData(mappedData, receiver.mode); +export const EmbeddedScreen = forwardRef< + EmbeddedScreenHandle, + EmbeddedScreenProps +>(({ embedding }, ref) => { + const [screenData, setScreenData] = useState(null); + const [selectedRows, setSelectedRows] = useState([]); + const componentRefs = useRef>(new Map()); + + // 화면 데이터 로드 + useEffect(() => { + loadScreenData(embedding.childScreenId); + }, [embedding.childScreenId]); + + // 외부에서 호출 가능한 메서드 + useImperativeHandle(ref, () => ({ + getSelectedRows: () => selectedRows, + + clearSelection: () => { + setSelectedRows([]); + }, + + receiveData: async (data: any[], receivers: DataReceiver[]) => { + // 각 데이터 수신자에게 데이터 전달 + for (const receiver of receivers) { + const component = componentRefs.current.get(receiver.targetComponentId); + + if (!component) { + console.warn( + `컴포넌트를 찾을 수 없습니다: ${receiver.targetComponentId}` + ); + continue; } - }, - - getData: () => { - const allData: Record = {}; - componentRefs.current.forEach((component, id) => { - allData[id] = component.getData(); - }); - return allData; + + // 조건 확인 + let filteredData = data; + if (receiver.condition) { + filteredData = filterData(data, receiver.condition); + } + + // 매핑 적용 + const mappedData = applyMappingRules( + filteredData, + receiver.mappingRules + ); + + // 데이터 전달 + await component.receiveData(mappedData, receiver.mode); } - })); - - // 컴포넌트 등록 - const registerComponent = (id: string, component: DataReceivable) => { - componentRefs.current.set(id, component); - }; - - return ( -
- {screenData && ( - - )} -
- ); - } -); + }, + + getData: () => { + const allData: Record = {}; + componentRefs.current.forEach((component, id) => { + allData[id] = component.getData(); + }); + return allData; + }, + })); + + // 컴포넌트 등록 + const registerComponent = (id: string, component: DataReceivable) => { + componentRefs.current.set(id, component); + }; + + return ( +
+ {screenData && ( + + )} +
+ ); +}); ``` ### 3. DataReceivable 구현 예시 @@ -716,7 +735,7 @@ class TableComponent implements DataReceivable { componentId: string; componentType: ComponentType = "table"; private rows: any[] = []; - + async receiveData(data: any[], mode: DataReceiveMode): Promise { switch (mode) { case "append": @@ -727,30 +746,30 @@ class TableComponent implements DataReceivable { break; case "merge": // 키 기반 병합 (예: id 필드) - const existingIds = new Set(this.rows.map(r => r.id)); - const newRows = data.filter(r => !existingIds.has(r.id)); + const existingIds = new Set(this.rows.map((r) => r.id)); + const newRows = data.filter((r) => !existingIds.has(r.id)); this.rows = [...this.rows, ...newRows]; break; } - + this.render(); this.onDataReceived?.(data); } - + getData(): any { return this.rows; } - + clearData(): void { this.rows = []; this.render(); this.onDataCleared?.(); } - + validate(): boolean { return this.rows.length > 0; } - + private render() { // 테이블 리렌더링 } @@ -764,7 +783,7 @@ class InputComponent implements DataReceivable { componentId: string; componentType: ComponentType = "input"; private value: any = ""; - + async receiveData(data: any[], mode: DataReceiveMode): Promise { // 입력 필드는 단일 값이므로 첫 번째 항목만 사용 if (data.length > 0) { @@ -773,21 +792,21 @@ class InputComponent implements DataReceivable { this.onDataReceived?.(data); } } - + getData(): any { return this.value; } - + clearData(): void { this.value = ""; this.render(); this.onDataCleared?.(); } - + validate(): boolean { return this.value !== null && this.value !== undefined && this.value !== ""; } - + private render() { // 입력 필드 리렌더링 } @@ -812,7 +831,7 @@ export async function getScreenEmbeddings( AND company_code = $2 ORDER BY position `; - + const result = await pool.query(query, [parentScreenId, companyCode]); return { success: true, data: result.rows }; } @@ -828,16 +847,16 @@ export async function createScreenEmbedding( ) VALUES ($1, $2, $3, $4, $5, $6) RETURNING * `; - + const result = await pool.query(query, [ embedding.parentScreenId, embedding.childScreenId, embedding.position, embedding.mode, JSON.stringify(embedding.config), - companyCode + companyCode, ]); - + return { success: true, data: result.rows[0] }; } @@ -850,26 +869,26 @@ export async function updateScreenEmbedding( const updates: string[] = []; const values: any[] = []; let paramIndex = 1; - + if (embedding.position) { updates.push(`position = $${paramIndex++}`); values.push(embedding.position); } - + if (embedding.mode) { updates.push(`mode = $${paramIndex++}`); values.push(embedding.mode); } - + if (embedding.config) { updates.push(`config = $${paramIndex++}`); values.push(JSON.stringify(embedding.config)); } - + updates.push(`updated_at = NOW()`); - + values.push(id, companyCode); - + const query = ` UPDATE screen_embedding SET ${updates.join(", ")} @@ -877,13 +896,13 @@ export async function updateScreenEmbedding( AND company_code = $${paramIndex++} RETURNING * `; - + const result = await pool.query(query, values); - + if (result.rowCount === 0) { return { success: false, message: "임베딩 설정을 찾을 수 없습니다." }; } - + return { success: true, data: result.rows[0] }; } @@ -896,13 +915,13 @@ export async function deleteScreenEmbedding( DELETE FROM screen_embedding WHERE id = $1 AND company_code = $2 `; - + const result = await pool.query(query, [id, companyCode]); - + if (result.rowCount === 0) { return { success: false, message: "임베딩 설정을 찾을 수 없습니다." }; } - + return { success: true }; } ``` @@ -922,13 +941,17 @@ export async function getScreenDataTransfer( AND target_screen_id = $2 AND company_code = $3 `; - - const result = await pool.query(query, [sourceScreenId, targetScreenId, companyCode]); - + + const result = await pool.query(query, [ + sourceScreenId, + targetScreenId, + companyCode, + ]); + if (result.rowCount === 0) { return { success: false, message: "데이터 전달 설정을 찾을 수 없습니다." }; } - + return { success: true, data: result.rows[0] }; } @@ -944,7 +967,7 @@ export async function createScreenDataTransfer( ) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING * `; - + const result = await pool.query(query, [ transfer.sourceScreenId, transfer.targetScreenId, @@ -952,9 +975,9 @@ export async function createScreenDataTransfer( transfer.sourceComponentType, JSON.stringify(transfer.dataReceivers), JSON.stringify(transfer.buttonConfig), - companyCode + companyCode, ]); - + return { success: true, data: result.rows[0] }; } @@ -967,21 +990,21 @@ export async function updateScreenDataTransfer( const updates: string[] = []; const values: any[] = []; let paramIndex = 1; - + if (transfer.dataReceivers) { updates.push(`data_receivers = $${paramIndex++}`); values.push(JSON.stringify(transfer.dataReceivers)); } - + if (transfer.buttonConfig) { updates.push(`button_config = $${paramIndex++}`); values.push(JSON.stringify(transfer.buttonConfig)); } - + updates.push(`updated_at = NOW()`); - + values.push(id, companyCode); - + const query = ` UPDATE screen_data_transfer SET ${updates.join(", ")} @@ -989,13 +1012,13 @@ export async function updateScreenDataTransfer( AND company_code = $${paramIndex++} RETURNING * `; - + const result = await pool.query(query, values); - + if (result.rowCount === 0) { return { success: false, message: "데이터 전달 설정을 찾을 수 없습니다." }; } - + return { success: true, data: result.rows[0] }; } ``` @@ -1021,13 +1044,13 @@ export async function getScreenSplitPanel( WHERE ssp.screen_id = $1 AND ssp.company_code = $2 `; - + const result = await pool.query(query, [screenId, companyCode]); - + if (result.rowCount === 0) { return { success: false, message: "분할 패널 설정을 찾을 수 없습니다." }; } - + return { success: true, data: result.rows[0] }; } @@ -1037,19 +1060,28 @@ export async function createScreenSplitPanel( companyCode: string ): Promise> { const client = await pool.connect(); - + try { await client.query("BEGIN"); - + // 1. 좌측 임베딩 생성 - const leftEmbedding = await createScreenEmbedding(panel.leftEmbedding, companyCode); - + const leftEmbedding = await createScreenEmbedding( + panel.leftEmbedding, + companyCode + ); + // 2. 우측 임베딩 생성 - const rightEmbedding = await createScreenEmbedding(panel.rightEmbedding, companyCode); - + const rightEmbedding = await createScreenEmbedding( + panel.rightEmbedding, + companyCode + ); + // 3. 데이터 전달 설정 생성 - const dataTransfer = await createScreenDataTransfer(panel.dataTransfer, companyCode); - + const dataTransfer = await createScreenDataTransfer( + panel.dataTransfer, + companyCode + ); + // 4. 분할 패널 생성 const query = ` INSERT INTO screen_split_panel ( @@ -1058,18 +1090,18 @@ export async function createScreenSplitPanel( ) VALUES ($1, $2, $3, $4, $5, $6) RETURNING * `; - + const result = await client.query(query, [ panel.screenId, leftEmbedding.data!.id, rightEmbedding.data!.id, dataTransfer.data!.id, JSON.stringify(panel.layoutConfig), - companyCode + companyCode, ]); - + await client.query("COMMIT"); - + return { success: true, data: result.rows[0] }; } catch (error) { await client.query("ROLLBACK"); @@ -1087,6 +1119,7 @@ export async function createScreenSplitPanel( ### Phase 1: 기본 인프라 구축 (1-2주) #### 1.1 데이터베이스 마이그레이션 + - [ ] `screen_embedding` 테이블 생성 - [ ] `screen_data_transfer` 테이블 생성 - [ ] `screen_split_panel` 테이블 생성 @@ -1094,12 +1127,14 @@ export async function createScreenSplitPanel( - [ ] 샘플 데이터 삽입 #### 1.2 타입 정의 + - [ ] TypeScript 인터페이스 작성 - [ ] `types/screen-embedding.ts` - [ ] `types/data-transfer.ts` - [ ] `types/split-panel.ts` #### 1.3 백엔드 API + - [ ] 화면 임베딩 CRUD API - [ ] 데이터 전달 설정 CRUD API - [ ] 분할 패널 CRUD API @@ -1108,12 +1143,14 @@ export async function createScreenSplitPanel( ### Phase 2: 화면 임베딩 기능 (2-3주) #### 2.1 EmbeddedScreen 컴포넌트 + - [ ] 기본 임베딩 기능 - [ ] 모드별 렌더링 (view, select, form, edit) - [ ] 선택 모드 구현 (체크박스) - [ ] 이벤트 핸들링 #### 2.2 DataReceivable 인터페이스 구현 + - [ ] TableComponent - [ ] InputComponent - [ ] SelectComponent @@ -1123,6 +1160,7 @@ export async function createScreenSplitPanel( - [ ] HiddenComponent #### 2.3 컴포넌트 등록 시스템 + - [ ] 컴포넌트 마운트 시 자동 등록 - [ ] 컴포넌트 ID 관리 - [ ] 컴포넌트 참조 관리 @@ -1130,6 +1168,7 @@ export async function createScreenSplitPanel( ### Phase 3: 데이터 전달 시스템 (2-3주) #### 3.1 매핑 엔진 + - [ ] 매핑 규칙 파싱 - [ ] 필드 매핑 적용 - [ ] 변환 함수 구현 @@ -1139,11 +1178,13 @@ export async function createScreenSplitPanel( - [ ] concat, join #### 3.2 조건부 전달 + - [ ] 조건 파싱 - [ ] 필터링 로직 - [ ] 복합 조건 지원 #### 3.3 검증 시스템 + - [ ] 필수 필드 검증 - [ ] 최소/최대 행 수 검증 - [ ] 커스텀 검증 함수 실행 @@ -1151,18 +1192,21 @@ export async function createScreenSplitPanel( ### Phase 4: 분할 패널 UI (2-3주) #### 4.1 ScreenSplitPanel 컴포넌트 + - [ ] 기본 레이아웃 - [ ] 리사이저 구현 - [ ] 전달 버튼 - [ ] 반응형 디자인 #### 4.2 설정 UI + - [ ] 화면 선택 드롭다운 - [ ] 매핑 규칙 설정 UI - [ ] 드래그앤드롭 매핑 - [ ] 미리보기 기능 #### 4.3 시각적 피드백 + - [ ] 데이터 전달 애니메이션 - [ ] 로딩 상태 표시 - [ ] 성공/실패 토스트 @@ -1170,14 +1214,17 @@ export async function createScreenSplitPanel( ### Phase 5: 고급 기능 (2-3주) #### 5.1 양방향 동기화 + - [ ] 우측 → 좌측 데이터 반영 - [ ] 실시간 업데이트 #### 5.2 트랜잭션 지원 + - [ ] 전체 성공 또는 전체 실패 - [ ] 롤백 기능 #### 5.3 성능 최적화 + - [ ] 대량 데이터 처리 - [ ] 가상 스크롤링 - [ ] 메모이제이션 @@ -1185,15 +1232,18 @@ export async function createScreenSplitPanel( ### Phase 6: 테스트 및 문서화 (1-2주) #### 6.1 단위 테스트 + - [ ] 매핑 엔진 테스트 - [ ] 변환 함수 테스트 - [ ] 검증 로직 테스트 #### 6.2 통합 테스트 + - [ ] 전체 워크플로우 테스트 - [ ] 실제 시나리오 테스트 #### 6.3 문서화 + - [ ] 사용자 가이드 - [ ] 개발자 문서 - [ ] API 문서 @@ -1205,6 +1255,7 @@ export async function createScreenSplitPanel( ### 시나리오 1: 입고 등록 #### 요구사항 + - 발주 목록에서 품목을 선택하여 입고 등록 - 선택된 품목의 정보를 입고 처리 품목 테이블에 추가 - 공급자 정보를 자동으로 입력 필드에 설정 @@ -1216,23 +1267,23 @@ export async function createScreenSplitPanel( const 입고등록_설정: ScreenSplitPanel = { screenId: 100, leftEmbedding: { - childScreenId: 10, // 발주 목록 조회 화면 + childScreenId: 10, // 발주 목록 조회 화면 position: "left", mode: "select", config: { width: "50%", multiSelect: true, showSearch: true, - showPagination: true - } + showPagination: true, + }, }, rightEmbedding: { - childScreenId: 20, // 입고 등록 폼 화면 + childScreenId: 20, // 입고 등록 폼 화면 position: "right", mode: "form", config: { - width: "50%" - } + width: "50%", + }, }, dataTransfer: { sourceScreenId: 10, @@ -1248,33 +1299,33 @@ const 입고등록_설정: ScreenSplitPanel = { { sourceField: "품목코드", targetField: "품목코드" }, { sourceField: "품목명", targetField: "품목명" }, { sourceField: "발주수량", targetField: "발주수량" }, - { sourceField: "미입고수량", targetField: "입고수량" } - ] + { sourceField: "미입고수량", targetField: "입고수량" }, + ], }, { targetComponentId: "input-공급자", targetComponentType: "input", mode: "replace", mappingRules: [ - { - sourceField: "공급자", + { + sourceField: "공급자", targetField: "value", - transform: "first" - } - ] + transform: "first", + }, + ], }, { targetComponentId: "input-품목수", targetComponentType: "input", mode: "replace", mappingRules: [ - { - sourceField: "품목코드", + { + sourceField: "품목코드", targetField: "value", - transform: "count" - } - ] - } + transform: "count", + }, + ], + }, ], buttonConfig: { label: "선택 품목 추가", @@ -1282,23 +1333,24 @@ const 입고등록_설정: ScreenSplitPanel = { icon: "ArrowRight", validation: { requireSelection: true, - minSelection: 1 - } - } + minSelection: 1, + }, + }, }, layoutConfig: { splitRatio: 50, resizable: true, minLeftWidth: 400, minRightWidth: 600, - orientation: "horizontal" - } + orientation: "horizontal", + }, }; ``` ### 시나리오 2: 수주 등록 #### 요구사항 + - 견적서 목록에서 품목을 선택하여 수주 등록 - 고객 정보를 자동으로 폼에 설정 - 품목별 수량 및 금액 자동 계산 @@ -1310,21 +1362,21 @@ const 입고등록_설정: ScreenSplitPanel = { const 수주등록_설정: ScreenSplitPanel = { screenId: 101, leftEmbedding: { - childScreenId: 30, // 견적서 목록 조회 화면 + childScreenId: 30, // 견적서 목록 조회 화면 position: "left", mode: "select", config: { width: "40%", - multiSelect: true - } + multiSelect: true, + }, }, rightEmbedding: { - childScreenId: 40, // 수주 등록 폼 화면 + childScreenId: 40, // 수주 등록 폼 화면 position: "right", mode: "form", config: { - width: "60%" - } + width: "60%", + }, }, dataTransfer: { sourceScreenId: 30, @@ -1339,54 +1391,55 @@ const 수주등록_설정: ScreenSplitPanel = { { sourceField: "품목명", targetField: "품목명" }, { sourceField: "수량", targetField: "수량" }, { sourceField: "단가", targetField: "단가" }, - { - sourceField: "수량", + { + sourceField: "수량", targetField: "금액", transform: "custom", transformConfig: { - formula: "수량 * 단가" - } - } - ] + formula: "수량 * 단가", + }, + }, + ], }, { targetComponentId: "input-고객명", targetComponentType: "input", mode: "replace", mappingRules: [ - { sourceField: "고객명", targetField: "value", transform: "first" } - ] + { sourceField: "고객명", targetField: "value", transform: "first" }, + ], }, { targetComponentId: "input-총금액", targetComponentType: "input", mode: "replace", mappingRules: [ - { - sourceField: "금액", + { + sourceField: "금액", targetField: "value", - transform: "sum" - } - ] - } + transform: "sum", + }, + ], + }, ], buttonConfig: { label: "견적서 불러오기", position: "center", - icon: "Download" - } + icon: "Download", + }, }, layoutConfig: { splitRatio: 40, resizable: true, - orientation: "horizontal" - } + orientation: "horizontal", + }, }; ``` ### 시나리오 3: 출고 등록 #### 요구사항 + - 재고 목록에서 품목을 선택하여 출고 등록 - 재고 수량 확인 및 경고 - 출고 가능 수량만 필터링 @@ -1398,21 +1451,21 @@ const 수주등록_설정: ScreenSplitPanel = { const 출고등록_설정: ScreenSplitPanel = { screenId: 102, leftEmbedding: { - childScreenId: 50, // 재고 목록 조회 화면 + childScreenId: 50, // 재고 목록 조회 화면 position: "left", mode: "select", config: { width: "45%", - multiSelect: true - } + multiSelect: true, + }, }, rightEmbedding: { - childScreenId: 60, // 출고 등록 폼 화면 + childScreenId: 60, // 출고 등록 폼 화면 position: "right", mode: "form", config: { - width: "55%" - } + width: "55%", + }, }, dataTransfer: { sourceScreenId: 50, @@ -1426,26 +1479,26 @@ const 출고등록_설정: ScreenSplitPanel = { { sourceField: "품목코드", targetField: "품목코드" }, { sourceField: "품목명", targetField: "품목명" }, { sourceField: "재고수량", targetField: "가용수량" }, - { sourceField: "창고", targetField: "출고창고" } + { sourceField: "창고", targetField: "출고창고" }, ], condition: { field: "재고수량", operator: "greaterThan", - value: 0 - } + value: 0, + }, }, { targetComponentId: "input-총출고수량", targetComponentType: "input", mode: "replace", mappingRules: [ - { - sourceField: "재고수량", + { + sourceField: "재고수량", targetField: "value", - transform: "sum" - } - ] - } + transform: "sum", + }, + ], + }, ], buttonConfig: { label: "출고 품목 추가", @@ -1453,15 +1506,15 @@ const 출고등록_설정: ScreenSplitPanel = { icon: "ArrowRight", validation: { requireSelection: true, - confirmMessage: "선택한 품목을 출고 처리하시겠습니까?" - } - } + confirmMessage: "선택한 품목을 출고 처리하시겠습니까?", + }, + }, }, layoutConfig: { splitRatio: 45, resizable: true, - orientation: "horizontal" - } + orientation: "horizontal", + }, }; ``` @@ -1472,11 +1525,13 @@ const 출고등록_설정: ScreenSplitPanel = { ### 1. 성능 최적화 #### 대량 데이터 처리 + - 가상 스크롤링 적용 - 청크 단위 데이터 전달 - 백그라운드 처리 #### 메모리 관리 + - 컴포넌트 언마운트 시 참조 해제 - 이벤트 리스너 정리 - 메모이제이션 활용 @@ -1484,11 +1539,13 @@ const 출고등록_설정: ScreenSplitPanel = { ### 2. 보안 #### 권한 검증 + - 화면 접근 권한 확인 - 데이터 전달 권한 확인 - 멀티테넌시 격리 #### 데이터 검증 + - 입력값 검증 - SQL 인젝션 방지 - XSS 방지 @@ -1496,22 +1553,26 @@ const 출고등록_설정: ScreenSplitPanel = { ### 3. 에러 처리 #### 사용자 친화적 메시지 + - 명확한 오류 메시지 - 복구 방법 안내 - 로그 기록 #### 트랜잭션 롤백 + - 부분 실패 시 전체 롤백 - 데이터 일관성 유지 ### 4. 확장성 #### 플러그인 시스템 + - 커스텀 변환 함수 등록 - 커스텀 검증 함수 등록 - 커스텀 컴포넌트 타입 추가 #### 이벤트 시스템 + - 데이터 전달 전/후 이벤트 - 커스텀 이벤트 핸들러 @@ -1520,31 +1581,37 @@ const 출고등록_설정: ScreenSplitPanel = { ## 마일스톤 ### M1: 기본 인프라 (2주) + - 데이터베이스 스키마 완성 - 백엔드 API 완성 - 타입 정의 완성 ### M2: 화면 임베딩 (3주) + - EmbeddedScreen 컴포넌트 완성 - DataReceivable 인터페이스 구현 완료 - 선택 모드 동작 확인 ### M3: 데이터 전달 (3주) + - 매핑 엔진 완성 - 변환 함수 구현 완료 - 조건부 전달 동작 확인 ### M4: 분할 패널 UI (3주) + - ScreenSplitPanel 컴포넌트 완성 - 설정 UI 완성 - 입고 등록 시나리오 완성 ### M5: 고급 기능 및 최적화 (3주) + - 양방향 동기화 완성 - 성능 최적화 완료 - 전체 테스트 통과 ### M6: 문서화 및 배포 (1주) + - 사용자 가이드 작성 - 개발자 문서 작성 - 프로덕션 배포 @@ -1567,6 +1634,7 @@ const 출고등록_설정: ScreenSplitPanel = { ## 성공 지표 ### 기능적 지표 + - [ ] 입고 등록 시나리오 완벽 동작 - [ ] 수주 등록 시나리오 완벽 동작 - [ ] 출고 등록 시나리오 완벽 동작 @@ -1574,11 +1642,13 @@ const 출고등록_설정: ScreenSplitPanel = { - [ ] 모든 변환 함수 정상 동작 ### 성능 지표 + - [ ] 1000개 행 데이터 전달 < 1초 - [ ] 화면 로딩 시간 < 2초 - [ ] 메모리 사용량 < 100MB ### 사용성 지표 + - [ ] 설정 UI 직관적 - [ ] 에러 메시지 명확 - [ ] 문서 완성도 90% 이상 @@ -1588,15 +1658,18 @@ const 출고등록_설정: ScreenSplitPanel = { ## 리스크 관리 ### 기술적 리스크 + - **복잡도 증가**: 단계별 구현으로 관리 - **성능 문제**: 초기부터 최적화 고려 - **호환성 문제**: 기존 시스템과 충돌 방지 ### 일정 리스크 + - **예상 기간 초과**: 버퍼 2주 확보 - **우선순위 변경**: 핵심 기능 먼저 구현 ### 인력 리스크 + - **담당자 부재**: 문서화 철저히 - **지식 공유**: 주간 리뷰 미팅 @@ -1605,4 +1678,3 @@ const 출고등록_설정: ScreenSplitPanel = { ## 결론 화면 임베딩 및 데이터 전달 시스템은 복잡한 업무 워크플로우를 효율적으로 처리할 수 있는 강력한 기능입니다. 단계별로 체계적으로 구현하면 약 3.5개월 내에 완성할 수 있으며, 이를 통해 사용자 경험을 크게 향상시킬 수 있습니다. - diff --git a/화면_임베딩_시스템_Phase1-4_구현_완료.md b/화면_임베딩_시스템_Phase1-4_구현_완료.md index cf4879c0..c1880ef7 100644 --- a/화면_임베딩_시스템_Phase1-4_구현_완료.md +++ b/화면_임베딩_시스템_Phase1-4_구현_완료.md @@ -21,12 +21,14 @@ **생성된 테이블**: 1. **screen_embedding** (화면 임베딩 설정) + - 한 화면을 다른 화면 안에 임베드 - 위치 (left, right, top, bottom, center) - 모드 (view, select, form, edit) - 설정 (width, height, multiSelect 등) 2. **screen_data_transfer** (데이터 전달 설정) + - 소스 화면 → 타겟 화면 데이터 전달 - 데이터 수신자 배열 (JSONB) - 매핑 규칙, 조건, 검증 @@ -38,6 +40,7 @@ - 레이아웃 설정 (splitRatio, resizable 등) **샘플 데이터**: + - 입고 등록 시나리오 샘플 데이터 포함 - 발주 목록 → 입고 처리 품목 매핑 예시 @@ -46,6 +49,7 @@ **파일**: `frontend/types/screen-embedding.ts` **주요 타입**: + ```typescript // 화면 임베딩 - EmbeddingMode: "view" | "select" | "form" | "edit" @@ -67,13 +71,15 @@ #### 1.3 백엔드 API -**파일**: +**파일**: + - `backend-node/src/controllers/screenEmbeddingController.ts` - `backend-node/src/routes/screenEmbeddingRoutes.ts` **API 엔드포인트**: **화면 임베딩**: + - `GET /api/screen-embedding?parentScreenId=1` - 목록 조회 - `GET /api/screen-embedding/:id` - 상세 조회 - `POST /api/screen-embedding` - 생성 @@ -81,18 +87,21 @@ - `DELETE /api/screen-embedding/:id` - 삭제 **데이터 전달**: + - `GET /api/screen-data-transfer?sourceScreenId=1&targetScreenId=2` - 조회 - `POST /api/screen-data-transfer` - 생성 - `PUT /api/screen-data-transfer/:id` - 수정 - `DELETE /api/screen-data-transfer/:id` - 삭제 **분할 패널**: + - `GET /api/screen-split-panel/:screenId` - 조회 - `POST /api/screen-split-panel` - 생성 (트랜잭션) - `PUT /api/screen-split-panel/:id` - 수정 - `DELETE /api/screen-split-panel/:id` - 삭제 (CASCADE) **특징**: + - ✅ 멀티테넌시 지원 (company_code 필터링) - ✅ 트랜잭션 처리 (분할 패널 생성/삭제) - ✅ 외래키 CASCADE 처리 @@ -103,25 +112,24 @@ **파일**: `frontend/lib/api/screenEmbedding.ts` **함수**: + ```typescript // 화면 임베딩 -- getScreenEmbeddings(parentScreenId) -- getScreenEmbeddingById(id) -- createScreenEmbedding(data) -- updateScreenEmbedding(id, data) -- deleteScreenEmbedding(id) - -// 데이터 전달 -- getScreenDataTransfer(sourceScreenId, targetScreenId) -- createScreenDataTransfer(data) -- updateScreenDataTransfer(id, data) -- deleteScreenDataTransfer(id) - -// 분할 패널 -- getScreenSplitPanel(screenId) -- createScreenSplitPanel(data) -- updateScreenSplitPanel(id, layoutConfig) -- deleteScreenSplitPanel(id) +-getScreenEmbeddings(parentScreenId) - + getScreenEmbeddingById(id) - + createScreenEmbedding(data) - + updateScreenEmbedding(id, data) - + deleteScreenEmbedding(id) - + // 데이터 전달 + getScreenDataTransfer(sourceScreenId, targetScreenId) - + createScreenDataTransfer(data) - + updateScreenDataTransfer(id, data) - + deleteScreenDataTransfer(id) - + // 분할 패널 + getScreenSplitPanel(screenId) - + createScreenSplitPanel(data) - + updateScreenSplitPanel(id, layoutConfig) - + deleteScreenSplitPanel(id); ``` --- @@ -133,6 +141,7 @@ **파일**: `frontend/components/screen-embedding/EmbeddedScreen.tsx` **주요 기능**: + - ✅ 화면 데이터 로드 - ✅ 모드별 렌더링 (view, select, form, edit) - ✅ 선택 모드 지원 (체크박스) @@ -141,6 +150,7 @@ - ✅ 로딩/에러 상태 UI **외부 인터페이스** (useImperativeHandle): + ```typescript - getSelectedRows(): any[] - clearSelection(): void @@ -149,6 +159,7 @@ ``` **데이터 수신 프로세스**: + 1. 조건 필터링 (condition) 2. 매핑 규칙 적용 (mappingRules) 3. 검증 (validation) @@ -165,10 +176,12 @@ **주요 함수**: 1. **applyMappingRules(data, rules)** + - 일반 매핑: 각 행에 대해 필드 매핑 - 변환 매핑: 집계 함수 적용 2. **변환 함수 지원**: + - `sum`: 합계 - `average`: 평균 - `count`: 개수 @@ -177,15 +190,18 @@ - `concat`, `join`: 문자열 결합 3. **filterDataByCondition(data, condition)** + - 조건 연산자: equals, notEquals, contains, greaterThan, lessThan, in, notIn 4. **validateMappingResult(data, rules)** + - 필수 필드 검증 5. **previewMapping(sampleData, rules)** - 매핑 결과 미리보기 **특징**: + - ✅ 중첩 객체 지원 (`user.address.city`) - ✅ 타입 안전성 - ✅ 에러 처리 @@ -195,6 +211,7 @@ **파일**: `frontend/lib/utils/logger.ts` **기능**: + - debug, info, warn, error 레벨 - 개발 환경에서만 debug 출력 - 타임스탬프 포함 @@ -208,6 +225,7 @@ **파일**: `frontend/components/screen-embedding/ScreenSplitPanel.tsx` **주요 기능**: + - ✅ 좌우 화면 임베딩 - ✅ 리사이저 (드래그로 비율 조정) - ✅ 데이터 전달 버튼 @@ -218,6 +236,7 @@ - ✅ 전달 후 선택 초기화 (옵션) **UI 구조**: + ``` ┌─────────────────────────────────────────────────────────┐ │ [좌측 패널 50%] │ [버튼] │ [우측 패널 50%] │ @@ -230,6 +249,7 @@ ``` **이벤트 흐름**: + 1. 좌측에서 행 선택 → 선택 카운트 업데이트 2. 전달 버튼 클릭 → 검증 3. 우측 화면의 컴포넌트들에 데이터 전달 @@ -281,7 +301,7 @@ ERP-node/ const inboundConfig: ScreenSplitPanel = { screenId: 100, leftEmbedding: { - childScreenId: 10, // 발주 목록 조회 + childScreenId: 10, // 발주 목록 조회 position: "left", mode: "select", config: { @@ -290,7 +310,7 @@ const inboundConfig: ScreenSplitPanel = { }, }, rightEmbedding: { - childScreenId: 20, // 입고 등록 폼 + childScreenId: 20, // 입고 등록 폼 position: "right", mode: "form", config: { @@ -352,7 +372,7 @@ const inboundConfig: ScreenSplitPanel = { onDataTransferred={(data) => { console.log("전달된 데이터:", data); }} -/> +/>; ``` --- @@ -395,6 +415,7 @@ const inboundConfig: ScreenSplitPanel = { ### Phase 5: 고급 기능 (예정) 1. **DataReceivable 인터페이스 구현** + - TableComponent - InputComponent - SelectComponent @@ -402,6 +423,7 @@ const inboundConfig: ScreenSplitPanel = { - 기타 컴포넌트들 2. **양방향 동기화** + - 우측 → 좌측 데이터 반영 - 실시간 업데이트 @@ -412,6 +434,7 @@ const inboundConfig: ScreenSplitPanel = { ### Phase 6: 설정 UI (예정) 1. **시각적 매핑 설정 UI** + - 드래그앤드롭으로 필드 매핑 - 변환 함수 선택 - 조건 설정 @@ -463,7 +486,7 @@ import { getScreenSplitPanel } from "@/lib/api/screenEmbedding"; const { data: config } = await getScreenSplitPanel(screenId); // 렌더링 - +; ``` --- @@ -471,6 +494,7 @@ const { data: config } = await getScreenSplitPanel(screenId); ## ✅ 체크리스트 ### 구현 완료 + - [x] 데이터베이스 스키마 (3개 테이블) - [x] TypeScript 타입 정의 - [x] 백엔드 API (15개 엔드포인트) @@ -481,6 +505,7 @@ const { data: config } = await getScreenSplitPanel(screenId); - [x] 로거 유틸리티 ### 다음 단계 + - [ ] DataReceivable 구현 (각 컴포넌트 타입별) - [ ] 설정 UI (드래그앤드롭 매핑) - [ ] 미리보기 기능 @@ -500,4 +525,3 @@ const { data: config } = await getScreenSplitPanel(screenId); - ✅ 매핑 엔진 완성 이제 입고 등록과 같은 복잡한 워크플로우를 구현할 수 있습니다. 다음 단계는 각 컴포넌트 타입별 DataReceivable 인터페이스 구현과 설정 UI 개발입니다. - diff --git a/화면_임베딩_시스템_충돌_분석_보고서.md b/화면_임베딩_시스템_충돌_분석_보고서.md index 00e16b8e..6cebf31e 100644 --- a/화면_임베딩_시스템_충돌_분석_보고서.md +++ b/화면_임베딩_시스템_충돌_분석_보고서.md @@ -11,6 +11,7 @@ ### 1. 데이터베이스 스키마 #### 새로운 테이블 (독립적) + ```sql - screen_embedding (신규) - screen_data_transfer (신규) @@ -18,11 +19,13 @@ ``` **충돌 없는 이유**: + - ✅ 완전히 새로운 테이블명 - ✅ 기존 테이블과 이름 중복 없음 - ✅ 외래키는 기존 `screen_definitions`만 참조 (읽기 전용) #### 기존 테이블 (영향 없음) + ```sql - screen_definitions (변경 없음) - screen_layouts (변경 없음) @@ -32,6 +35,7 @@ ``` **확인 사항**: + - ✅ 기존 테이블 구조 변경 없음 - ✅ 기존 데이터 마이그레이션 불필요 - ✅ 기존 쿼리 영향 없음 @@ -41,6 +45,7 @@ ### 2. API 엔드포인트 #### 새로운 엔드포인트 (독립적) + ``` POST /api/screen-embedding GET /api/screen-embedding @@ -59,11 +64,13 @@ DELETE /api/screen-split-panel/:id ``` **충돌 없는 이유**: + - ✅ 기존 `/api/screen-management/*` 와 다른 경로 - ✅ 새로운 라우트 추가만 (기존 라우트 수정 없음) - ✅ 독립적인 컨트롤러 파일 #### 기존 엔드포인트 (영향 없음) + ``` /api/screen-management/* (변경 없음) /api/screen/* (변경 없음) @@ -75,16 +82,19 @@ DELETE /api/screen-split-panel/:id ### 3. TypeScript 타입 #### 새로운 타입 파일 (독립적) + ```typescript -frontend/types/screen-embedding.ts (신규) +frontend / types / screen - embedding.ts(신규); ``` **충돌 없는 이유**: + - ✅ 기존 `screen.ts`, `screen-management.ts` 와 별도 파일 - ✅ 타입명 중복 없음 - ✅ 독립적인 네임스페이스 #### 기존 타입 (영향 없음) + ```typescript frontend/types/screen.ts (변경 없음) frontend/types/screen-management.ts (변경 없음) @@ -96,6 +106,7 @@ backend-node/src/types/screen.ts (변경 없음) ### 4. 프론트엔드 컴포넌트 #### 새로운 컴포넌트 (독립적) + ``` frontend/components/screen-embedding/ ├── EmbeddedScreen.tsx (신규) @@ -104,11 +115,13 @@ frontend/components/screen-embedding/ ``` **충돌 없는 이유**: + - ✅ 별도 디렉토리 (`screen-embedding/`) - ✅ 기존 컴포넌트 수정 없음 - ✅ 독립적으로 import 가능 #### 기존 컴포넌트 (영향 없음) + ``` frontend/components/screen/ (변경 없음) frontend/app/(main)/screens/[screenId]/page.tsx (변경 없음) @@ -121,17 +134,20 @@ frontend/app/(main)/screens/[screenId]/page.tsx (변경 없음) ### 1. screen_definitions 테이블 참조 **현재 구조**: + ```sql -- 새 테이블들이 screen_definitions를 참조 -CONSTRAINT fk_parent_screen FOREIGN KEY (parent_screen_id) +CONSTRAINT fk_parent_screen FOREIGN KEY (parent_screen_id) REFERENCES screen_definitions(screen_id) ON DELETE CASCADE ``` **잠재적 문제**: + - ⚠️ 기존 화면 삭제 시 임베딩 설정도 함께 삭제됨 (CASCADE) - ⚠️ 화면 ID 변경 시 임베딩 설정이 깨질 수 있음 **해결 방법**: + ```sql -- 이미 구현됨: ON DELETE CASCADE -- 화면 삭제 시 자동으로 관련 임베딩도 삭제 @@ -139,6 +155,7 @@ CONSTRAINT fk_parent_screen FOREIGN KEY (parent_screen_id) ``` **권장 사항**: + - ✅ 화면 삭제 전 임베딩 사용 여부 확인 UI 추가 (Phase 6) - ✅ 삭제 시 경고 메시지 표시 @@ -147,21 +164,23 @@ CONSTRAINT fk_parent_screen FOREIGN KEY (parent_screen_id) ### 2. 화면 렌더링 로직 **현재 화면 렌더링**: + ```typescript // frontend/app/(main)/screens/[screenId]/page.tsx function ScreenViewPage() { // 기존: 단일 화면 렌더링 const screenId = parseInt(params.screenId as string); - + // 레이아웃 로드 const layout = await screenApi.getScreenLayout(screenId); - + // 컴포넌트 렌더링 - + ; } ``` **새로운 렌더링 (분할 패널)**: + ```typescript // 분할 패널 화면인 경우 if (isSplitPanelScreen) { @@ -174,10 +193,12 @@ return ; ``` **잠재적 문제**: + - ⚠️ 화면 타입 구분 로직 필요 - ⚠️ 기존 화면 렌더링 로직 수정 필요 **해결 방법**: + ```typescript // 1. screen_definitions에 screen_type 컬럼 추가 (선택사항) ALTER TABLE screen_definitions ADD COLUMN screen_type VARCHAR(20) DEFAULT 'normal'; @@ -191,40 +212,45 @@ if (splitPanelConfig.success && splitPanelConfig.data) { ``` **권장 구현**: + ```typescript // frontend/app/(main)/screens/[screenId]/page.tsx 수정 useEffect(() => { const loadScreen = async () => { // 1. 분할 패널 확인 const splitPanelResult = await getScreenSplitPanel(screenId); - + if (splitPanelResult.success && splitPanelResult.data) { // 분할 패널 화면 - setScreenType('split_panel'); + setScreenType("split_panel"); setSplitPanelConfig(splitPanelResult.data); return; } - + // 2. 일반 화면 const screenResult = await screenApi.getScreen(screenId); const layoutResult = await screenApi.getScreenLayout(screenId); - - setScreenType('normal'); + + setScreenType("normal"); setScreen(screenResult.data); setLayout(layoutResult.data); }; - + loadScreen(); }, [screenId]); // 렌더링 -{screenType === 'split_panel' && splitPanelConfig && ( - -)} +{ + screenType === "split_panel" && splitPanelConfig && ( + + ); +} -{screenType === 'normal' && layout && ( - -)} +{ + screenType === "normal" && layout && ( + + ); +} ``` --- @@ -232,6 +258,7 @@ useEffect(() => { ### 3. 컴포넌트 등록 시스템 **현재 시스템**: + ```typescript // frontend/lib/registry/components.ts const componentRegistry = new Map(); @@ -242,6 +269,7 @@ export function registerComponent(id: string, component: any) { ``` **새로운 요구사항**: + ```typescript // DataReceivable 인터페이스 구현 필요 interface DataReceivable { @@ -254,29 +282,31 @@ interface DataReceivable { ``` **잠재적 문제**: + - ⚠️ 기존 컴포넌트들이 DataReceivable 인터페이스 미구현 - ⚠️ 데이터 수신 기능 없음 **해결 방법**: + ```typescript // Phase 5에서 구현 예정 // 기존 컴포넌트를 래핑하는 어댑터 패턴 사용 class TableComponentAdapter implements DataReceivable { constructor(private tableComponent: any) {} - + async receiveData(data: any[], mode: DataReceiveMode) { - if (mode === 'append') { + if (mode === "append") { this.tableComponent.addRows(data); - } else if (mode === 'replace') { + } else if (mode === "replace") { this.tableComponent.setRows(data); } } - + getData() { return this.tableComponent.getRows(); } - + clearData() { this.tableComponent.clearRows(); } @@ -284,6 +314,7 @@ class TableComponentAdapter implements DataReceivable { ``` **권장 사항**: + - ✅ 기존 컴포넌트 수정 없이 어댑터로 래핑 - ✅ 점진적으로 DataReceivable 구현 - ✅ 하위 호환성 유지 @@ -297,38 +328,41 @@ class TableComponentAdapter implements DataReceivable { **파일**: `frontend/app/(main)/screens/[screenId]/page.tsx` **수정 내용**: + ```typescript import { getScreenSplitPanel } from "@/lib/api/screenEmbedding"; import { ScreenSplitPanel } from "@/components/screen-embedding"; function ScreenViewPage() { - const [screenType, setScreenType] = useState<'normal' | 'split_panel'>('normal'); + const [screenType, setScreenType] = useState<"normal" | "split_panel">( + "normal" + ); const [splitPanelConfig, setSplitPanelConfig] = useState(null); - + useEffect(() => { const loadScreen = async () => { // 분할 패널 확인 const splitResult = await getScreenSplitPanel(screenId); - + if (splitResult.success && splitResult.data) { - setScreenType('split_panel'); + setScreenType("split_panel"); setSplitPanelConfig(splitResult.data); setLoading(false); return; } - + // 일반 화면 로드 (기존 로직) // ... }; - + loadScreen(); }, [screenId]); - + // 렌더링 - if (screenType === 'split_panel' && splitPanelConfig) { + if (screenType === "split_panel" && splitPanelConfig) { return ; } - + // 기존 렌더링 로직 // ... } @@ -343,6 +377,7 @@ function ScreenViewPage() { **파일**: 화면 관리 페이지 **추가 기능**: + - 화면 생성 시 "분할 패널" 타입 선택 - 분할 패널 설정 UI - 임베딩 설정 UI @@ -354,15 +389,15 @@ function ScreenViewPage() { ## 📊 충돌 위험도 평가 -| 항목 | 위험도 | 설명 | 조치 필요 | -|------|--------|------|-----------| -| 데이터베이스 스키마 | 🟢 낮음 | 독립적인 새 테이블 | ❌ 불필요 | -| API 엔드포인트 | 🟢 낮음 | 새로운 경로 추가 | ❌ 불필요 | -| TypeScript 타입 | 🟢 낮음 | 별도 파일 | ❌ 불필요 | -| 프론트엔드 컴포넌트 | 🟢 낮음 | 별도 디렉토리 | ❌ 불필요 | -| 화면 렌더링 로직 | 🟡 중간 | 조건 분기 추가 필요 | ✅ 필요 | -| 컴포넌트 등록 시스템 | 🟡 중간 | 어댑터 패턴 필요 | ✅ 필요 (Phase 5) | -| 외래키 CASCADE | 🟡 중간 | 화면 삭제 시 주의 | ⚠️ 주의 | +| 항목 | 위험도 | 설명 | 조치 필요 | +| -------------------- | ------- | ------------------- | ----------------- | +| 데이터베이스 스키마 | 🟢 낮음 | 독립적인 새 테이블 | ❌ 불필요 | +| API 엔드포인트 | 🟢 낮음 | 새로운 경로 추가 | ❌ 불필요 | +| TypeScript 타입 | 🟢 낮음 | 별도 파일 | ❌ 불필요 | +| 프론트엔드 컴포넌트 | 🟢 낮음 | 별도 디렉토리 | ❌ 불필요 | +| 화면 렌더링 로직 | 🟡 중간 | 조건 분기 추가 필요 | ✅ 필요 | +| 컴포넌트 등록 시스템 | 🟡 중간 | 어댑터 패턴 필요 | ✅ 필요 (Phase 5) | +| 외래키 CASCADE | 🟡 중간 | 화면 삭제 시 주의 | ⚠️ 주의 | **전체 위험도**: 🟢 **낮음** (대부분 독립적) @@ -371,24 +406,28 @@ function ScreenViewPage() { ## ✅ 안전성 체크리스트 ### 데이터베이스 + - [x] 새 테이블명이 기존과 중복되지 않음 - [x] 기존 테이블 구조 변경 없음 - [x] 외래키 CASCADE 설정 완료 - [x] 멀티테넌시 (company_code) 지원 ### 백엔드 + - [x] 새 라우트가 기존과 충돌하지 않음 - [x] 독립적인 컨트롤러 파일 - [x] 기존 API 수정 없음 - [x] 에러 핸들링 완료 ### 프론트엔드 + - [x] 새 컴포넌트가 별도 디렉토리 - [x] 기존 컴포넌트 수정 없음 - [x] 독립적인 타입 정의 - [ ] 화면 페이지 수정 필요 (조건 분기) ### 호환성 + - [x] 기존 화면 동작 영향 없음 - [x] 하위 호환성 유지 - [ ] 컴포넌트 어댑터 구현 (Phase 5) @@ -400,6 +439,7 @@ function ScreenViewPage() { ### 즉시 조치 (필수) 1. **화면 페이지 수정** + ```typescript // frontend/app/(main)/screens/[screenId]/page.tsx // 분할 패널 확인 로직 추가 @@ -421,11 +461,13 @@ function ScreenViewPage() { ### 단계적 조치 (Phase 5-6) 1. **컴포넌트 어댑터 구현** + - TableComponent → DataReceivable - InputComponent → DataReceivable - 기타 컴포넌트들 2. **설정 UI 개발** + - 분할 패널 생성 UI - 매핑 규칙 설정 UI - 미리보기 기능 @@ -442,6 +484,7 @@ function ScreenViewPage() { ### ✅ 안전성 평가: 높음 **이유**: + 1. ✅ 대부분의 코드가 독립적으로 추가됨 2. ✅ 기존 시스템 수정 최소화 3. ✅ 하위 호환성 유지 @@ -450,10 +493,12 @@ function ScreenViewPage() { ### ⚠️ 주의 사항 1. **화면 페이지 수정 필요** + - 분할 패널 확인 로직 추가 - 조건부 렌더링 구현 2. **점진적 구현 권장** + - Phase 5: 컴포넌트 어댑터 - Phase 6: 설정 UI - 단계별 테스트 @@ -467,4 +512,3 @@ function ScreenViewPage() { **충돌 위험도: 낮음 (🟢)** 새로운 시스템은 기존 시스템과 **독립적으로 동작**하며, 최소한의 수정만으로 통합 가능합니다. 화면 페이지에 조건 분기만 추가하면 바로 사용할 수 있습니다. -