diff --git a/backend-node/src/controllers/codeMergeController.ts b/backend-node/src/controllers/codeMergeController.ts index 29abfa8e..74d9e893 100644 --- a/backend-node/src/controllers/codeMergeController.ts +++ b/backend-node/src/controllers/codeMergeController.ts @@ -282,3 +282,175 @@ export async function previewCodeMerge( } } +/** + * 값 기반 코드 병합 - 모든 테이블의 모든 컬럼에서 해당 값을 찾아 변경 + * 컬럼명에 상관없이 oldValue를 가진 모든 곳을 newValue로 변경 + */ +export async function mergeCodeByValue( + req: AuthenticatedRequest, + res: Response +): Promise { + const { oldValue, newValue } = req.body; + const companyCode = req.user?.companyCode; + + try { + // 입력값 검증 + if (!oldValue || !newValue) { + res.status(400).json({ + success: false, + message: "필수 필드가 누락되었습니다. (oldValue, newValue)", + }); + return; + } + + if (!companyCode) { + res.status(401).json({ + success: false, + message: "인증 정보가 없습니다.", + }); + return; + } + + // 같은 값으로 병합 시도 방지 + if (oldValue === newValue) { + res.status(400).json({ + success: false, + message: "기존 값과 새 값이 동일합니다.", + }); + return; + } + + logger.info("값 기반 코드 병합 시작", { + oldValue, + newValue, + companyCode, + userId: req.user?.userId, + }); + + // PostgreSQL 함수 호출 + const result = await pool.query( + "SELECT * FROM merge_code_by_value($1, $2, $3)", + [oldValue, newValue, companyCode] + ); + + // 결과 처리 + const affectedData = Array.isArray(result) ? result : ((result as any).rows || []); + const totalRows = affectedData.reduce( + (sum: number, row: any) => sum + parseInt(row.out_rows_updated || 0), + 0 + ); + + logger.info("값 기반 코드 병합 완료", { + oldValue, + newValue, + affectedTablesCount: affectedData.length, + totalRowsUpdated: totalRows, + }); + + res.json({ + success: true, + message: `코드 병합 완료: ${oldValue} → ${newValue}`, + data: { + oldValue, + newValue, + affectedData: affectedData.map((row: any) => ({ + tableName: row.out_table_name, + columnName: row.out_column_name, + rowsUpdated: parseInt(row.out_rows_updated), + })), + totalRowsUpdated: totalRows, + }, + }); + } catch (error: any) { + logger.error("값 기반 코드 병합 실패:", { + error: error.message, + stack: error.stack, + oldValue, + newValue, + }); + + res.status(500).json({ + success: false, + message: "코드 병합 중 오류가 발생했습니다.", + error: { + code: "CODE_MERGE_BY_VALUE_ERROR", + details: error.message, + }, + }); + } +} + +/** + * 값 기반 코드 병합 미리보기 + * 컬럼명에 상관없이 해당 값을 가진 모든 테이블/컬럼 조회 + */ +export async function previewMergeCodeByValue( + req: AuthenticatedRequest, + res: Response +): Promise { + const { oldValue } = req.body; + const companyCode = req.user?.companyCode; + + try { + if (!oldValue) { + res.status(400).json({ + success: false, + message: "필수 필드가 누락되었습니다. (oldValue)", + }); + return; + } + + if (!companyCode) { + res.status(401).json({ + success: false, + message: "인증 정보가 없습니다.", + }); + return; + } + + logger.info("값 기반 코드 병합 미리보기", { oldValue, companyCode }); + + // PostgreSQL 함수 호출 + const result = await pool.query( + "SELECT * FROM preview_merge_code_by_value($1, $2)", + [oldValue, companyCode] + ); + + const preview = Array.isArray(result) ? result : ((result as any).rows || []); + const totalRows = preview.reduce( + (sum: number, row: any) => sum + parseInt(row.out_affected_rows || 0), + 0 + ); + + logger.info("값 기반 코드 병합 미리보기 완료", { + tablesCount: preview.length, + totalRows, + }); + + res.json({ + success: true, + message: "코드 병합 미리보기 완료", + data: { + oldValue, + preview: preview.map((row: any) => ({ + tableName: row.out_table_name, + columnName: row.out_column_name, + affectedRows: parseInt(row.out_affected_rows), + })), + totalAffectedRows: totalRows, + }, + }); + } catch (error: any) { + logger.error("값 기반 코드 병합 미리보기 실패:", error); + + res.status(500).json({ + success: false, + message: "코드 병합 미리보기 중 오류가 발생했습니다.", + error: { + code: "PREVIEW_BY_VALUE_ERROR", + details: error.message, + }, + }); + } +} + diff --git a/backend-node/src/controllers/tableManagementController.ts b/backend-node/src/controllers/tableManagementController.ts index 9b3d81a2..65cd5f4c 100644 --- a/backend-node/src/controllers/tableManagementController.ts +++ b/backend-node/src/controllers/tableManagementController.ts @@ -2185,3 +2185,67 @@ export async function multiTableSave( } } +/** + * 두 테이블 간의 엔티티 관계 자동 감지 + * GET /api/table-management/tables/entity-relations?leftTable=xxx&rightTable=yyy + * + * column_labels에서 정의된 엔티티/카테고리 타입 설정을 기반으로 + * 두 테이블 간의 외래키 관계를 자동으로 감지합니다. + */ +export async function getTableEntityRelations( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { leftTable, rightTable } = req.query; + + logger.info(`=== 테이블 엔티티 관계 조회 시작: ${leftTable} <-> ${rightTable} ===`); + + if (!leftTable || !rightTable) { + const response: ApiResponse = { + success: false, + message: "leftTable과 rightTable 파라미터가 필요합니다.", + error: { + code: "MISSING_PARAMETERS", + details: "leftTable과 rightTable 쿼리 파라미터가 필요합니다.", + }, + }; + res.status(400).json(response); + return; + } + + const tableManagementService = new TableManagementService(); + const relations = await tableManagementService.detectTableEntityRelations( + String(leftTable), + String(rightTable) + ); + + logger.info(`테이블 엔티티 관계 조회 완료: ${relations.length}개 발견`); + + const response: ApiResponse = { + success: true, + message: `${relations.length}개의 엔티티 관계를 발견했습니다.`, + data: { + leftTable: String(leftTable), + rightTable: String(rightTable), + relations, + }, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("테이블 엔티티 관계 조회 중 오류 발생:", error); + + const response: ApiResponse = { + success: false, + message: "테이블 엔티티 관계 조회 중 오류가 발생했습니다.", + error: { + code: "ENTITY_RELATIONS_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }; + + res.status(500).json(response); + } +} + diff --git a/backend-node/src/routes/codeMergeRoutes.ts b/backend-node/src/routes/codeMergeRoutes.ts index 78cbd3e1..2cb41923 100644 --- a/backend-node/src/routes/codeMergeRoutes.ts +++ b/backend-node/src/routes/codeMergeRoutes.ts @@ -3,6 +3,8 @@ import { mergeCodeAllTables, getTablesWithColumn, previewCodeMerge, + mergeCodeByValue, + previewMergeCodeByValue, } from "../controllers/codeMergeController"; import { authenticateToken } from "../middleware/authMiddleware"; @@ -13,7 +15,7 @@ router.use(authenticateToken); /** * POST /api/code-merge/merge-all-tables - * 코드 병합 실행 (모든 관련 테이블에 적용) + * 코드 병합 실행 (모든 관련 테이블에 적용 - 같은 컬럼명만) * Body: { columnName, oldValue, newValue } */ router.post("/merge-all-tables", mergeCodeAllTables); @@ -26,10 +28,24 @@ router.get("/tables-with-column/:columnName", getTablesWithColumn); /** * POST /api/code-merge/preview - * 코드 병합 미리보기 (실제 실행 없이 영향받을 데이터 확인) + * 코드 병합 미리보기 (같은 컬럼명 기준) * Body: { columnName, oldValue } */ router.post("/preview", previewCodeMerge); +/** + * POST /api/code-merge/merge-by-value + * 값 기반 코드 병합 (모든 테이블의 모든 컬럼에서 해당 값을 찾아 변경) + * Body: { oldValue, newValue } + */ +router.post("/merge-by-value", mergeCodeByValue); + +/** + * POST /api/code-merge/preview-by-value + * 값 기반 코드 병합 미리보기 (컬럼명 상관없이 값으로 검색) + * Body: { oldValue } + */ +router.post("/preview-by-value", previewMergeCodeByValue); + export default router; diff --git a/backend-node/src/routes/dataRoutes.ts b/backend-node/src/routes/dataRoutes.ts index f87aa5d6..f9d88d92 100644 --- a/backend-node/src/routes/dataRoutes.ts +++ b/backend-node/src/routes/dataRoutes.ts @@ -698,6 +698,7 @@ router.post( try { const { tableName } = req.params; const filterConditions = req.body; + const userCompany = req.user?.companyCode; if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(tableName)) { return res.status(400).json({ @@ -706,11 +707,12 @@ router.post( }); } - console.log(`🗑️ 그룹 삭제:`, { tableName, filterConditions }); + console.log(`🗑️ 그룹 삭제:`, { tableName, filterConditions, userCompany }); const result = await dataService.deleteGroupRecords( tableName, - filterConditions + filterConditions, + userCompany // 회사 코드 전달 ); if (!result.success) { diff --git a/backend-node/src/routes/tableManagementRoutes.ts b/backend-node/src/routes/tableManagementRoutes.ts index d0716d59..fa7832ee 100644 --- a/backend-node/src/routes/tableManagementRoutes.ts +++ b/backend-node/src/routes/tableManagementRoutes.ts @@ -25,6 +25,7 @@ import { toggleLogTable, getCategoryColumnsByMenu, // 🆕 메뉴별 카테고리 컬럼 조회 multiTableSave, // 🆕 범용 다중 테이블 저장 + getTableEntityRelations, // 🆕 두 테이블 간 엔티티 관계 조회 } from "../controllers/tableManagementController"; const router = express.Router(); @@ -38,6 +39,15 @@ router.use(authenticateToken); */ router.get("/tables", getTableList); +/** + * 두 테이블 간 엔티티 관계 조회 + * GET /api/table-management/tables/entity-relations?leftTable=xxx&rightTable=yyy + * + * column_labels에서 정의된 엔티티/카테고리 타입 설정을 기반으로 + * 두 테이블 간의 외래키 관계를 자동으로 감지합니다. + */ +router.get("/tables/entity-relations", getTableEntityRelations); + /** * 테이블 컬럼 정보 조회 * GET /api/table-management/tables/:tableName/columns diff --git a/backend-node/src/services/dataService.ts b/backend-node/src/services/dataService.ts index a1a494f2..75c57673 100644 --- a/backend-node/src/services/dataService.ts +++ b/backend-node/src/services/dataService.ts @@ -1189,6 +1189,13 @@ class DataService { [tableName] ); + console.log(`🔍 테이블 ${tableName}의 Primary Key 조회 결과:`, { + pkColumns: pkResult.map((r) => r.attname), + pkCount: pkResult.length, + inputId: typeof id === "object" ? JSON.stringify(id).substring(0, 200) + "..." : id, + inputIdType: typeof id, + }); + let whereClauses: string[] = []; let params: any[] = []; @@ -1216,17 +1223,31 @@ class DataService { params.push(typeof id === "object" ? id[pkColumn] : id); } - const queryText = `DELETE FROM "${tableName}" WHERE ${whereClauses.join(" AND ")}`; + const queryText = `DELETE FROM "${tableName}" WHERE ${whereClauses.join(" AND ")} RETURNING *`; console.log(`🗑️ 삭제 쿼리:`, queryText, params); const result = await query(queryText, params); + // 삭제된 행이 없으면 실패 처리 + if (result.length === 0) { + console.warn( + `⚠️ 레코드 삭제 실패: ${tableName}, 해당 조건에 맞는 레코드가 없습니다.`, + { whereClauses, params } + ); + return { + success: false, + message: "삭제할 레코드를 찾을 수 없습니다. 이미 삭제되었거나 권한이 없습니다.", + error: "RECORD_NOT_FOUND", + }; + } + console.log( `✅ 레코드 삭제 완료: ${tableName}, 영향받은 행: ${result.length}` ); return { success: true, + data: result[0], // 삭제된 레코드 정보 반환 }; } catch (error) { console.error(`레코드 삭제 오류 (${tableName}):`, error); @@ -1240,10 +1261,14 @@ class DataService { /** * 조건에 맞는 모든 레코드 삭제 (그룹 삭제) + * @param tableName 테이블명 + * @param filterConditions 삭제 조건 + * @param userCompany 사용자 회사 코드 (멀티테넌시 필터링) */ async deleteGroupRecords( tableName: string, - filterConditions: Record + filterConditions: Record, + userCompany?: string ): Promise> { try { const validation = await this.validateTableAccess(tableName); @@ -1255,6 +1280,7 @@ class DataService { const whereValues: any[] = []; let paramIndex = 1; + // 사용자 필터 조건 추가 for (const [key, value] of Object.entries(filterConditions)) { whereConditions.push(`"${key}" = $${paramIndex}`); whereValues.push(value); @@ -1269,10 +1295,24 @@ class DataService { }; } + // 🔒 멀티테넌시: company_code 필터링 (최고 관리자 제외) + const hasCompanyCode = await this.checkColumnExists(tableName, "company_code"); + if (hasCompanyCode && userCompany && userCompany !== "*") { + whereConditions.push(`"company_code" = $${paramIndex}`); + whereValues.push(userCompany); + paramIndex++; + console.log(`🔒 멀티테넌시 필터 적용: company_code = ${userCompany}`); + } + const whereClause = whereConditions.join(" AND "); const deleteQuery = `DELETE FROM "${tableName}" WHERE ${whereClause} RETURNING *`; - console.log(`🗑️ 그룹 삭제:`, { tableName, conditions: filterConditions }); + console.log(`🗑️ 그룹 삭제:`, { + tableName, + conditions: filterConditions, + userCompany, + whereClause, + }); const result = await pool.query(deleteQuery, whereValues); diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index 98db1eee..7df10fdb 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -1306,6 +1306,41 @@ export class TableManagementService { paramCount: number; } | null> { try { + // 🆕 배열 값 처리 (다중 값 검색 - 분할패널 엔티티 타입에서 "2,3" 형태 지원) + // 좌측에서 "2"를 선택해도, 우측에서 "2,3"을 가진 행이 표시되도록 함 + if (Array.isArray(value) && value.length > 0) { + // 배열의 각 값에 대해 OR 조건으로 검색 + // 우측 컬럼에 "2,3" 같은 다중 값이 있을 수 있으므로 + // 각 값을 LIKE 또는 = 조건으로 처리 + const conditions: string[] = []; + const values: any[] = []; + + value.forEach((v: any, idx: number) => { + const safeValue = String(v).trim(); + // 정확히 일치하거나, 콤마로 구분된 값 중 하나로 포함 + // 예: "2,3" 컬럼에서 "2"를 찾으려면: + // - 정확히 "2" + // - "2," 로 시작 + // - ",2" 로 끝남 + // - ",2," 중간에 포함 + const paramBase = paramIndex + (idx * 4); + conditions.push(`( + ${columnName}::text = $${paramBase} OR + ${columnName}::text LIKE $${paramBase + 1} OR + ${columnName}::text LIKE $${paramBase + 2} OR + ${columnName}::text LIKE $${paramBase + 3} + )`); + values.push(safeValue, `${safeValue},%`, `%,${safeValue}`, `%,${safeValue},%`); + }); + + logger.info(`🔍 다중 값 배열 검색: ${columnName} IN [${value.join(", ")}]`); + return { + whereClause: `(${conditions.join(" OR ")})`, + values, + paramCount: values.length, + }; + } + // 🔧 파이프로 구분된 문자열 처리 (다중선택 또는 날짜 범위) if (typeof value === "string" && value.includes("|")) { const columnInfo = await this.getColumnWebTypeInfo( @@ -4630,4 +4665,101 @@ export class TableManagementService { return false; } } + + /** + * 두 테이블 간의 엔티티 관계 자동 감지 + * column_labels에서 엔티티 타입 설정을 기반으로 테이블 간 관계를 찾습니다. + * + * @param leftTable 좌측 테이블명 + * @param rightTable 우측 테이블명 + * @returns 감지된 엔티티 관계 배열 + */ + async detectTableEntityRelations( + leftTable: string, + rightTable: string + ): Promise> { + try { + logger.info(`두 테이블 간 엔티티 관계 감지 시작: ${leftTable} <-> ${rightTable}`); + + const relations: Array<{ + leftColumn: string; + rightColumn: string; + direction: "left_to_right" | "right_to_left"; + inputType: string; + displayColumn?: string; + }> = []; + + // 1. 우측 테이블에서 좌측 테이블을 참조하는 엔티티 컬럼 찾기 + // 예: right_table의 customer_id -> left_table(customer_mng)의 customer_code + const rightToLeftRels = await query<{ + column_name: string; + reference_column: string; + input_type: string; + display_column: string | null; + }>( + `SELECT column_name, reference_column, input_type, display_column + FROM column_labels + WHERE table_name = $1 + AND input_type IN ('entity', 'category') + AND reference_table = $2 + AND reference_column IS NOT NULL + AND reference_column != ''`, + [rightTable, leftTable] + ); + + for (const rel of rightToLeftRels) { + relations.push({ + leftColumn: rel.reference_column, + rightColumn: rel.column_name, + direction: "right_to_left", + inputType: rel.input_type, + displayColumn: rel.display_column || undefined, + }); + } + + // 2. 좌측 테이블에서 우측 테이블을 참조하는 엔티티 컬럼 찾기 + // 예: left_table의 item_id -> right_table(item_info)의 item_number + const leftToRightRels = await query<{ + column_name: string; + reference_column: string; + input_type: string; + display_column: string | null; + }>( + `SELECT column_name, reference_column, input_type, display_column + FROM column_labels + WHERE table_name = $1 + AND input_type IN ('entity', 'category') + AND reference_table = $2 + AND reference_column IS NOT NULL + AND reference_column != ''`, + [leftTable, rightTable] + ); + + for (const rel of leftToRightRels) { + relations.push({ + leftColumn: rel.column_name, + rightColumn: rel.reference_column, + direction: "left_to_right", + inputType: rel.input_type, + displayColumn: rel.display_column || undefined, + }); + } + + logger.info(`엔티티 관계 감지 완료: ${relations.length}개 발견`); + relations.forEach((rel, idx) => { + logger.info(` ${idx + 1}. ${leftTable}.${rel.leftColumn} <-> ${rightTable}.${rel.rightColumn} (${rel.direction})`); + }); + + return relations; + } catch (error) { + logger.error(`엔티티 관계 감지 실패: ${leftTable} <-> ${rightTable}`, error); + return []; + } + } } diff --git a/frontend/components/screen/InteractiveScreenViewer.tsx b/frontend/components/screen/InteractiveScreenViewer.tsx index 376f9953..f786d1d1 100644 --- a/frontend/components/screen/InteractiveScreenViewer.tsx +++ b/frontend/components/screen/InteractiveScreenViewer.tsx @@ -1369,58 +1369,25 @@ export const InteractiveScreenViewer: React.FC = ( } case "entity": { + // DynamicWebTypeRenderer로 위임하여 EntitySearchInputWrapper 사용 const widget = comp as WidgetComponent; - const config = widget.webTypeConfig as EntityTypeConfig | undefined; - - console.log("🏢 InteractiveScreenViewer - Entity 위젯:", { - componentId: widget.id, - widgetType: widget.widgetType, - config, - appliedSettings: { - entityName: config?.entityName, - displayField: config?.displayField, - valueField: config?.valueField, - multiple: config?.multiple, - defaultValue: config?.defaultValue, - }, - }); - - const finalPlaceholder = config?.placeholder || "엔티티를 선택하세요..."; - const defaultOptions = [ - { label: "사용자", value: "user" }, - { label: "제품", value: "product" }, - { label: "주문", value: "order" }, - { label: "카테고리", value: "category" }, - ]; - - return ( - , + return applyStyles( + updateFormData(fieldName, value), + onFormDataChange: updateFormData, + formData: formData, + readonly: readonly, + required: required, + placeholder: widget.placeholder || "엔티티를 선택하세요", + isInteractive: true, + className: "w-full h-full", + }} + />, ); } diff --git a/frontend/components/screen/panels/webtype-configs/EntityTypeConfigPanel.tsx b/frontend/components/screen/panels/webtype-configs/EntityTypeConfigPanel.tsx index 05171d01..1cae7dce 100644 --- a/frontend/components/screen/panels/webtype-configs/EntityTypeConfigPanel.tsx +++ b/frontend/components/screen/panels/webtype-configs/EntityTypeConfigPanel.tsx @@ -6,6 +6,7 @@ import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Checkbox } from "@/components/ui/checkbox"; +import { Switch } from "@/components/ui/switch"; import { Badge } from "@/components/ui/badge"; import { Search, Database, Link, X, Plus } from "lucide-react"; import { EntityTypeConfig } from "@/types/screen"; @@ -26,6 +27,8 @@ export const EntityTypeConfigPanel: React.FC = ({ co placeholder: "", displayFormat: "simple", separator: " - ", + multiple: false, // 다중 선택 + uiMode: "select", // UI 모드: select, combo, modal, autocomplete ...config, }; @@ -38,6 +41,8 @@ export const EntityTypeConfigPanel: React.FC = ({ co placeholder: safeConfig.placeholder, displayFormat: safeConfig.displayFormat, separator: safeConfig.separator, + multiple: safeConfig.multiple, + uiMode: safeConfig.uiMode, }); const [newFilter, setNewFilter] = useState({ field: "", operator: "=", value: "" }); @@ -74,6 +79,8 @@ export const EntityTypeConfigPanel: React.FC = ({ co placeholder: safeConfig.placeholder, displayFormat: safeConfig.displayFormat, separator: safeConfig.separator, + multiple: safeConfig.multiple, + uiMode: safeConfig.uiMode, }); }, [ safeConfig.referenceTable, @@ -83,8 +90,18 @@ export const EntityTypeConfigPanel: React.FC = ({ co safeConfig.placeholder, safeConfig.displayFormat, safeConfig.separator, + safeConfig.multiple, + safeConfig.uiMode, ]); + // UI 모드 옵션 + const uiModes = [ + { value: "select", label: "드롭다운 선택" }, + { value: "combo", label: "입력 + 모달 버튼" }, + { value: "modal", label: "모달 팝업" }, + { value: "autocomplete", label: "자동완성" }, + ]; + const updateConfig = (key: keyof EntityTypeConfig, value: any) => { // 로컬 상태 즉시 업데이트 setLocalValues((prev) => ({ ...prev, [key]: value })); @@ -260,6 +277,46 @@ export const EntityTypeConfigPanel: React.FC = ({ co /> + {/* UI 모드 */} +
+ + +

+ {localValues.uiMode === "select" && "검색 가능한 드롭다운 형태로 표시됩니다."} + {localValues.uiMode === "combo" && "입력 필드와 검색 버튼이 함께 표시됩니다."} + {localValues.uiMode === "modal" && "모달 팝업에서 데이터를 검색하고 선택합니다."} + {localValues.uiMode === "autocomplete" && "입력하면서 자동완성 목록이 표시됩니다."} +

+
+ + {/* 다중 선택 */} +
+
+ +

여러 항목을 선택할 수 있습니다.

+
+ updateConfig("multiple", checked)} + /> +
+ {/* 필터 관리 */}
diff --git a/frontend/lib/api/dynamicForm.ts b/frontend/lib/api/dynamicForm.ts index a5a3b2eb..d2433c48 100644 --- a/frontend/lib/api/dynamicForm.ts +++ b/frontend/lib/api/dynamicForm.ts @@ -93,10 +93,15 @@ export class DynamicFormApi { ): Promise> { try { console.log("🔄 폼 데이터 업데이트 요청:", { id, formData }); + console.log("🌐 API URL:", `/dynamic-form/${id}`); + console.log("📦 요청 본문:", JSON.stringify(formData, null, 2)); const response = await apiClient.put(`/dynamic-form/${id}`, formData); console.log("✅ 폼 데이터 업데이트 성공:", response.data); + console.log("📊 응답 상태:", response.status); + console.log("📋 응답 헤더:", response.headers); + return { success: true, data: response.data, @@ -104,6 +109,8 @@ export class DynamicFormApi { }; } catch (error: any) { console.error("❌ 폼 데이터 업데이트 실패:", error); + console.error("📊 에러 응답:", error.response?.data); + console.error("📊 에러 상태:", error.response?.status); const errorMessage = error.response?.data?.message || error.message || "데이터 업데이트 중 오류가 발생했습니다."; diff --git a/frontend/lib/api/tableManagement.ts b/frontend/lib/api/tableManagement.ts index d03d83bf..5953fd82 100644 --- a/frontend/lib/api/tableManagement.ts +++ b/frontend/lib/api/tableManagement.ts @@ -328,6 +328,40 @@ class TableManagementApi { }; } } + + /** + * 두 테이블 간의 엔티티 관계 자동 감지 + * column_labels에서 정의된 엔티티/카테고리 타입 설정을 기반으로 + * 두 테이블 간의 외래키 관계를 자동으로 감지합니다. + */ + async getTableEntityRelations( + leftTable: string, + rightTable: string + ): Promise; + }>> { + try { + const response = await apiClient.get( + `${this.basePath}/tables/entity-relations?leftTable=${encodeURIComponent(leftTable)}&rightTable=${encodeURIComponent(rightTable)}` + ); + return response.data; + } catch (error: any) { + console.error(`❌ 테이블 엔티티 관계 조회 실패: ${leftTable} <-> ${rightTable}`, error); + return { + success: false, + message: error.response?.data?.message || error.message || "테이블 엔티티 관계를 조회할 수 없습니다.", + errorCode: error.response?.data?.errorCode, + }; + } + } } // 싱글톤 인스턴스 생성 diff --git a/frontend/lib/registry/components/entity-search-input/EntitySearchInputComponent.tsx b/frontend/lib/registry/components/entity-search-input/EntitySearchInputComponent.tsx index 70785171..5045a43b 100644 --- a/frontend/lib/registry/components/entity-search-input/EntitySearchInputComponent.tsx +++ b/frontend/lib/registry/components/entity-search-input/EntitySearchInputComponent.tsx @@ -35,7 +35,9 @@ export function EntitySearchInputComponent({ parentValue: parentValueProp, parentFieldId, formData, - // 🆕 추가 props + // 다중선택 props + multiple: multipleProp, + // 추가 props component, isInteractive, onFormDataChange, @@ -49,8 +51,11 @@ export function EntitySearchInputComponent({ // uiMode가 있으면 우선 사용, 없으면 modeProp 사용, 기본값 "combo" const mode = (uiMode || modeProp || "combo") as "select" | "modal" | "combo" | "autocomplete"; - // 연쇄관계 설정 추출 (webTypeConfig 또는 component.componentConfig에서) - const config = component?.componentConfig || {}; + // 다중선택 및 연쇄관계 설정 (props > webTypeConfig > componentConfig 순서) + const config = component?.componentConfig || component?.webTypeConfig || {}; + const isMultiple = multipleProp ?? config.multiple ?? false; + + // 연쇄관계 설정 추출 const effectiveCascadingRelationCode = cascadingRelationCode || config.cascadingRelationCode; // cascadingParentField: ConfigPanel에서 저장되는 필드명 const effectiveParentFieldId = parentFieldId || config.cascadingParentField || config.parentFieldId; @@ -68,11 +73,27 @@ export function EntitySearchInputComponent({ const [isLoadingOptions, setIsLoadingOptions] = useState(false); const [optionsLoaded, setOptionsLoaded] = useState(false); + // 다중선택 상태 (콤마로 구분된 값들) + const [selectedValues, setSelectedValues] = useState([]); + const [selectedDataList, setSelectedDataList] = useState([]); + // 연쇄관계 상태 const [cascadingOptions, setCascadingOptions] = useState([]); const [isCascadingLoading, setIsCascadingLoading] = useState(false); const previousParentValue = useRef(null); + // 다중선택 초기값 설정 + useEffect(() => { + if (isMultiple && value) { + const vals = + typeof value === "string" ? value.split(",").filter(Boolean) : Array.isArray(value) ? value : [value]; + setSelectedValues(vals.map(String)); + } else if (isMultiple && !value) { + setSelectedValues([]); + setSelectedDataList([]); + } + }, [isMultiple, value]); + // 부모 필드 값 결정 (직접 전달 또는 formData에서 추출) - 자식 역할일 때만 필요 const parentValue = isChildRole ? (parentValueProp ?? (effectiveParentFieldId && formData ? formData[effectiveParentFieldId] : undefined)) @@ -249,23 +270,75 @@ export function EntitySearchInputComponent({ }, [value, displayField, effectiveOptions, mode, valueField, tableName, selectedData]); const handleSelect = (newValue: any, fullData: EntitySearchResult) => { - setSelectedData(fullData); - setDisplayValue(fullData[displayField] || ""); - onChange?.(newValue, fullData); + if (isMultiple) { + // 다중선택 모드 + const valueStr = String(newValue); + const isAlreadySelected = selectedValues.includes(valueStr); + + let newSelectedValues: string[]; + let newSelectedDataList: EntitySearchResult[]; + + if (isAlreadySelected) { + // 이미 선택된 항목이면 제거 + newSelectedValues = selectedValues.filter((v) => v !== valueStr); + newSelectedDataList = selectedDataList.filter((d) => String(d[valueField]) !== valueStr); + } else { + // 선택되지 않은 항목이면 추가 + newSelectedValues = [...selectedValues, valueStr]; + newSelectedDataList = [...selectedDataList, fullData]; + } + + setSelectedValues(newSelectedValues); + setSelectedDataList(newSelectedDataList); + + const joinedValue = newSelectedValues.join(","); + onChange?.(joinedValue, newSelectedDataList); + + if (isInteractive && onFormDataChange && component?.columnName) { + onFormDataChange(component.columnName, joinedValue); + console.log("📤 EntitySearchInput (multiple) -> onFormDataChange:", component.columnName, joinedValue); + } + } else { + // 단일선택 모드 + setSelectedData(fullData); + setDisplayValue(fullData[displayField] || ""); + onChange?.(newValue, fullData); + + if (isInteractive && onFormDataChange && component?.columnName) { + onFormDataChange(component.columnName, newValue); + console.log("📤 EntitySearchInput -> onFormDataChange:", component.columnName, newValue); + } + } + }; + + // 다중선택 모드에서 개별 항목 제거 + const handleRemoveValue = (valueToRemove: string) => { + const newSelectedValues = selectedValues.filter((v) => v !== valueToRemove); + const newSelectedDataList = selectedDataList.filter((d) => String(d[valueField]) !== valueToRemove); + + setSelectedValues(newSelectedValues); + setSelectedDataList(newSelectedDataList); + + const joinedValue = newSelectedValues.join(","); + onChange?.(joinedValue || null, newSelectedDataList); - // 🆕 onFormDataChange 호출 (formData에 값 저장) if (isInteractive && onFormDataChange && component?.columnName) { - onFormDataChange(component.columnName, newValue); - console.log("📤 EntitySearchInput -> onFormDataChange:", component.columnName, newValue); + onFormDataChange(component.columnName, joinedValue || null); + console.log("📤 EntitySearchInput (remove) -> onFormDataChange:", component.columnName, joinedValue); } }; const handleClear = () => { - setDisplayValue(""); - setSelectedData(null); - onChange?.(null, null); + if (isMultiple) { + setSelectedValues([]); + setSelectedDataList([]); + onChange?.(null, []); + } else { + setDisplayValue(""); + setSelectedData(null); + onChange?.(null, null); + } - // 🆕 onFormDataChange 호출 (formData에서 값 제거) if (isInteractive && onFormDataChange && component?.columnName) { onFormDataChange(component.columnName, null); console.log("📤 EntitySearchInput -> onFormDataChange (clear):", component.columnName, null); @@ -280,7 +353,10 @@ export function EntitySearchInputComponent({ const handleSelectOption = (option: EntitySearchResult) => { handleSelect(option[valueField], option); - setSelectOpen(false); + // 다중선택이 아닌 경우에만 드롭다운 닫기 + if (!isMultiple) { + setSelectOpen(false); + } }; // 높이 계산 (style에서 height가 있으면 사용, 없으면 기본값) @@ -289,6 +365,111 @@ export function EntitySearchInputComponent({ // select 모드: 검색 가능한 드롭다운 if (mode === "select") { + // 다중선택 모드 + if (isMultiple) { + return ( +
+ {/* 라벨 렌더링 */} + {component?.label && component?.style?.labelDisplay !== false && ( + + )} + + {/* 선택된 항목들 표시 (태그 형식) */} +
!disabled && !isLoading && setSelectOpen(true)} + style={{ cursor: disabled ? "not-allowed" : "pointer" }} + > + {selectedValues.length > 0 ? ( + selectedValues.map((val) => { + const opt = effectiveOptions.find((o) => String(o[valueField]) === val); + const label = opt?.[displayField] || val; + return ( + + {label} + {!disabled && ( + + )} + + ); + }) + ) : ( + + {isLoading + ? "로딩 중..." + : shouldApplyCascading && !parentValue + ? "상위 항목을 먼저 선택하세요" + : placeholder} + + )} + +
+ + {/* 옵션 드롭다운 */} + {selectOpen && !disabled && ( +
+ + + + 항목을 찾을 수 없습니다. + + {effectiveOptions.map((option, index) => { + const isSelected = selectedValues.includes(String(option[valueField])); + return ( + handleSelectOption(option)} + className="text-xs sm:text-sm" + > + +
+ {option[displayField]} + {valueField !== displayField && ( + {option[valueField]} + )} +
+
+ ); + })} +
+
+
+ {/* 닫기 버튼 */} +
+ +
+
+ )} + + {/* 외부 클릭 시 닫기 */} + {selectOpen &&
setSelectOpen(false)} />} +
+ ); + } + + // 단일선택 모드 (기존 로직) return (
{/* 라벨 렌더링 */} @@ -366,6 +547,95 @@ export function EntitySearchInputComponent({ } // modal, combo, autocomplete 모드 + // 다중선택 모드 + if (isMultiple) { + return ( +
+ {/* 라벨 렌더링 */} + {component?.label && component?.style?.labelDisplay !== false && ( + + )} + + {/* 선택된 항목들 표시 (태그 형식) + 검색 버튼 */} +
+
+ {selectedValues.length > 0 ? ( + selectedValues.map((val) => { + // selectedDataList에서 먼저 찾고, 없으면 effectiveOptions에서 찾기 + const dataFromList = selectedDataList.find((d) => String(d[valueField]) === val); + const opt = dataFromList || effectiveOptions.find((o) => String(o[valueField]) === val); + const label = opt?.[displayField] || val; + return ( + + {label} + {!disabled && ( + + )} + + ); + }) + ) : ( + {placeholder} + )} +
+ + {/* 모달 버튼: modal 또는 combo 모드일 때만 표시 */} + {(mode === "modal" || mode === "combo") && ( + + )} +
+ + {/* 검색 모달: modal 또는 combo 모드일 때만 렌더링 */} + {(mode === "modal" || mode === "combo") && ( + + )} +
+ ); + } + + // 단일선택 모드 (기존 로직) return (
{/* 라벨 렌더링 */} diff --git a/frontend/lib/registry/components/entity-search-input/EntitySearchInputConfigPanel.tsx b/frontend/lib/registry/components/entity-search-input/EntitySearchInputConfigPanel.tsx index 3cd4d35a..fb75daa4 100644 --- a/frontend/lib/registry/components/entity-search-input/EntitySearchInputConfigPanel.tsx +++ b/frontend/lib/registry/components/entity-search-input/EntitySearchInputConfigPanel.tsx @@ -747,6 +747,23 @@ export function EntitySearchInputConfigPanel({

+
+
+ + + updateConfig({ multiple: checked }) + } + /> +
+

+ {localConfig.multiple + ? "여러 항목을 선택할 수 있습니다. 값은 콤마로 구분됩니다." + : "하나의 항목만 선택할 수 있습니다."} +

+
+
= ({ + component, + value, + onChange, + readonly = false, + ...props +}) => { + // component에서 필요한 설정 추출 + const widget = component as any; + const webTypeConfig = widget?.webTypeConfig || {}; + const componentConfig = widget?.componentConfig || {}; + + // 설정 우선순위: webTypeConfig > componentConfig > component 직접 속성 + const config = { ...componentConfig, ...webTypeConfig }; + + // 테이블 타입 관리에서 설정된 참조 테이블 정보 사용 + const tableName = config.referenceTable || widget?.referenceTable || ""; + const displayField = config.labelField || config.displayColumn || config.displayField || "name"; + const valueField = config.valueField || config.referenceColumn || "id"; + + // UI 모드: uiMode > mode 순서 + const uiMode = config.uiMode || config.mode || "select"; + + // 다중선택 설정 + const multiple = config.multiple ?? false; + + // placeholder + const placeholder = config.placeholder || widget?.placeholder || "항목을 선택하세요"; + + console.log("🏢 EntitySearchInputWrapper 렌더링:", { + tableName, + displayField, + valueField, + uiMode, + multiple, + value, + config, + }); + + // 테이블 정보가 없으면 안내 메시지 표시 + if (!tableName) { + return ( +
+ 테이블 타입 관리에서 참조 테이블을 설정해주세요 +
+ ); + } + + return ( + + ); +}; + +EntitySearchInputWrapper.displayName = "EntitySearchInputWrapper"; + diff --git a/frontend/lib/registry/components/entity-search-input/EntitySearchModal.tsx b/frontend/lib/registry/components/entity-search-input/EntitySearchModal.tsx index 7f841ec3..555efe9b 100644 --- a/frontend/lib/registry/components/entity-search-input/EntitySearchModal.tsx +++ b/frontend/lib/registry/components/entity-search-input/EntitySearchModal.tsx @@ -11,7 +11,9 @@ import { } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { Search, Loader2 } from "lucide-react"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Search, Loader2, Check } from "lucide-react"; +import { cn } from "@/lib/utils"; import { useEntitySearch } from "./useEntitySearch"; import { EntitySearchResult } from "./types"; @@ -26,6 +28,9 @@ interface EntitySearchModalProps { modalTitle?: string; modalColumns?: string[]; onSelect: (value: any, fullData: EntitySearchResult) => void; + // 다중선택 관련 + multiple?: boolean; + selectedValues?: string[]; // 이미 선택된 값들 } export function EntitySearchModal({ @@ -39,6 +44,8 @@ export function EntitySearchModal({ modalTitle = "검색", modalColumns = [], onSelect, + multiple = false, + selectedValues = [], }: EntitySearchModalProps) { const [localSearchText, setLocalSearchText] = useState(""); const { @@ -71,7 +78,15 @@ export function EntitySearchModal({ const handleSelect = (item: EntitySearchResult) => { onSelect(item[valueField], item); - onOpenChange(false); + // 다중선택이 아닌 경우에만 모달 닫기 + if (!multiple) { + onOpenChange(false); + } + }; + + // 항목이 선택되어 있는지 확인 + const isItemSelected = (item: EntitySearchResult): boolean => { + return selectedValues.includes(String(item[valueField])); }; // 표시할 컬럼 결정 @@ -123,10 +138,16 @@ export function EntitySearchModal({ {/* 검색 결과 테이블 */}
-
+
- + + {/* 다중선택 시 체크박스 컬럼 */} + {multiple && ( + + )} {displayColumns.map((col) => ( + {!multiple && ( + + )} {loading && results.length === 0 ? ( - ) : results.length === 0 ? ( - ) : ( results.map((item, index) => { const uniqueKey = item[valueField] !== undefined ? `${item[valueField]}` : `row-${index}`; + const isSelected = isItemSelected(item); return ( - handleSelect(item)} - > + className={cn( + "border-t cursor-pointer transition-colors", + isSelected ? "bg-blue-50 hover:bg-blue-100" : "hover:bg-accent" + )} + onClick={() => handleSelect(item)} + > + {/* 다중선택 시 체크박스 */} + {multiple && ( + + )} {displayColumns.map((col) => ( - ))} - - - ); + {item[col] || "-"} + + ))} + {!multiple && ( + + )} + + ); }) )} @@ -211,12 +250,18 @@ export function EntitySearchModal({ )} + {/* 다중선택 시 선택된 항목 수 표시 */} + {multiple && selectedValues.length > 0 && ( +
+ {selectedValues.length}개 항목 선택됨 +
+ )}
diff --git a/frontend/lib/registry/components/entity-search-input/config.ts b/frontend/lib/registry/components/entity-search-input/config.ts index e147736f..fab81c9f 100644 --- a/frontend/lib/registry/components/entity-search-input/config.ts +++ b/frontend/lib/registry/components/entity-search-input/config.ts @@ -11,6 +11,9 @@ export interface EntitySearchInputConfig { showAdditionalInfo?: boolean; additionalFields?: string[]; + // 다중 선택 설정 + multiple?: boolean; // 여러 항목 선택 가능 여부 (기본: false) + // 연쇄관계 설정 (cascading_relation 테이블과 연동) cascadingRelationCode?: string; // 연쇄관계 코드 (WAREHOUSE_LOCATION 등) cascadingRole?: "parent" | "child"; // 역할 (부모/자식) diff --git a/frontend/lib/registry/components/entity-search-input/index.ts b/frontend/lib/registry/components/entity-search-input/index.ts index 3f70d0fe..5d1bf7d4 100644 --- a/frontend/lib/registry/components/entity-search-input/index.ts +++ b/frontend/lib/registry/components/entity-search-input/index.ts @@ -42,6 +42,7 @@ export type { EntitySearchInputConfig } from "./config"; // 컴포넌트 내보내기 export { EntitySearchInputComponent } from "./EntitySearchInputComponent"; +export { EntitySearchInputWrapper } from "./EntitySearchInputWrapper"; export { EntitySearchInputRenderer } from "./EntitySearchInputRenderer"; export { EntitySearchModal } from "./EntitySearchModal"; export { useEntitySearch } from "./useEntitySearch"; diff --git a/frontend/lib/registry/components/entity-search-input/types.ts b/frontend/lib/registry/components/entity-search-input/types.ts index d29268b6..73abd9dd 100644 --- a/frontend/lib/registry/components/entity-search-input/types.ts +++ b/frontend/lib/registry/components/entity-search-input/types.ts @@ -19,6 +19,9 @@ export interface EntitySearchInputProps { placeholder?: string; disabled?: boolean; + // 다중선택 + multiple?: boolean; // 여러 항목 선택 가능 여부 (기본: false) + // 필터링 filterCondition?: Record; // 추가 WHERE 조건 companyCode?: string; // 멀티테넌시 diff --git a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx index ad7f5302..e674e1a9 100644 --- a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx @@ -830,7 +830,8 @@ export const SplitPanelLayoutComponent: React.FC size: 1, }); - const detail = result.items && result.items.length > 0 ? result.items[0] : null; + // result.data가 EntityJoinResponse의 실제 배열 필드 + const detail = result.data && result.data.length > 0 ? result.data[0] : null; setRightData(detail); } else if (relationshipType === "join") { // 조인 모드: 다른 테이블의 관련 데이터 (여러 개) @@ -899,16 +900,54 @@ export const SplitPanelLayoutComponent: React.FC return; } - // 🆕 복합키 지원 - if (keys && keys.length > 0 && leftTable) { + // 🆕 엔티티 관계 자동 감지 로직 개선 + // 1. 설정된 keys가 있으면 사용 + // 2. 없으면 테이블 타입관리에서 정의된 엔티티 관계를 자동으로 조회 + let effectiveKeys = keys || []; + + if (effectiveKeys.length === 0 && leftTable && rightTableName) { + // 엔티티 관계 자동 감지 + console.log("🔍 [분할패널] 엔티티 관계 자동 감지 시작:", leftTable, "->", rightTableName); + const { tableManagementApi } = await import("@/lib/api/tableManagement"); + const relResponse = await tableManagementApi.getTableEntityRelations(leftTable, rightTableName); + + if (relResponse.success && relResponse.data?.relations && relResponse.data.relations.length > 0) { + effectiveKeys = relResponse.data.relations.map((rel) => ({ + leftColumn: rel.leftColumn, + rightColumn: rel.rightColumn, + })); + console.log("✅ [분할패널] 자동 감지된 관계:", effectiveKeys); + } + } + + if (effectiveKeys.length > 0 && leftTable) { // 복합키: 여러 조건으로 필터링 const { entityJoinApi } = await import("@/lib/api/entityJoin"); - // 복합키 조건 생성 + // 복합키 조건 생성 (다중 값 지원) + // 🆕 항상 배열로 전달하여 백엔드에서 다중 값 컬럼 검색을 지원하도록 함 + // 예: 좌측에서 "2"를 선택해도, 우측에서 "2,3"을 가진 행이 표시되도록 const searchConditions: Record = {}; - keys.forEach((key) => { + effectiveKeys.forEach((key) => { if (key.leftColumn && key.rightColumn && leftItem[key.leftColumn] !== undefined) { - searchConditions[key.rightColumn] = leftItem[key.leftColumn]; + const leftValue = leftItem[key.leftColumn]; + // 다중 값 지원: 모든 값을 배열로 변환하여 다중 값 컬럼 검색 활성화 + if (typeof leftValue === "string") { + if (leftValue.includes(",")) { + // "2,3" 형태면 분리해서 배열로 + const values = leftValue.split(",").map((v: string) => v.trim()).filter((v: string) => v); + searchConditions[key.rightColumn] = values; + console.log("🔗 [분할패널] 다중 값 검색 (분리):", key.rightColumn, "=", values); + } else { + // 단일 값도 배열로 변환 (우측에 "2,3" 같은 다중 값이 있을 수 있으므로) + searchConditions[key.rightColumn] = [leftValue.trim()]; + console.log("🔗 [분할패널] 다중 값 검색 (단일):", key.rightColumn, "=", [leftValue.trim()]); + } + } else { + // 숫자나 다른 타입은 배열로 감싸기 + searchConditions[key.rightColumn] = [leftValue]; + console.log("🔗 [분할패널] 다중 값 검색 (기타):", key.rightColumn, "=", [leftValue]); + } } }); @@ -947,7 +986,7 @@ export const SplitPanelLayoutComponent: React.FC setRightData(filteredData); } else { - // 단일키 (하위 호환성) + // 단일키 (하위 호환성) 또는 관계를 찾지 못한 경우 const leftColumn = componentConfig.rightPanel?.relation?.leftColumn; const rightColumn = componentConfig.rightPanel?.relation?.foreignKey; @@ -965,6 +1004,9 @@ export const SplitPanelLayoutComponent: React.FC componentConfig.rightPanel?.deduplication, // 🆕 중복 제거 설정 전달 ); setRightData(joinedData || []); // 모든 관련 레코드 (배열) + } else { + console.warn("⚠️ [분할패널] 테이블 관계를 찾을 수 없습니다:", leftTable, "->", rightTableName); + setRightData([]); } } } @@ -1613,47 +1655,89 @@ export const SplitPanelLayoutComponent: React.FC try { console.log("🗑️ 데이터 삭제:", { tableName, primaryKey }); - // 🔍 중복 제거 설정 디버깅 - console.log("🔍 중복 제거 디버깅:", { + // 🔍 그룹 삭제 설정 확인 (editButton.groupByColumns 또는 deduplication) + const groupByColumns = componentConfig.rightPanel?.editButton?.groupByColumns || []; + const deduplication = componentConfig.rightPanel?.dataFilter?.deduplication; + + console.log("🔍 삭제 설정 디버깅:", { panel: deleteModalPanel, - dataFilter: componentConfig.rightPanel?.dataFilter, - deduplication: componentConfig.rightPanel?.dataFilter?.deduplication, - enabled: componentConfig.rightPanel?.dataFilter?.deduplication?.enabled, + groupByColumns, + deduplication, + deduplicationEnabled: deduplication?.enabled, }); let result; - // 🔧 중복 제거가 활성화된 경우, groupByColumn 기준으로 모든 관련 레코드 삭제 - if (deleteModalPanel === "right" && componentConfig.rightPanel?.dataFilter?.deduplication?.enabled) { - const deduplication = componentConfig.rightPanel.dataFilter.deduplication; - const groupByColumn = deduplication.groupByColumn; - - if (groupByColumn && deleteModalItem[groupByColumn]) { - const groupValue = deleteModalItem[groupByColumn]; - console.log(`🔗 중복 제거 활성화: ${groupByColumn} = ${groupValue} 기준으로 모든 레코드 삭제`); - - // groupByColumn 값으로 필터링하여 삭제 - const filterConditions: Record = { - [groupByColumn]: groupValue, - }; - - // 좌측 패널의 선택된 항목 정보도 포함 (customer_id 등) - if (selectedLeftItem && componentConfig.rightPanel?.mode === "join") { - const leftColumn = componentConfig.rightPanel.join.leftColumn; - const rightColumn = componentConfig.rightPanel.join.rightColumn; - filterConditions[rightColumn] = selectedLeftItem[leftColumn]; + // 🔧 우측 패널 삭제 시 그룹 삭제 조건 확인 + if (deleteModalPanel === "right") { + // 1. groupByColumns가 설정된 경우 (패널 설정에서 선택된 컬럼들) + if (groupByColumns.length > 0) { + const filterConditions: Record = {}; + + // 선택된 컬럼들의 값을 필터 조건으로 추가 + for (const col of groupByColumns) { + if (deleteModalItem[col] !== undefined && deleteModalItem[col] !== null) { + filterConditions[col] = deleteModalItem[col]; + } } - console.log("🗑️ 그룹 삭제 조건:", filterConditions); + // 🔒 안전장치: 조인 모드에서 좌측 패널의 키 값도 필터 조건에 포함 + // (다른 거래처의 같은 품목이 삭제되는 것을 방지) + if (selectedLeftItem && componentConfig.rightPanel?.mode === "join") { + const leftColumn = componentConfig.rightPanel.join?.leftColumn; + const rightColumn = componentConfig.rightPanel.join?.rightColumn; + if (leftColumn && rightColumn && selectedLeftItem[leftColumn]) { + // rightColumn이 filterConditions에 없으면 추가 + if (!filterConditions[rightColumn]) { + filterConditions[rightColumn] = selectedLeftItem[leftColumn]; + console.log(`🔒 안전장치: ${rightColumn} = ${selectedLeftItem[leftColumn]} 추가`); + } + } + } - // 그룹 삭제 API 호출 - result = await dataApi.deleteGroupRecords(tableName, filterConditions); - } else { - // 단일 레코드 삭제 + // 필터 조건이 있으면 그룹 삭제 + if (Object.keys(filterConditions).length > 0) { + console.log(`🔗 그룹 삭제 (groupByColumns): ${groupByColumns.join(", ")} 기준`); + console.log("🗑️ 그룹 삭제 조건:", filterConditions); + + result = await dataApi.deleteGroupRecords(tableName, filterConditions); + } else { + // 필터 조건이 없으면 단일 삭제 + console.log("⚠️ groupByColumns 값이 없어 단일 삭제로 전환"); + result = await dataApi.deleteRecord(tableName, primaryKey); + } + } + // 2. 중복 제거(deduplication)가 활성화된 경우 + else if (deduplication?.enabled && deduplication?.groupByColumn) { + const groupByColumn = deduplication.groupByColumn; + const groupValue = deleteModalItem[groupByColumn]; + + if (groupValue) { + console.log(`🔗 중복 제거 활성화: ${groupByColumn} = ${groupValue} 기준으로 모든 레코드 삭제`); + + const filterConditions: Record = { + [groupByColumn]: groupValue, + }; + + // 좌측 패널의 선택된 항목 정보도 포함 (customer_id 등) + if (selectedLeftItem && componentConfig.rightPanel?.mode === "join") { + const leftColumn = componentConfig.rightPanel.join.leftColumn; + const rightColumn = componentConfig.rightPanel.join.rightColumn; + filterConditions[rightColumn] = selectedLeftItem[leftColumn]; + } + + console.log("🗑️ 그룹 삭제 조건:", filterConditions); + result = await dataApi.deleteGroupRecords(tableName, filterConditions); + } else { + result = await dataApi.deleteRecord(tableName, primaryKey); + } + } + // 3. 그 외: 단일 레코드 삭제 + else { result = await dataApi.deleteRecord(tableName, primaryKey); } } else { - // 단일 레코드 삭제 + // 좌측 패널: 단일 레코드 삭제 result = await dataApi.deleteRecord(tableName, primaryKey); } diff --git a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel.tsx b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel.tsx index 5ca50ffb..832107c4 100644 --- a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel.tsx +++ b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel.tsx @@ -429,6 +429,71 @@ export const SplitPanelLayoutConfigPanel: React.FC + >([]); + const [isDetectingRelations, setIsDetectingRelations] = useState(false); + + useEffect(() => { + const detectRelations = async () => { + const leftTable = config.leftPanel?.tableName || screenTableName; + const rightTable = config.rightPanel?.tableName; + + // 조인 모드이고 양쪽 테이블이 모두 있을 때만 감지 + if (relationshipType !== "join" || !leftTable || !rightTable) { + setAutoDetectedRelations([]); + return; + } + + setIsDetectingRelations(true); + try { + const { tableManagementApi } = await import("@/lib/api/tableManagement"); + const response = await tableManagementApi.getTableEntityRelations(leftTable, rightTable); + + if (response.success && response.data?.relations) { + console.log("🔍 엔티티 관계 자동 감지:", response.data.relations); + setAutoDetectedRelations(response.data.relations); + + // 감지된 관계가 있고, 현재 설정된 키가 없으면 자동으로 첫 번째 관계를 설정 + const currentKeys = config.rightPanel?.relation?.keys || []; + if (response.data.relations.length > 0 && currentKeys.length === 0) { + // 첫 번째 관계만 자동 설정 (사용자가 추가로 설정 가능) + const firstRel = response.data.relations[0]; + console.log("✅ 첫 번째 엔티티 관계 자동 설정:", firstRel); + updateRightPanel({ + relation: { + ...config.rightPanel?.relation, + type: "join", + useMultipleKeys: true, + keys: [ + { + leftColumn: firstRel.leftColumn, + rightColumn: firstRel.rightColumn, + }, + ], + }, + }); + } + } + } catch (error) { + console.error("❌ 엔티티 관계 감지 실패:", error); + setAutoDetectedRelations([]); + } finally { + setIsDetectingRelations(false); + } + }; + + detectRelations(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [config.leftPanel?.tableName, config.rightPanel?.tableName, screenTableName, relationshipType]); + console.log("🔧 SplitPanelLayoutConfigPanel 렌더링"); console.log(" - config:", config); console.log(" - tables:", tables); @@ -1633,234 +1698,50 @@ export const SplitPanelLayoutConfigPanel: React.FC )} - {/* 컬럼 매핑 - 조인 모드에서만 표시 */} + {/* 엔티티 관계 자동 감지 (읽기 전용) - 조인 모드에서만 표시 */} {relationshipType !== "detail" && ( -
-
-
- -

좌측 테이블의 컬럼을 우측 테이블의 컬럼과 연결합니다

-
- +
+
+ +

테이블 타입관리에서 정의된 엔티티 관계입니다

-

복합키: 여러 컬럼으로 조인 (예: item_code + lot_number)

- - {/* 복합키가 설정된 경우 */} - {(config.rightPanel?.relation?.keys || []).length > 0 ? ( - <> - {(config.rightPanel?.relation?.keys || []).map((key, index) => ( -
-
- 조인 키 {index + 1} - -
-
-
- - -
-
- - -
-
+ {isDetectingRelations ? ( +
+
+ 관계 감지 중... +
+ ) : autoDetectedRelations.length > 0 ? ( +
+ {autoDetectedRelations.map((rel, index) => ( +
+ + {leftTableName}.{rel.leftColumn} + + + + {rightTableName}.{rel.rightColumn} + + + {rel.inputType === "entity" ? "엔티티" : "카테고리"} +
))} - +

+ 테이블 타입관리에서 엔티티/카테고리 설정을 변경하면 자동으로 적용됩니다 +

+
+ ) : config.rightPanel?.tableName ? ( +
+

감지된 엔티티 관계가 없습니다

+

+ 테이블 타입관리에서 엔티티 타입과 참조 테이블을 설정하세요 +

+
) : ( - /* 단일키 (하위 호환성) */ - <> -
- - - - - - - - - 컬럼을 찾을 수 없습니다. - - {leftTableColumns.map((column) => ( - { - updateRightPanel({ - relation: { ...config.rightPanel?.relation, leftColumn: value }, - }); - setLeftColumnOpen(false); - }} - > - - {column.columnName} - ({column.columnLabel || ""}) - - ))} - - - - -
- -
- -
- -
- - - - - - - - - 컬럼을 찾을 수 없습니다. - - {rightTableColumns.map((column) => ( - { - updateRightPanel({ - relation: { ...config.rightPanel?.relation, foreignKey: value }, - }); - setRightColumnOpen(false); - }} - > - - {column.columnName} - ({column.columnLabel || ""}) - - ))} - - - - -
- +
+

우측 테이블을 선택하면 관계를 자동 감지합니다

+
)}
)} diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx index 4ee024a4..3e043331 100644 --- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx +++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx @@ -333,6 +333,14 @@ export function UniversalFormModalComponent({ } } + // 🆕 테이블 섹션 데이터 병합 (품목 리스트 등) + for (const [key, value] of Object.entries(formData)) { + if (key.startsWith("_tableSection_") && Array.isArray(value)) { + event.detail.formData[key] = value; + console.log(`[UniversalFormModal] 테이블 섹션 병합: ${key}, ${value.length}개 항목`); + } + } + // 🆕 수정 모드: 원본 그룹 데이터 전달 (UPDATE/DELETE 추적용) if (originalGroupedData.length > 0) { event.detail.formData._originalGroupedData = originalGroupedData; @@ -355,15 +363,9 @@ export function UniversalFormModalComponent({ // 테이블 타입 섹션 찾기 const tableSection = config.sections.find((s) => s.type === "table"); if (!tableSection) { - // console.log("[UniversalFormModal] 테이블 섹션 없음 - _groupedData 무시"); return; } - // console.log("[UniversalFormModal] 수정 모드 - 테이블 섹션 초기화:", { - // sectionId: tableSection.id, - // itemCount: _groupedData.length, - // }); - // 원본 데이터 저장 (수정/삭제 추적용) setOriginalGroupedData(JSON.parse(JSON.stringify(_groupedData))); diff --git a/frontend/lib/registry/init.ts b/frontend/lib/registry/init.ts index c0082c2b..46f562b1 100644 --- a/frontend/lib/registry/init.ts +++ b/frontend/lib/registry/init.ts @@ -12,7 +12,7 @@ import { CheckboxWidget } from "@/components/screen/widgets/types/CheckboxWidget import { RadioWidget } from "@/components/screen/widgets/types/RadioWidget"; import { FileWidget } from "@/components/screen/widgets/types/FileWidget"; import { CodeWidget } from "@/components/screen/widgets/types/CodeWidget"; -import { EntityWidget } from "@/components/screen/widgets/types/EntityWidget"; +import { EntitySearchInputWrapper } from "@/lib/registry/components/entity-search-input/EntitySearchInputWrapper"; import { ButtonWidget } from "@/components/screen/widgets/types/ButtonWidget"; // 개별적으로 설정 패널들을 import @@ -352,7 +352,7 @@ export function initializeWebTypeRegistry() { name: "엔티티 선택", category: "input", description: "데이터베이스 엔티티 선택 필드", - component: EntityWidget, + component: EntitySearchInputWrapper, configPanel: EntityConfigPanel, defaultConfig: { entityType: "", diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index a1f53285..82cf68ff 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -724,11 +724,16 @@ export class ButtonActionExecutor { // originalData는 수정 버튼 클릭 시 editData로 전달되어 context.originalData로 설정됨 // 빈 객체 {}도 truthy이므로 Object.keys로 실제 데이터 유무 확인 const hasRealOriginalData = originalData && Object.keys(originalData).length > 0; - const isUpdate = hasRealOriginalData && !!primaryKeyValue; + + // 🆕 폴백 로직: originalData가 없어도 formData에 id가 있으면 UPDATE로 판단 + // 조건부 컨테이너 등에서 originalData 전달이 누락되는 경우를 처리 + const hasIdInFormData = formData.id !== undefined && formData.id !== null && formData.id !== ""; + const isUpdate = (hasRealOriginalData || hasIdInFormData) && !!primaryKeyValue; console.log("🔍 [handleSave] INSERT/UPDATE 판단:", { hasOriginalData: !!originalData, hasRealOriginalData, + hasIdInFormData, originalDataKeys: originalData ? Object.keys(originalData) : [], primaryKeyValue, isUpdate, @@ -741,18 +746,18 @@ export class ButtonActionExecutor { // UPDATE 처리 - 부분 업데이트 사용 (원본 데이터가 있는 경우) console.log("🔄 UPDATE 모드로 저장:", { primaryKeyValue, - formData, - originalData, hasOriginalData: !!originalData, + hasIdInFormData, + updateReason: hasRealOriginalData ? "originalData 존재" : "formData.id 존재 (폴백)", }); - if (originalData) { + if (hasRealOriginalData) { // 부분 업데이트: 변경된 필드만 업데이트 console.log("📝 부분 업데이트 실행 (변경된 필드만)"); saveResult = await DynamicFormApi.updateFormDataPartial(primaryKeyValue, originalData, formData, tableName); } else { - // 전체 업데이트 (기존 방식) - console.log("📝 전체 업데이트 실행 (모든 필드)"); + // 전체 업데이트 (originalData 없이 id로 UPDATE 판단된 경우) + console.log("📝 전체 업데이트 실행 (originalData 없음 - 폴백 모드)"); saveResult = await DynamicFormApi.updateFormData(primaryKeyValue, { tableName, data: formData, @@ -1883,37 +1888,45 @@ export class ButtonActionExecutor { const originalItem = originalGroupedData.find((orig) => orig.id === item.id); if (!originalItem) { - console.warn(`⚠️ [UPDATE] 원본 데이터 없음 - INSERT로 처리: id=${item.id}`); - // 원본이 없으면 신규로 처리 - const rowToSave = { ...commonFieldsData, ...item, ...userInfo }; - Object.keys(rowToSave).forEach((key) => { + // 🆕 폴백 로직: 원본 데이터가 없어도 id가 있으면 UPDATE 시도 + // originalGroupedData 전달이 누락된 경우를 처리 + console.warn(`⚠️ [UPDATE] 원본 데이터 없음 - id가 있으므로 UPDATE 시도 (폴백): id=${item.id}`); + + // ⚠️ 중요: commonFieldsData가 item보다 우선순위가 높아야 함 + // item에 있는 기존 값(예: manager_id=123)이 commonFieldsData의 새 값(manager_id=234)을 덮어쓰지 않도록 + // 순서: item(기존) → commonFieldsData(새로 입력) → userInfo(메타데이터) + const rowToUpdate = { ...item, ...commonFieldsData, ...userInfo }; + Object.keys(rowToUpdate).forEach((key) => { if (key.startsWith("_")) { - delete rowToSave[key]; + delete rowToUpdate[key]; } }); - delete rowToSave.id; // id 제거하여 INSERT - // 🆕 메인 레코드 ID 연결 (별도 테이블에 저장하는 경우) - if (targetTableName && mainRecordId && saveConfig.primaryKeyColumn) { - rowToSave[saveConfig.primaryKeyColumn] = mainRecordId; - } - - const saveResult = await DynamicFormApi.saveFormData({ - screenId: screenId!, + console.log("📝 [UPDATE 폴백] 저장할 데이터:", { + id: item.id, tableName: saveTableName, - data: rowToSave, + commonFieldsData, + itemFields: Object.keys(item).filter(k => !k.startsWith("_")), + rowToUpdate, }); - if (!saveResult.success) { - throw new Error(saveResult.message || "품목 저장 실패"); + // id를 유지하고 UPDATE 실행 + const updateResult = await DynamicFormApi.updateFormData(item.id, { + tableName: saveTableName, + data: rowToUpdate, + }); + + if (!updateResult.success) { + throw new Error(updateResult.message || "품목 수정 실패"); } - insertedCount++; + updatedCount++; continue; } // 변경 사항 확인 (공통 필드 포함) - const currentDataWithCommon = { ...commonFieldsData, ...item }; + // ⚠️ 중요: commonFieldsData가 item보다 우선순위가 높아야 함 (새로 입력한 값이 기존 값을 덮어씀) + const currentDataWithCommon = { ...item, ...commonFieldsData }; const hasChanges = this.checkForChanges(originalItem, currentDataWithCommon); if (hasChanges) { @@ -1938,13 +1951,14 @@ export class ButtonActionExecutor { } // 3️⃣ 삭제된 품목 DELETE (원본에는 있지만 현재에는 없는 항목) - const currentIds = new Set(currentItems.map((item) => item.id).filter(Boolean)); - const deletedItems = originalGroupedData.filter((orig) => orig.id && !currentIds.has(orig.id)); + // ⚠️ id 타입 통일: 문자열로 변환하여 비교 (숫자 vs 문자열 불일치 방지) + const currentIds = new Set(currentItems.map((item) => String(item.id)).filter(Boolean)); + const deletedItems = originalGroupedData.filter((orig) => orig.id && !currentIds.has(String(orig.id))); for (const deletedItem of deletedItems) { console.log(`🗑️ [DELETE] 품목 삭제: id=${deletedItem.id}, tableName=${saveTableName}`); - const deleteResult = await DynamicFormApi.deleteFormDataFromTable(saveTableName, deletedItem.id); + const deleteResult = await DynamicFormApi.deleteFormDataFromTable(deletedItem.id, saveTableName); if (!deleteResult.success) { throw new Error(deleteResult.message || "품목 삭제 실패"); @@ -4981,26 +4995,35 @@ export class ButtonActionExecutor { const { oldValue, newValue } = confirmed; - // 미리보기 표시 (옵션) + // 미리보기 표시 (값 기반 검색 - 모든 테이블의 모든 컬럼에서 검색) if (config.mergeShowPreview !== false) { const { apiClient } = await import("@/lib/api/client"); - const previewResponse = await apiClient.post("/code-merge/preview", { - columnName, + toast.loading("영향받는 데이터 검색 중...", { duration: Infinity }); + + const previewResponse = await apiClient.post("/code-merge/preview-by-value", { oldValue, }); + toast.dismiss(); + if (previewResponse.data.success) { const preview = previewResponse.data.data; const totalRows = preview.totalAffectedRows; + // 상세 정보 생성 + const detailList = preview.preview + .map((p: any) => ` - ${p.tableName}.${p.columnName}: ${p.affectedRows}건`) + .join("\n"); + const confirmMerge = confirm( - "⚠️ 코드 병합 확인\n\n" + + "코드 병합 확인\n\n" + `${oldValue} → ${newValue}\n\n` + "영향받는 데이터:\n" + - `- 테이블 수: ${preview.preview.length}개\n` + + `- 테이블/컬럼 수: ${preview.preview.length}개\n` + `- 총 행 수: ${totalRows}개\n\n` + - `데이터는 삭제되지 않고, "${columnName}" 컬럼 값만 변경됩니다.\n\n` + + (preview.preview.length <= 10 ? `상세:\n${detailList}\n\n` : "") + + "모든 테이블에서 해당 값이 변경됩니다.\n\n" + "계속하시겠습니까?", ); @@ -5010,13 +5033,12 @@ 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, + const response = await apiClient.post("/code-merge/merge-by-value", { oldValue, newValue, }); @@ -5025,9 +5047,17 @@ export class ButtonActionExecutor { if (response.data.success) { const data = response.data.data; + + // 변경된 테이블/컬럼 목록 생성 + const changedList = data.affectedData + .map((d: any) => `${d.tableName}.${d.columnName}: ${d.rowsUpdated}건`) + .join(", "); + toast.success( - "코드 병합 완료!\n" + `${data.affectedTables.length}개 테이블, ${data.totalRowsUpdated}개 행 업데이트`, + `코드 병합 완료! ${data.affectedData.length}개 테이블/컬럼, ${data.totalRowsUpdated}개 행 업데이트`, ); + + console.log("코드 병합 결과:", data.affectedData); // 화면 새로고침 context.onRefresh?.(); diff --git a/frontend/types/screen-management.ts b/frontend/types/screen-management.ts index 646632f5..89127c1a 100644 --- a/frontend/types/screen-management.ts +++ b/frontend/types/screen-management.ts @@ -365,6 +365,8 @@ export interface EntityTypeConfig { separator?: string; // 여러 컬럼 표시 시 구분자 (기본: ' - ') // UI 모드 uiMode?: "select" | "modal" | "combo" | "autocomplete"; // 기본: "combo" + // 다중 선택 + multiple?: boolean; // 여러 항목 선택 가능 여부 (기본: false) } /**
+ 선택 + ))} - - 선택 - + 선택 +
+

검색 중...

+ 검색 결과가 없습니다
+ handleSelect(item)} + onClick={(e) => e.stopPropagation()} + /> + - {item[col] || "-"} - - -
+ +