From bc557c4074dad9a122e83d988249cd9b7ffe6b5e Mon Sep 17 00:00:00 2001
From: kjs
Date: Mon, 17 Nov 2025 15:25:08 +0900
Subject: [PATCH] =?UTF-8?q?=EC=83=81=EC=84=B8=EC=9E=85=EB=A0=A5=20?=
=?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=ED=85=8C=EC=9D=B4?=
=?UTF-8?q?=EB=B8=94=20=EC=84=A0=ED=83=9D=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?=
=?UTF-8?q?=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../src/services/screenManagementService.ts | 124 +++++-
.../src/services/tableManagementService.ts | 22 +
frontend/components/common/ScreenModal.tsx | 23 +-
.../screen/InteractiveScreenViewerDynamic.tsx | 1 +
.../config-panels/ButtonConfigPanel.tsx | 11 +-
.../button-primary/ButtonPrimaryComponent.tsx | 6 +
.../ButtonPrimaryConfigPanel.tsx | 77 ----
.../components/button-primary/index.ts | 3 +-
.../SelectedItemsDetailInputComponent.tsx | 285 ++++++++++---
.../SelectedItemsDetailInputConfigPanel.tsx | 213 +++++++---
.../selected-items-detail-input/types.ts | 17 +-
frontend/lib/utils/buttonActions.ts | 32 +-
.../lib/utils/getComponentConfigPanel.tsx | 136 ++++++-
선택항목_상세입력_완전_자동화_가이드.md | 375 ++++++++++++++++++
14 files changed, 1095 insertions(+), 230 deletions(-)
delete mode 100644 frontend/lib/registry/components/button-primary/ButtonPrimaryConfigPanel.tsx
create mode 100644 선택항목_상세입력_완전_자동화_가이드.md
diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts
index e7b6e806..c2036cbd 100644
--- a/backend-node/src/services/screenManagementService.ts
+++ b/backend-node/src/services/screenManagementService.ts
@@ -1068,43 +1068,131 @@ export class ScreenManagementService {
[tableName]
);
- // column_labels 테이블에서 입력타입 정보 조회 (있는 경우)
- const webTypeInfo = await query<{
+ // 🆕 table_type_columns에서 입력타입 정보 조회 (회사별만, fallback 없음)
+ // 멀티테넌시: 각 회사는 자신의 설정만 사용, 최고관리자 설정은 별도 관리
+ console.log(`🔍 [getTableColumns] 시작: table=${tableName}, company=${companyCode}`);
+
+ const typeInfo = await query<{
column_name: string;
input_type: string | null;
- column_label: string | null;
detail_settings: any;
}>(
- `SELECT column_name, input_type, column_label, detail_settings
+ `SELECT column_name, input_type, detail_settings
+ FROM table_type_columns
+ WHERE table_name = $1
+ AND company_code = $2
+ ORDER BY id DESC`, // 최신 레코드 우선 (중복 방지)
+ [tableName, companyCode]
+ );
+
+ console.log(`📊 [getTableColumns] typeInfo 조회 완료: ${typeInfo.length}개`);
+ const currencyCodeType = typeInfo.find(t => t.column_name === 'currency_code');
+ if (currencyCodeType) {
+ console.log(`💰 [getTableColumns] currency_code 발견:`, currencyCodeType);
+ } else {
+ console.log(`⚠️ [getTableColumns] currency_code 없음`);
+ }
+
+ // column_labels 테이블에서 라벨 정보 조회 (우선순위 2)
+ const labelInfo = await query<{
+ column_name: string;
+ column_label: string | null;
+ }>(
+ `SELECT column_name, column_label
FROM column_labels
WHERE table_name = $1`,
[tableName]
);
- // 컬럼 정보 매핑
- return columns.map((column: any) => {
- const webTypeData = webTypeInfo.find(
- (wt) => wt.column_name === column.column_name
- );
+ // 🆕 category_column_mapping에서 코드 카테고리 정보 조회
+ const categoryInfo = await query<{
+ physical_column_name: string;
+ logical_column_name: string;
+ }>(
+ `SELECT physical_column_name, logical_column_name
+ FROM category_column_mapping
+ WHERE table_name = $1
+ AND company_code = $2`,
+ [tableName, companyCode]
+ );
- return {
+ // 컬럼 정보 매핑
+ const columnMap = new Map();
+
+ // 먼저 information_schema에서 가져온 컬럼들로 기본 맵 생성
+ columns.forEach((column: any) => {
+ columnMap.set(column.column_name, {
tableName: tableName,
columnName: column.column_name,
- columnLabel:
- webTypeData?.column_label ||
- this.getColumnLabel(column.column_name),
dataType: column.data_type,
- webType:
- (webTypeData?.input_type as WebType) ||
- this.inferWebType(column.data_type),
isNullable: column.is_nullable,
columnDefault: column.column_default || undefined,
characterMaximumLength: column.character_maximum_length || undefined,
numericPrecision: column.numeric_precision || undefined,
numericScale: column.numeric_scale || undefined,
- detailSettings: webTypeData?.detail_settings || undefined,
- };
+ });
});
+
+ console.log(`🗺️ [getTableColumns] 기본 columnMap 생성: ${columnMap.size}개`);
+
+ // table_type_columns에서 input_type 추가 (중복 시 최신 것만)
+ const addedTypes = new Set();
+ typeInfo.forEach((type) => {
+ const colName = type.column_name;
+ if (!addedTypes.has(colName) && columnMap.has(colName)) {
+ const col = columnMap.get(colName);
+ col.inputType = type.input_type;
+ col.webType = type.input_type; // webType도 동일하게 설정
+ col.detailSettings = type.detail_settings;
+ addedTypes.add(colName);
+
+ if (colName === 'currency_code') {
+ console.log(`✅ [getTableColumns] currency_code inputType 설정됨: ${type.input_type}`);
+ }
+ }
+ });
+
+ console.log(`🏷️ [getTableColumns] inputType 추가 완료: ${addedTypes.size}개`);
+
+ // column_labels에서 라벨 추가
+ labelInfo.forEach((label) => {
+ const col = columnMap.get(label.column_name);
+ if (col) {
+ col.columnLabel = label.column_label || this.getColumnLabel(label.column_name);
+ }
+ });
+
+ // category_column_mapping에서 코드 카테고리 추가
+ categoryInfo.forEach((cat) => {
+ const col = columnMap.get(cat.physical_column_name);
+ if (col) {
+ col.codeCategory = cat.logical_column_name;
+ }
+ });
+
+ // 최종 결과 생성
+ const result = Array.from(columnMap.values()).map((col) => ({
+ ...col,
+ // 기본값 설정
+ columnLabel: col.columnLabel || this.getColumnLabel(col.columnName),
+ inputType: col.inputType || this.inferWebType(col.dataType),
+ webType: col.webType || this.inferWebType(col.dataType),
+ detailSettings: col.detailSettings || undefined,
+ codeCategory: col.codeCategory || undefined,
+ }));
+
+ // 디버깅: currency_code의 최종 inputType 확인
+ const currencyCodeResult = result.find(r => r.columnName === 'currency_code');
+ if (currencyCodeResult) {
+ console.log(`🎯 [getTableColumns] 최종 currency_code:`, {
+ inputType: currencyCodeResult.inputType,
+ webType: currencyCodeResult.webType,
+ dataType: currencyCodeResult.dataType
+ });
+ }
+
+ console.log(`✅ [getTableColumns] 반환: ${result.length}개 컬럼`);
+ return result;
} catch (error) {
console.error("테이블 컬럼 조회 실패:", error);
throw new Error("테이블 컬럼 정보를 조회할 수 없습니다.");
diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts
index 8ce3c9d4..e9104bd6 100644
--- a/backend-node/src/services/tableManagementService.ts
+++ b/backend-node/src/services/tableManagementService.ts
@@ -165,6 +165,10 @@ export class TableManagementService {
const offset = (page - 1) * size;
// 🔥 company_code가 있으면 table_type_columns 조인하여 회사별 inputType 가져오기
+ console.log(
+ `🔍 [getColumnList] 시작: table=${tableName}, company=${companyCode}`
+ );
+
const rawColumns = companyCode
? await query(
`SELECT
@@ -174,6 +178,8 @@ export class TableManagementService {
c.data_type as "dbType",
COALESCE(cl.input_type, 'text') as "webType",
COALESCE(ttc.input_type, cl.input_type, 'direct') as "inputType",
+ ttc.input_type as "ttc_input_type",
+ cl.input_type as "cl_input_type",
COALESCE(ttc.detail_settings::text, cl.detail_settings, '') as "detailSettings",
COALESCE(cl.description, '') as "description",
c.is_nullable as "isNullable",
@@ -250,6 +256,22 @@ export class TableManagementService {
[tableName, size, offset]
);
+ // 디버깅: currency_code 확인
+ const currencyCol = rawColumns.find(
+ (col: any) => col.columnName === "currency_code"
+ );
+ if (currencyCol) {
+ console.log(`🎯 [getColumnList] currency_code 원본 쿼리 결과:`, {
+ columnName: currencyCol.columnName,
+ inputType: currencyCol.inputType,
+ ttc_input_type: currencyCol.ttc_input_type,
+ cl_input_type: currencyCol.cl_input_type,
+ webType: currencyCol.webType,
+ });
+ } else {
+ console.log(`⚠️ [getColumnList] currency_code가 rawColumns에 없음`);
+ }
+
// 🆕 category_column_mapping 조회
const tableExistsResult = await query(
`SELECT EXISTS (
diff --git a/frontend/components/common/ScreenModal.tsx b/frontend/components/common/ScreenModal.tsx
index 4bbb913e..3cb55fc1 100644
--- a/frontend/components/common/ScreenModal.tsx
+++ b/frontend/components/common/ScreenModal.tsx
@@ -119,7 +119,19 @@ export const ScreenModal: React.FC = ({ className }) => {
// 전역 모달 이벤트 리스너
useEffect(() => {
const handleOpenModal = (event: CustomEvent) => {
- const { screenId, title, description, size } = event.detail;
+ const { screenId, title, description, size, urlParams } = event.detail;
+
+ // 🆕 URL 파라미터가 있으면 현재 URL에 추가
+ if (urlParams && typeof window !== "undefined") {
+ const currentUrl = new URL(window.location.href);
+ Object.entries(urlParams).forEach(([key, value]) => {
+ currentUrl.searchParams.set(key, String(value));
+ });
+ // pushState로 URL 변경 (페이지 새로고침 없이)
+ window.history.pushState({}, "", currentUrl.toString());
+ console.log("✅ URL 파라미터 추가:", urlParams);
+ }
+
setModalState({
isOpen: true,
screenId,
@@ -130,6 +142,15 @@ export const ScreenModal: React.FC = ({ className }) => {
};
const handleCloseModal = () => {
+ // 🆕 URL 파라미터 제거
+ if (typeof window !== "undefined") {
+ const currentUrl = new URL(window.location.href);
+ // dataSourceId 파라미터 제거
+ currentUrl.searchParams.delete("dataSourceId");
+ window.history.pushState({}, "", currentUrl.toString());
+ console.log("🧹 URL 파라미터 제거");
+ }
+
setModalState({
isOpen: false,
screenId: null,
diff --git a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx
index a2ab1522..ba27c94e 100644
--- a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx
+++ b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx
@@ -316,6 +316,7 @@ export const InteractiveScreenViewerDynamic: React.FC {
console.log("🔍 테이블에서 선택된 행 데이터:", selectedData);
diff --git a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx
index 7bbc4dbe..5288108f 100644
--- a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx
+++ b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx
@@ -419,17 +419,22 @@ export const ButtonConfigPanel: React.FC = ({
-
+
{
onUpdateProperty("componentConfig.action.dataSourceId", e.target.value);
}}
/>
+
+ ✨ 비워두면 같은 화면의 TableList를 자동으로 감지합니다
+
- TableList에서 데이터를 저장한 ID와 동일해야 합니다 (보통 테이블명)
+ 직접 지정하려면 테이블명을 입력하세요 (예: item_info)
diff --git a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx
index 183581ca..1ae66e0b 100644
--- a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx
+++ b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx
@@ -52,6 +52,9 @@ export interface ButtonPrimaryComponentProps extends ComponentRendererProps {
// 플로우 선택된 데이터 정보 (플로우 위젯 선택 액션용)
flowSelectedData?: any[];
flowSelectedStepId?: number | null;
+
+ // 🆕 같은 화면의 모든 컴포넌트 (TableList 자동 감지용)
+ allComponents?: any[];
}
/**
@@ -88,6 +91,7 @@ export const ButtonPrimaryComponent: React.FC = ({
selectedRowsData,
flowSelectedData,
flowSelectedStepId,
+ allComponents, // 🆕 같은 화면의 모든 컴포넌트
...props
}) => {
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인
@@ -409,6 +413,8 @@ export const ButtonPrimaryComponent: React.FC = ({
sortOrder, // 🆕 정렬 방향
columnOrder, // 🆕 컬럼 순서
tableDisplayData, // 🆕 화면에 표시된 데이터
+ // 🆕 같은 화면의 모든 컴포넌트 (TableList 자동 감지용)
+ allComponents,
// 플로우 선택된 데이터 정보 추가
flowSelectedData,
flowSelectedStepId,
diff --git a/frontend/lib/registry/components/button-primary/ButtonPrimaryConfigPanel.tsx b/frontend/lib/registry/components/button-primary/ButtonPrimaryConfigPanel.tsx
deleted file mode 100644
index bbe1faed..00000000
--- a/frontend/lib/registry/components/button-primary/ButtonPrimaryConfigPanel.tsx
+++ /dev/null
@@ -1,77 +0,0 @@
-"use client";
-
-import React from "react";
-import { Input } from "@/components/ui/input";
-import { Label } from "@/components/ui/label";
-import { Checkbox } from "@/components/ui/checkbox";
-import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
-import { ButtonPrimaryConfig } from "./types";
-
-export interface ButtonPrimaryConfigPanelProps {
- config: ButtonPrimaryConfig;
- onChange: (config: Partial) => void;
-}
-
-/**
- * ButtonPrimary 설정 패널
- * 컴포넌트의 설정값들을 편집할 수 있는 UI 제공
- */
-export const ButtonPrimaryConfigPanel: React.FC = ({ config, onChange }) => {
- const handleChange = (key: keyof ButtonPrimaryConfig, value: any) => {
- onChange({ [key]: value });
- };
-
- return (
-
-
button-primary 설정
-
- {/* 버튼 관련 설정 */}
-
-
- handleChange("text", e.target.value)} />
-
-
-
-
-
-
-
- {/* 공통 설정 */}
-
-
- handleChange("disabled", checked)}
- />
-
-
-
-
- handleChange("required", checked)}
- />
-
-
-
-
- handleChange("readonly", checked)}
- />
-
-
- );
-};
diff --git a/frontend/lib/registry/components/button-primary/index.ts b/frontend/lib/registry/components/button-primary/index.ts
index f9e19a14..7710c338 100644
--- a/frontend/lib/registry/components/button-primary/index.ts
+++ b/frontend/lib/registry/components/button-primary/index.ts
@@ -5,7 +5,6 @@ import { createComponentDefinition } from "../../utils/createComponentDefinition
import { ComponentCategory } from "@/types/component";
import type { WebType } from "@/types/screen";
import { ButtonPrimaryWrapper } from "./ButtonPrimaryComponent";
-import { ButtonPrimaryConfigPanel } from "./ButtonPrimaryConfigPanel";
import { ButtonPrimaryConfig } from "./types";
/**
@@ -31,7 +30,7 @@ export const ButtonPrimaryDefinition = createComponentDefinition({
},
},
defaultSize: { width: 120, height: 40 },
- configPanel: ButtonPrimaryConfigPanel,
+ configPanel: undefined, // 상세 설정 패널(ButtonConfigPanel)이 대신 사용됨
icon: "MousePointer",
tags: ["버튼", "액션", "클릭"],
version: "1.0.0",
diff --git a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx
index b4f2e38d..23238eb2 100644
--- a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx
+++ b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx
@@ -1,6 +1,7 @@
"use client";
import React, { useState, useEffect, useMemo, useCallback } from "react";
+import { useSearchParams } from "next/navigation";
import { ComponentRendererProps } from "@/types/component";
import { SelectedItemsDetailInputConfig, AdditionalFieldDefinition } from "./types";
import { useModalDataStore, ModalDataItem } from "@/stores/modalDataStore";
@@ -12,6 +13,7 @@ import { Button } from "@/components/ui/button";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { X } from "lucide-react";
+import { commonCodeApi } from "@/lib/api/commonCode";
import { cn } from "@/lib/utils";
export interface SelectedItemsDetailInputComponentProps extends ComponentRendererProps {
@@ -38,6 +40,10 @@ export const SelectedItemsDetailInputComponent: React.FC {
+ // 🆕 URL 파라미터에서 dataSourceId 읽기
+ const searchParams = useSearchParams();
+ const urlDataSourceId = searchParams?.get("dataSourceId") || undefined;
+
// 컴포넌트 설정
const componentConfig = useMemo(() => ({
dataSourceId: component.id || "default",
@@ -52,13 +58,22 @@ export const SelectedItemsDetailInputComponent: React.FC 컴포넌트 설정 > component.id
const dataSourceId = useMemo(
- () => componentConfig.dataSourceId || component.id || "default",
- [componentConfig.dataSourceId, component.id]
+ () => urlDataSourceId || componentConfig.dataSourceId || component.id || "default",
+ [urlDataSourceId, componentConfig.dataSourceId, component.id]
);
+ // 디버깅 로그
+ useEffect(() => {
+ console.log("📍 [SelectedItemsDetailInput] dataSourceId 결정:", {
+ urlDataSourceId,
+ configDataSourceId: componentConfig.dataSourceId,
+ componentId: component.id,
+ finalDataSourceId: dataSourceId,
+ });
+ }, [urlDataSourceId, componentConfig.dataSourceId, component.id, dataSourceId]);
+
// 전체 레지스트리를 가져와서 컴포넌트 내부에서 필터링 (캐싱 문제 회피)
const dataRegistry = useModalDataStore((state) => state.dataRegistry);
const modalData = useMemo(
@@ -70,6 +85,79 @@ export const SelectedItemsDetailInputComponent: React.FC([]);
+
+ // 🆕 코드 카테고리별 옵션 캐싱
+ const [codeOptions, setCodeOptions] = useState>>({});
+
+ // 🆕 필드에 codeCategory가 있으면 자동으로 옵션 로드
+ useEffect(() => {
+ const loadCodeOptions = async () => {
+ // 🆕 code/category 타입 필드 + codeCategory가 있는 필드 모두 처리
+ const codeFields = componentConfig.additionalFields?.filter(
+ (field) => field.inputType === "code" || field.inputType === "category"
+ );
+
+ if (!codeFields || codeFields.length === 0) return;
+
+ const newOptions: Record> = { ...codeOptions };
+
+ // 🆕 대상 테이블의 컬럼 메타데이터에서 codeCategory 가져오기
+ const targetTable = componentConfig.targetTable;
+ let targetTableColumns: any[] = [];
+
+ if (targetTable) {
+ try {
+ const { tableTypeApi } = await import("@/lib/api/screen");
+ const columnsResponse = await tableTypeApi.getColumns(targetTable);
+ targetTableColumns = columnsResponse || [];
+ } catch (error) {
+ console.error("❌ 대상 테이블 컬럼 조회 실패:", error);
+ }
+ }
+
+ for (const field of codeFields) {
+ // 이미 codeCategory가 있으면 사용
+ let codeCategory = field.codeCategory;
+
+ // 🆕 codeCategory가 없으면 대상 테이블 컬럼에서 찾기
+ if (!codeCategory && targetTableColumns.length > 0) {
+ const columnMeta = targetTableColumns.find(
+ (col: any) => (col.columnName || col.column_name) === field.name
+ );
+ if (columnMeta) {
+ codeCategory = columnMeta.codeCategory || columnMeta.code_category;
+ console.log(`🔍 필드 "${field.name}"의 codeCategory를 메타데이터에서 찾음:`, codeCategory);
+ }
+ }
+
+ if (!codeCategory) {
+ console.warn(`⚠️ 필드 "${field.name}"의 codeCategory를 찾을 수 없습니다`);
+ continue;
+ }
+
+ // 이미 로드된 옵션이면 스킵
+ if (newOptions[codeCategory]) continue;
+
+ try {
+ const response = await commonCodeApi.options.getOptions(codeCategory);
+ if (response.success && response.data) {
+ newOptions[codeCategory] = response.data.map((opt) => ({
+ label: opt.label,
+ value: opt.value,
+ }));
+ console.log(`✅ 코드 옵션 로드 완료: ${codeCategory}`, newOptions[codeCategory]);
+ }
+ } catch (error) {
+ console.error(`❌ 코드 옵션 로드 실패: ${codeCategory}`, error);
+ }
+ }
+
+ setCodeOptions(newOptions);
+ };
+
+ loadCodeOptions();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [componentConfig.additionalFields, componentConfig.targetTable]);
// 모달 데이터가 변경되면 로컬 상태 업데이트
useEffect(() => {
@@ -151,7 +239,130 @@ export const SelectedItemsDetailInputComponent: React.FC handleFieldChange(item.id, field.name, e.target.value)}
+ maxLength={field.validation?.maxLength}
+ className="h-8 text-xs sm:h-10 sm:text-sm"
+ />
+ );
+
+ case "number":
+ case "int":
+ case "integer":
+ case "bigint":
+ case "decimal":
+ case "numeric":
+ return (
+ handleFieldChange(item.id, field.name, e.target.value)}
+ min={field.validation?.min}
+ max={field.validation?.max}
+ className="h-8 text-xs sm:h-10 sm:text-sm"
+ />
+ );
+
+ case "date":
+ case "timestamp":
+ case "datetime":
+ return (
+ handleFieldChange(item.id, field.name, e.target.value)}
+ className="h-8 text-xs sm:h-10 sm:text-sm"
+ />
+ );
+
+ case "checkbox":
+ case "boolean":
+ case "bool":
+ return (
+ handleFieldChange(item.id, field.name, checked)}
+ disabled={componentConfig.disabled || componentConfig.readonly}
+ />
+ );
+
+ case "textarea":
+ return (
+