feature/screen-management #210

Merged
kjs merged 3 commits from feature/screen-management into main 2025-11-17 15:25:35 +09:00
14 changed files with 1095 additions and 230 deletions

View File

@ -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<string, any>();
// 먼저 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<string>();
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("테이블 컬럼 정보를 조회할 수 없습니다.");

View File

@ -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<any>(
`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<any>(
`SELECT EXISTS (

View File

@ -119,7 +119,19 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ 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<ScreenModalProps> = ({ 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,

View File

@ -316,6 +316,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
userId={user?.userId} // ✅ 사용자 ID 전달
userName={user?.userName} // ✅ 사용자 이름 전달
companyCode={user?.companyCode} // ✅ 회사 코드 전달
allComponents={allComponents} // 🆕 같은 화면의 모든 컴포넌트 전달 (TableList 자동 감지용)
selectedRowsData={selectedRowsData}
onSelectedRowsChange={(selectedRows, selectedData) => {
console.log("🔍 테이블에서 선택된 행 데이터:", selectedData);

View File

@ -419,17 +419,22 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
</p>
<div>
<Label htmlFor="data-source-id"> ID</Label>
<Label htmlFor="data-source-id">
ID <span className="text-primary">()</span>
</Label>
<Input
id="data-source-id"
placeholder="예: item_info (테이블명과 동일하게 입력)"
placeholder="비워두면 자동으로 감지됩니다"
value={component.componentConfig?.action?.dataSourceId || ""}
onChange={(e) => {
onUpdateProperty("componentConfig.action.dataSourceId", e.target.value);
}}
/>
<p className="mt-1 text-xs text-primary font-medium">
TableList를
</p>
<p className="mt-1 text-xs text-muted-foreground">
TableList에서 ID와 ( )
(: item_info)
</p>
</div>

View File

@ -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<ButtonPrimaryComponentProps> = ({
selectedRowsData,
flowSelectedData,
flowSelectedStepId,
allComponents, // 🆕 같은 화면의 모든 컴포넌트
...props
}) => {
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인
@ -409,6 +413,8 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
sortOrder, // 🆕 정렬 방향
columnOrder, // 🆕 컬럼 순서
tableDisplayData, // 🆕 화면에 표시된 데이터
// 🆕 같은 화면의 모든 컴포넌트 (TableList 자동 감지용)
allComponents,
// 플로우 선택된 데이터 정보 추가
flowSelectedData,
flowSelectedStepId,

View File

@ -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<ButtonPrimaryConfig>) => void;
}
/**
* ButtonPrimary
* UI
*/
export const ButtonPrimaryConfigPanel: React.FC<ButtonPrimaryConfigPanelProps> = ({ config, onChange }) => {
const handleChange = (key: keyof ButtonPrimaryConfig, value: any) => {
onChange({ [key]: value });
};
return (
<div className="space-y-4">
<div className="text-sm font-medium">button-primary </div>
{/* 버튼 관련 설정 */}
<div className="space-y-2">
<Label htmlFor="text"> </Label>
<Input id="text" value={config.text || ""} onChange={(e) => handleChange("text", e.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="actionType"> </Label>
<Select value={config.actionType || "button"} onValueChange={(value) => handleChange("actionType", value)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="button">Button</SelectItem>
<SelectItem value="submit">Submit</SelectItem>
<SelectItem value="reset">Reset</SelectItem>
</SelectContent>
</Select>
</div>
{/* 공통 설정 */}
<div className="space-y-2">
<Label htmlFor="disabled"></Label>
<Checkbox
id="disabled"
checked={config.disabled || false}
onCheckedChange={(checked) => handleChange("disabled", checked)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="required"> </Label>
<Checkbox
id="required"
checked={config.required || false}
onCheckedChange={(checked) => handleChange("required", checked)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="readonly"> </Label>
<Checkbox
id="readonly"
checked={config.readonly || false}
onCheckedChange={(checked) => handleChange("readonly", checked)}
/>
</div>
</div>
);
};

View File

@ -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",

View File

@ -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<SelectedItemsDetailInpu
screenId,
...props
}) => {
// 🆕 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<SelectedItemsDetailInpu
...component.config,
} as SelectedItemsDetailInputConfig), [config, component.config, component.id]);
// 모달 데이터 스토어에서 데이터 가져오기
// dataSourceId를 안정적으로 유지
// 🆕 dataSourceId 우선순위: URL 파라미터 > 컴포넌트 설정 > 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<SelectedItemsDetailInpu
// 로컬 상태로 데이터 관리
const [items, setItems] = useState<ModalDataItem[]>([]);
// 🆕 코드 카테고리별 옵션 캐싱
const [codeOptions, setCodeOptions] = useState<Record<string, Array<{ label: string; value: string }>>>({});
// 🆕 필드에 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<string, Array<{ label: string; value: string }>> = { ...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<SelectedItemsDetailInpu
required: field.required,
};
switch (field.type) {
// 🆕 inputType이 있으면 우선 사용, 없으면 field.type 사용
const renderType = field.inputType || field.type;
// 🆕 inputType에 따라 적절한 컴포넌트 렌더링
switch (renderType) {
// 기본 타입들
case "text":
case "varchar":
case "char":
return (
<Input
{...commonProps}
type="text"
onChange={(e) => 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 (
<Input
{...commonProps}
type="number"
onChange={(e) => 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 (
<Input
{...commonProps}
type="date"
onChange={(e) => 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 (
<Checkbox
checked={value === true || value === "true"}
onCheckedChange={(checked) => handleFieldChange(item.id, field.name, checked)}
disabled={componentConfig.disabled || componentConfig.readonly}
/>
);
case "textarea":
return (
<Textarea
{...commonProps}
onChange={(e) => handleFieldChange(item.id, field.name, e.target.value)}
rows={2}
className="resize-none text-xs sm:text-sm"
/>
);
// 🆕 추가 inputType들
case "code":
case "category":
// 🆕 codeCategory를 field.codeCategory 또는 codeOptions에서 찾기
let categoryOptions = field.options; // 기본값
if (field.codeCategory && codeOptions[field.codeCategory]) {
categoryOptions = codeOptions[field.codeCategory];
} else {
// codeCategory가 없으면 모든 codeOptions에서 이 필드에 맞는 옵션 찾기
const matchedCategory = Object.keys(codeOptions).find((cat) => {
// 필드명과 매칭되는 카테고리 찾기 (예: currency_code → CURRENCY)
return field.name.toLowerCase().includes(cat.toLowerCase().replace('_', ''));
});
if (matchedCategory) {
categoryOptions = codeOptions[matchedCategory];
}
}
return (
<Select
value={value || ""}
onValueChange={(val) => handleFieldChange(item.id, field.name, val)}
disabled={componentConfig.disabled || componentConfig.readonly}
>
<SelectTrigger className="h-8 w-full text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder={field.placeholder || "선택하세요"} />
</SelectTrigger>
<SelectContent>
{categoryOptions && categoryOptions.length > 0 ? (
categoryOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))
) : (
<SelectItem value="" disabled>
...
</SelectItem>
)}
</SelectContent>
</Select>
);
case "entity":
// TODO: EntitySelect 컴포넌트 사용
return (
<Input
{...commonProps}
type="text"
onChange={(e) => handleFieldChange(item.id, field.name, e.target.value)}
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
);
case "select":
return (
<Select
@ -172,48 +383,8 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
</Select>
);
case "textarea":
return (
<Textarea
{...commonProps}
onChange={(e) => handleFieldChange(item.id, field.name, e.target.value)}
rows={2}
className="resize-none text-xs sm:text-sm"
/>
);
case "date":
return (
<Input
{...commonProps}
type="date"
onChange={(e) => handleFieldChange(item.id, field.name, e.target.value)}
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
);
case "number":
return (
<Input
{...commonProps}
type="number"
onChange={(e) => 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 "checkbox":
return (
<Checkbox
checked={value === true || value === "true"}
onCheckedChange={(checked) => handleFieldChange(item.id, field.name, checked)}
disabled={componentConfig.disabled || componentConfig.readonly}
/>
);
default: // text
// 기본값: 텍스트 입력
default:
return (
<Input
{...commonProps}
@ -254,9 +425,9 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
)}
{/* 원본 데이터 컬럼 */}
{componentConfig.displayColumns?.map((colName) => (
<TableHead key={colName} className="h-12 px-4 py-3 text-xs font-semibold sm:text-sm">
{colName}
{componentConfig.displayColumns?.map((col) => (
<TableHead key={col.name} className="h-12 px-4 py-3 text-xs font-semibold sm:text-sm">
{col.label || col.name}
</TableHead>
))}
@ -284,9 +455,9 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
)}
{/* 원본 데이터 표시 */}
{componentConfig.displayColumns?.map((colName) => (
<TableCell key={colName} className="h-14 px-4 py-3 text-xs sm:text-sm">
{item.originalData[colName] || "-"}
{componentConfig.displayColumns?.map((col) => (
<TableCell key={col.name} className="h-14 px-4 py-3 text-xs sm:text-sm">
{item.originalData[col.name] || "-"}
</TableCell>
))}
@ -349,10 +520,10 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
<CardContent className="space-y-3">
{/* 원본 데이터 표시 */}
{componentConfig.displayColumns?.map((colName) => (
<div key={colName} className="flex items-center justify-between text-xs sm:text-sm">
<span className="font-medium text-muted-foreground">{colName}:</span>
<span>{item.originalData[colName] || "-"}</span>
{componentConfig.displayColumns?.map((col) => (
<div key={col.name} className="flex items-center justify-between text-xs sm:text-sm">
<span className="font-medium text-muted-foreground">{col.label || col.name}:</span>
<span>{item.originalData[col.name] || "-"}</span>
</div>
))}

View File

@ -17,9 +17,12 @@ import { cn } from "@/lib/utils";
export interface SelectedItemsDetailInputConfigPanelProps {
config: SelectedItemsDetailInputConfig;
onChange: (config: Partial<SelectedItemsDetailInputConfig>) => void;
tableColumns?: Array<{ columnName: string; columnLabel?: string; dataType?: string }>;
sourceTableColumns?: Array<{ columnName: string; columnLabel?: string; dataType?: string }>; // 🆕 원본 테이블 컬럼
targetTableColumns?: Array<{ columnName: string; columnLabel?: string; dataType?: string }>; // 🆕 대상 테이블 컬럼
allTables?: Array<{ tableName: string; displayName?: string }>;
onTableChange?: (tableName: string) => void;
screenTableName?: string; // 🆕 현재 화면의 테이블명 (자동 설정용)
onSourceTableChange?: (tableName: string) => void; // 🆕 원본 테이블 변경 콜백
onTargetTableChange?: (tableName: string) => void; // 🆕 대상 테이블 변경 콜백 (기존 onTableChange 대체)
}
/**
@ -29,13 +32,37 @@ export interface SelectedItemsDetailInputConfigPanelProps {
export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailInputConfigPanelProps> = ({
config,
onChange,
tableColumns = [],
sourceTableColumns = [], // 🆕 원본 테이블 컬럼
targetTableColumns = [], // 🆕 대상 테이블 컬럼
allTables = [],
onTableChange,
screenTableName, // 🆕 현재 화면의 테이블명
onSourceTableChange, // 🆕 원본 테이블 변경 콜백
onTargetTableChange, // 🆕 대상 테이블 변경 콜백
}) => {
const [localFields, setLocalFields] = useState<AdditionalFieldDefinition[]>(config.additionalFields || []);
const [displayColumns, setDisplayColumns] = useState<string[]>(config.displayColumns || []);
const [displayColumns, setDisplayColumns] = useState<Array<{ name: string; label: string; width?: string }>>(config.displayColumns || []);
const [fieldPopoverOpen, setFieldPopoverOpen] = useState<Record<number, boolean>>({});
// 🆕 원본 테이블 선택 상태
const [sourceTableSelectOpen, setSourceTableSelectOpen] = useState(false);
const [sourceTableSearchValue, setSourceTableSearchValue] = useState("");
// 🆕 대상 테이블 선택 상태 (기존 tableSelectOpen)
const [tableSelectOpen, setTableSelectOpen] = useState(false);
const [tableSearchValue, setTableSearchValue] = useState("");
// 🆕 초기 로드 시 screenTableName을 targetTable로 자동 설정
React.useEffect(() => {
if (screenTableName && !config.targetTable) {
console.log("✨ 현재 화면 테이블을 저장 대상 테이블로 자동 설정:", screenTableName);
handleChange("targetTable", screenTableName);
// 컬럼도 자동 로드
if (onTargetTableChange) {
onTargetTableChange(screenTableName);
}
}
}, [screenTableName]); // config.targetTable은 의존성에서 제외 (한 번만 실행)
const handleChange = (key: keyof SelectedItemsDetailInputConfig, value: any) => {
onChange({ [key]: value });
@ -46,7 +73,7 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
handleChange("additionalFields", fields);
};
const handleDisplayColumnsChange = (columns: string[]) => {
const handleDisplayColumnsChange = (columns: Array<{ name: string; label: string; width?: string }>) => {
setDisplayColumns(columns);
handleChange("displayColumns", columns);
};
@ -74,28 +101,47 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
};
// 표시 컬럼 추가
const addDisplayColumn = (columnName: string) => {
if (!displayColumns.includes(columnName)) {
handleDisplayColumnsChange([...displayColumns, columnName]);
const addDisplayColumn = (columnName: string, columnLabel: string) => {
if (!displayColumns.some(col => col.name === columnName)) {
handleDisplayColumnsChange([...displayColumns, { name: columnName, label: columnLabel }]);
}
};
// 표시 컬럼 제거
const removeDisplayColumn = (columnName: string) => {
handleDisplayColumnsChange(displayColumns.filter((col) => col !== columnName));
handleDisplayColumnsChange(displayColumns.filter((col) => col.name !== columnName));
};
// 사용되지 않은 컬럼 목록
// 🆕 표시 컬럼용: 원본 테이블에서 사용되지 않은 컬럼 목록
const availableColumns = useMemo(() => {
const usedColumns = new Set([...displayColumns, ...localFields.map((f) => f.name)]);
return tableColumns.filter((col) => !usedColumns.has(col.columnName));
}, [tableColumns, displayColumns, localFields]);
const usedColumns = new Set([...displayColumns.map(c => c.name), ...localFields.map((f) => f.name)]);
return sourceTableColumns.filter((col) => !usedColumns.has(col.columnName));
}, [sourceTableColumns, displayColumns, localFields]);
// 테이블 선택 Combobox 상태
const [tableSelectOpen, setTableSelectOpen] = useState(false);
const [tableSearchValue, setTableSearchValue] = useState("");
// 🆕 추가 입력 필드용: 대상 테이블에서 사용되지 않은 컬럼 목록
const availableTargetColumns = useMemo(() => {
const usedColumns = new Set([...displayColumns.map(c => c.name), ...localFields.map((f) => f.name)]);
return targetTableColumns.filter((col) => !usedColumns.has(col.columnName));
}, [targetTableColumns, displayColumns, localFields]);
// 필터링된 테이블 목록
// 🆕 원본 테이블 필터링
const filteredSourceTables = useMemo(() => {
if (!sourceTableSearchValue) return allTables;
const searchLower = sourceTableSearchValue.toLowerCase();
return allTables.filter(
(table) =>
table.tableName.toLowerCase().includes(searchLower) || table.displayName?.toLowerCase().includes(searchLower),
);
}, [allTables, sourceTableSearchValue]);
// 🆕 선택된 원본 테이블 표시명
const selectedSourceTableLabel = useMemo(() => {
if (!config.sourceTable) return "원본 테이블을 선택하세요";
const table = allTables.find((t) => t.tableName === config.sourceTable);
return table ? table.displayName || table.tableName : config.sourceTable;
}, [config.sourceTable, allTables]);
// 대상 테이블 필터링
const filteredTables = useMemo(() => {
if (!tableSearchValue) return allTables;
const searchLower = tableSearchValue.toLowerCase();
@ -105,9 +151,9 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
);
}, [allTables, tableSearchValue]);
// 선택된 테이블 표시명
// 선택된 대상 테이블 표시명
const selectedTableLabel = useMemo(() => {
if (!config.targetTable) return "테이블을 선택하세요";
if (!config.targetTable) return "저장 대상 테이블을 선택하세요";
const table = allTables.find((t) => t.tableName === config.targetTable);
return table ? table.displayName || table.tableName : config.targetTable;
}, [config.targetTable, allTables]);
@ -116,21 +162,83 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
<div className="space-y-4">
{/* 데이터 소스 ID */}
<div className="space-y-2">
<Label className="text-xs font-semibold sm:text-sm"> ID</Label>
<Label className="text-xs font-semibold sm:text-sm">
ID <span className="text-primary font-normal">( )</span>
</Label>
<Input
value={config.dataSourceId || ""}
onChange={(e) => handleChange("dataSourceId", e.target.value)}
placeholder="table-list-123"
placeholder="비워두면 URL 파라미터에서 자동 설정"
className="h-7 text-xs sm:h-8 sm:text-sm"
/>
<p className="text-[10px] text-primary font-medium sm:text-xs">
URL (Button이 )
</p>
<p className="text-[10px] text-gray-500 sm:text-xs">
💡 ID ( TableList의 ID)
</p>
</div>
{/* 🆕 원본 데이터 테이블 */}
<div className="space-y-2">
<Label className="text-xs font-semibold sm:text-sm">
<span className="text-destructive">*</span>
</Label>
<Popover open={sourceTableSelectOpen} onOpenChange={setSourceTableSelectOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={sourceTableSelectOpen}
className="h-8 w-full justify-between text-xs sm:text-sm"
disabled={allTables.length === 0}
>
{selectedSourceTableLabel}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50 sm:h-4 sm:w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0" align="start">
<Command>
<CommandInput placeholder="테이블 검색..." value={sourceTableSearchValue} onValueChange={setSourceTableSearchValue} className="h-8 text-xs sm:text-sm" />
<CommandEmpty> .</CommandEmpty>
<CommandGroup className="max-h-64 overflow-auto">
{filteredSourceTables.map((table) => (
<CommandItem
key={table.tableName}
value={table.tableName}
onSelect={(currentValue) => {
handleChange("sourceTable", currentValue);
setSourceTableSelectOpen(false);
setSourceTableSearchValue("");
if (onSourceTableChange) {
onSourceTableChange(currentValue);
}
}}
className="text-xs sm:text-sm"
>
<Check
className={cn(
"mr-2 h-3 w-3 sm:h-4 sm:w-4",
config.sourceTable === table.tableName ? "opacity-100" : "opacity-0",
)}
/>
{table.displayName || table.tableName}
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
<p className="text-[10px] text-gray-500 sm:text-xs">
(: item_info)
</p>
</div>
{/* 저장 대상 테이블 */}
<div className="space-y-2">
<Label className="text-xs font-semibold sm:text-sm"> </Label>
<Label className="text-xs font-semibold sm:text-sm">
<span className="text-destructive">*</span>
</Label>
<Popover open={tableSelectOpen} onOpenChange={setTableSelectOpen}>
<PopoverTrigger asChild>
<Button
@ -161,8 +269,8 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
handleChange("targetTable", currentValue);
setTableSelectOpen(false);
setTableSearchValue("");
if (onTableChange) {
onTableChange(currentValue);
if (onTargetTableChange) {
onTargetTableChange(currentValue);
}
}}
className="text-xs sm:text-sm"
@ -187,14 +295,14 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
<div className="space-y-2">
<Label className="text-xs font-semibold sm:text-sm"> </Label>
<div className="space-y-2">
{displayColumns.map((colName, index) => (
{displayColumns.map((col, index) => (
<div key={index} className="flex items-center gap-2">
<Input value={colName} readOnly className="h-7 flex-1 text-xs sm:h-8 sm:text-sm" />
<Input value={col.label || col.name} readOnly className="h-7 flex-1 text-xs sm:h-8 sm:text-sm" />
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeDisplayColumn(colName)}
onClick={() => removeDisplayColumn(col.name)}
className="h-6 w-6 text-red-500 hover:bg-red-50 sm:h-7 sm:w-7"
>
<X className="h-3 w-3 sm:h-4 sm:w-4" />
@ -223,7 +331,7 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
<CommandItem
key={column.columnName}
value={column.columnName}
onSelect={() => addDisplayColumn(column.columnName)}
onSelect={() => addDisplayColumn(column.columnName, column.columnLabel || column.columnName)}
className="text-xs sm:text-sm"
>
<div>
@ -284,7 +392,7 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
<CommandInput placeholder="컬럼 검색..." className="h-6 text-[10px] sm:h-7 sm:text-xs" />
<CommandEmpty className="text-[10px] sm:text-xs"> .</CommandEmpty>
<CommandGroup className="max-h-[150px] overflow-auto sm:max-h-[200px]">
{availableColumns.map((column) => (
{availableTargetColumns.map((column) => (
<CommandItem
key={column.columnName}
value={column.columnName}
@ -292,6 +400,8 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
updateField(index, {
name: column.columnName,
label: column.columnLabel || column.columnName,
inputType: column.inputType || "text", // 🆕 inputType 포함
codeCategory: column.codeCategory, // 🆕 codeCategory 포함
});
setFieldPopoverOpen({ ...fieldPopoverOpen, [index]: false });
}}
@ -328,37 +438,16 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
<div className="grid grid-cols-2 gap-2 sm:gap-3">
<div className="space-y-1">
<Label className="text-[10px] sm:text-xs"></Label>
<Select
value={field.type}
onValueChange={(value) =>
updateField(index, { type: value as AdditionalFieldDefinition["type"] })
}
>
<SelectTrigger className="h-6 w-full text-[10px] sm:h-7 sm:text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="text" className="text-[10px] sm:text-xs">
</SelectItem>
<SelectItem value="number" className="text-[10px] sm:text-xs">
</SelectItem>
<SelectItem value="date" className="text-[10px] sm:text-xs">
</SelectItem>
<SelectItem value="select" className="text-[10px] sm:text-xs">
</SelectItem>
<SelectItem value="checkbox" className="text-[10px] sm:text-xs">
</SelectItem>
<SelectItem value="textarea" className="text-[10px] sm:text-xs">
</SelectItem>
</SelectContent>
</Select>
<Label className="text-[10px] sm:text-xs"> ()</Label>
<Input
value={field.inputType || field.type || "text"}
readOnly
disabled
className="h-6 w-full text-[10px] sm:h-7 sm:text-xs bg-muted"
/>
<p className="text-[9px] text-primary sm:text-[10px]">
</p>
</div>
<div className="space-y-1">

View File

@ -12,6 +12,10 @@ export interface AdditionalFieldDefinition {
label: string;
/** 입력 타입 */
type: "text" | "number" | "date" | "select" | "checkbox" | "textarea";
/** 🆕 데이터베이스 inputType (실제 렌더링 시 사용) */
inputType?: string;
/** 🆕 코드 카테고리 (inputType이 code/category일 때) */
codeCategory?: string;
/** 필수 입력 여부 */
required?: boolean;
/** 플레이스홀더 */
@ -43,13 +47,20 @@ export interface SelectedItemsDetailInputConfig extends ComponentConfig {
dataSourceId?: string;
/**
*
* : ["item_code", "item_name", "spec", "unit"]
* 🆕 ( )
* : "item_info"
*/
displayColumns?: string[];
sourceTable?: string;
/**
* (name, label, width)
*
*/
displayColumns?: Array<{ name: string; label: string; width?: string }>;
/**
*
*
*/
additionalFields?: AdditionalFieldDefinition[];

View File

@ -106,6 +106,9 @@ export interface ButtonActionContext {
// 플로우 선택된 데이터 정보 (플로우 위젯 선택 액션용)
flowSelectedData?: any[];
flowSelectedStepId?: number | null;
// 🆕 같은 화면의 모든 컴포넌트 (TableList 자동 감지용)
allComponents?: any[];
// 제어 실행을 위한 추가 정보
buttonId?: string;
@ -686,8 +689,28 @@ export class ButtonActionExecutor {
dataSourceId: config.dataSourceId,
});
// 1. dataSourceId 확인 (없으면 selectedRows에서 데이터 전달)
const dataSourceId = config.dataSourceId || context.tableName || "default";
// 🆕 1. dataSourceId 자동 결정
let dataSourceId = config.dataSourceId;
// dataSourceId가 없으면 같은 화면의 TableList 자동 감지
if (!dataSourceId && context.allComponents) {
const tableListComponent = context.allComponents.find(
(comp: any) => comp.componentType === "table-list" && comp.componentConfig?.tableName
);
if (tableListComponent) {
dataSourceId = tableListComponent.componentConfig.tableName;
console.log("✨ TableList 자동 감지:", {
componentId: tableListComponent.id,
tableName: dataSourceId,
});
}
}
// 여전히 없으면 context.tableName 또는 "default" 사용
if (!dataSourceId) {
dataSourceId = context.tableName || "default";
}
// 2. modalDataStore에서 데이터 확인
try {
@ -711,7 +734,7 @@ export class ButtonActionExecutor {
return false;
}
// 3. 모달 열기
// 3. 모달 열기 + URL 파라미터로 dataSourceId 전달
if (config.targetScreenId) {
// config에 modalDescription이 있으면 우선 사용
let description = config.modalDescription || "";
@ -726,13 +749,14 @@ export class ButtonActionExecutor {
}
}
// 전역 모달 상태 업데이트를 위한 이벤트 발생
// 🆕 전역 모달 상태 업데이트를 위한 이벤트 발생 (URL 파라미터 포함)
const modalEvent = new CustomEvent("openScreenModal", {
detail: {
screenId: config.targetScreenId,
title: config.modalTitle || "데이터 입력",
description: description,
size: config.modalSize || "lg", // 데이터 입력 화면은 기본 large
urlParams: { dataSourceId }, // 🆕 URL 파라미터로 dataSourceId 전달
},
});

View File

@ -15,7 +15,7 @@ const CONFIG_PANEL_MAP: Record<string, () => Promise<any>> = {
"radio-basic": () => import("@/lib/registry/components/radio-basic/RadioBasicConfigPanel"),
"toggle-switch": () => import("@/lib/registry/components/toggle-switch/ToggleSwitchConfigPanel"),
"file-upload": () => import("@/lib/registry/components/file-upload/FileUploadConfigPanel"),
"button-primary": () => import("@/lib/registry/components/button-primary/ButtonPrimaryConfigPanel"),
"button-primary": () => import("@/components/screen/config-panels/ButtonConfigPanel"),
"text-display": () => import("@/lib/registry/components/text-display/TextDisplayConfigPanel"),
"slider-basic": () => import("@/lib/registry/components/slider-basic/SliderBasicConfigPanel"),
"image-display": () => import("@/lib/registry/components/image-display/ImageDisplayConfigPanel"),
@ -70,6 +70,7 @@ export async function getComponentConfigPanel(componentId: string): Promise<Reac
module.FlowWidgetConfigPanel || // flow-widget의 export명
module.CustomerItemMappingConfigPanel || // customer-item-mapping의 export명
module.SelectedItemsDetailInputConfigPanel || // selected-items-detail-input의 export명
module.ButtonConfigPanel || // button-primary의 export명
module.default;
if (!ConfigPanelComponent) {
@ -140,6 +141,10 @@ export const DynamicComponentConfigPanel: React.FC<ComponentConfigPanelProps> =
const [error, setError] = React.useState<string | null>(null);
const [selectedTableColumns, setSelectedTableColumns] = React.useState(tableColumns);
const [allTablesList, setAllTablesList] = React.useState<any[]>([]);
// 🆕 selected-items-detail-input 전용 상태
const [sourceTableColumns, setSourceTableColumns] = React.useState<any[]>([]);
const [targetTableColumns, setTargetTableColumns] = React.useState<any[]>([]);
React.useEffect(() => {
let mounted = true;
@ -176,14 +181,15 @@ export const DynamicComponentConfigPanel: React.FC<ComponentConfigPanelProps> =
setSelectedTableColumns(tableColumns);
}, [tableColumns]);
// RepeaterConfigPanel인 경우에만 전체 테이블 목록 로드
// RepeaterConfigPanel과 selected-items-detail-input에서 전체 테이블 목록 로드
React.useEffect(() => {
if (componentId === "repeater-field-group") {
if (componentId === "repeater-field-group" || componentId === "selected-items-detail-input") {
const loadAllTables = async () => {
try {
const { tableManagementApi } = await import("@/lib/api/tableManagement");
const response = await tableManagementApi.getTableList();
if (response.success && response.data) {
console.log(`✅ 전체 테이블 목록 로드 완료 (${componentId}):`, response.data.length);
setAllTablesList(response.data);
}
} catch (error) {
@ -194,6 +200,57 @@ export const DynamicComponentConfigPanel: React.FC<ComponentConfigPanelProps> =
}
}, [componentId]);
// 🆕 selected-items-detail-input: 초기 sourceTable/targetTable 컬럼 로드
React.useEffect(() => {
if (componentId === "selected-items-detail-input") {
console.log("🔍 selected-items-detail-input 초기 설정:", config);
// 원본 테이블 컬럼 로드
if (config.sourceTable) {
const loadSourceColumns = async () => {
try {
const { tableTypeApi } = await import("@/lib/api/screen");
const columnsResponse = await tableTypeApi.getColumns(config.sourceTable);
const columns = (columnsResponse || []).map((col: any) => ({
columnName: col.columnName || col.column_name,
columnLabel: col.displayName || col.columnLabel || col.column_label || col.columnName || col.column_name,
dataType: col.dataType || col.data_type || col.dbType,
}));
console.log("✅ 원본 테이블 컬럼 초기 로드 완료:", columns.length);
setSourceTableColumns(columns);
} catch (error) {
console.error("❌ 원본 테이블 컬럼 초기 로드 실패:", error);
}
};
loadSourceColumns();
}
// 대상 테이블 컬럼 로드
if (config.targetTable) {
const loadTargetColumns = async () => {
try {
const { tableTypeApi } = await import("@/lib/api/screen");
const columnsResponse = await tableTypeApi.getColumns(config.targetTable);
const columns = (columnsResponse || []).map((col: any) => ({
columnName: col.columnName || col.column_name,
columnLabel: col.displayName || col.columnLabel || col.column_label || col.columnName || col.column_name,
dataType: col.dataType || col.data_type || col.dbType,
}));
console.log("✅ 대상 테이블 컬럼 초기 로드 완료:", columns.length);
setTargetTableColumns(columns);
} catch (error) {
console.error("❌ 대상 테이블 컬럼 초기 로드 실패:", error);
}
};
loadTargetColumns();
}
}
}, [componentId, config.sourceTable, config.targetTable]);
if (loading) {
return (
<div className="rounded-md border border-dashed border-gray-300 bg-gray-50 p-4">
@ -266,6 +323,63 @@ export const DynamicComponentConfigPanel: React.FC<ComponentConfigPanelProps> =
}
};
// 🆕 원본 테이블 컬럼 로드 핸들러 (selected-items-detail-input용)
const handleSourceTableChange = async (tableName: string) => {
console.log("🔄 원본 테이블 변경:", tableName);
try {
const { tableTypeApi } = await import("@/lib/api/screen");
const columnsResponse = await tableTypeApi.getColumns(tableName);
const columns = (columnsResponse || []).map((col: any) => ({
columnName: col.columnName || col.column_name,
columnLabel: col.displayName || col.columnLabel || col.column_label || col.columnName || col.column_name,
dataType: col.dataType || col.data_type || col.dbType,
inputType: col.inputType || col.input_type, // 🆕 inputType 추가
}));
console.log("✅ 원본 테이블 컬럼 로드 완료:", columns.length);
setSourceTableColumns(columns);
} catch (error) {
console.error("❌ 원본 테이블 컬럼 로드 실패:", error);
setSourceTableColumns([]);
}
};
// 🆕 대상 테이블 컬럼 로드 핸들러 (selected-items-detail-input용)
const handleTargetTableChange = async (tableName: string) => {
console.log("🔄 대상 테이블 변경:", tableName);
try {
const { tableTypeApi } = await import("@/lib/api/screen");
const columnsResponse = await tableTypeApi.getColumns(tableName);
console.log("📡 [handleTargetTableChange] API 응답 (원본):", {
totalColumns: columnsResponse.length,
sampleColumns: columnsResponse.slice(0, 3),
currency_code_raw: columnsResponse.find((c: any) => (c.columnName || c.column_name) === 'currency_code')
});
const columns = (columnsResponse || []).map((col: any) => ({
columnName: col.columnName || col.column_name,
columnLabel: col.displayName || col.columnLabel || col.column_label || col.columnName || col.column_name,
dataType: col.dataType || col.data_type || col.dbType,
inputType: col.inputType || col.input_type, // 🆕 inputType 추가
codeCategory: col.codeCategory || col.code_category, // 🆕 codeCategory 추가
}));
console.log("✅ 대상 테이블 컬럼 변환 완료:", {
tableName,
totalColumns: columns.length,
currency_code: columns.find((c: any) => c.columnName === "currency_code"),
discount_rate: columns.find((c: any) => c.columnName === "discount_rate")
});
setTargetTableColumns(columns);
} catch (error) {
console.error("❌ 대상 테이블 컬럼 로드 실패:", error);
setTargetTableColumns([]);
}
};
// 🆕 수주 등록 관련 컴포넌트들은 간단한 인터페이스 사용
const isSimpleConfigPanel = [
"autocomplete-search-input",
@ -279,6 +393,22 @@ export const DynamicComponentConfigPanel: React.FC<ComponentConfigPanelProps> =
return <ConfigPanelComponent config={config} onConfigChange={onChange} />;
}
// 🆕 selected-items-detail-input은 특별한 props 사용
if (componentId === "selected-items-detail-input") {
return (
<ConfigPanelComponent
config={config}
onChange={onChange}
sourceTableColumns={sourceTableColumns} // 🆕 원본 테이블 컬럼
targetTableColumns={targetTableColumns} // 🆕 대상 테이블 컬럼
allTables={allTablesList.length > 0 ? allTablesList : tables} // 전체 테이블 목록 (동적 로드 or 전달된 목록)
screenTableName={screenTableName} // 🆕 현재 화면의 테이블명 (자동 설정용)
onSourceTableChange={handleSourceTableChange} // 🆕 원본 테이블 변경 핸들러
onTargetTableChange={handleTargetTableChange} // 🆕 대상 테이블 변경 핸들러
/>
);
}
return (
<ConfigPanelComponent
config={config}

View File

@ -0,0 +1,375 @@
# 선택 항목 상세입력 완전 자동화 가이드 🚀
## ✨ 완전 자동화 완료!
이제 **아무 설정도 하지 않아도** 데이터가 자동으로 전달됩니다!
---
## 🎯 자동화된 흐름
### 1단계: TableList (선택 화면)
```
┌─────────────────────────────────┐
│ TableList 컴포넌트 │
│ - 테이블: item_info │
│ - 체크박스로 품목 3개 선택 │
└─────────────────────────────────┘
↓ (자동 저장)
┌─────────────────────────────────┐
│ modalDataStore │
│ { "item_info": [선택된 데이터] }│
└─────────────────────────────────┘
```
### 2단계: Button (다음 버튼)
```
┌─────────────────────────────────┐
│ Button 컴포넌트 │
│ - 액션: "데이터 전달 + 모달열기"│
│ - dataSourceId: (비워둠) │ ← 자동 감지!
└─────────────────────────────────┘
↓ (자동 감지)
┌─────────────────────────────────┐
│ 같은 화면에서 TableList 찾기 │
│ → tableName = "item_info" │
└─────────────────────────────────┘
↓ (URL 전달)
┌─────────────────────────────────┐
│ 모달 열기 │
│ URL: ?dataSourceId=item_info │
└─────────────────────────────────┘
```
### 3단계: SelectedItemsDetailInput (상세 입력 화면)
```
┌─────────────────────────────────┐
│ SelectedItemsDetailInput │
│ - dataSourceId: (비워둠) │ ← URL에서 자동 읽기!
└─────────────────────────────────┘
↓ (자동 읽기)
┌─────────────────────────────────┐
│ URL: ?dataSourceId=item_info │
│ → modalDataStore에서 데이터 로드│
└─────────────────────────────────┘
┌─────────────────────────────────┐
│ 화면에 데이터 표시 │
│ 선택된 3개 품목 + 입력 필드 │
└─────────────────────────────────┘
```
---
## 🛠️ 설정 방법 (완전 자동)
### 1단계: 선택 화면 구성
#### TableList 컴포넌트
```yaml
테이블: item_info
옵션:
- 체크박스 표시: ✓
- 다중 선택: ✓
```
**설정 끝!** 선택된 데이터는 자동으로 `modalDataStore`에 저장됩니다.
#### Button 컴포넌트
```yaml
버튼 텍스트: "다음"
버튼 액션: "데이터 전달 + 모달 열기" 🆕
설정:
- 데이터 소스 ID: (비워둠) ← ✨ 자동 감지!
- 모달 제목: "상세 정보 입력"
- 모달 크기: Large
- 대상 화면: (상세 입력 화면 선택)
```
**중요**: `데이터 소스 ID`**비워두세요**! 자동으로 같은 화면의 TableList를 찾아서 테이블명을 사용합니다.
---
### 2단계: 상세 입력 화면 구성
#### SelectedItemsDetailInput 컴포넌트 추가
```yaml
컴포넌트: "선택 항목 상세입력"
```
**설정 끝!** URL 파라미터에서 자동으로 `dataSourceId`를 읽어옵니다.
#### 상세 설정 (선택사항)
```yaml
데이터 소스 ID: (비워둠) ← ✨ URL에서 자동!
표시할 컬럼:
- 품목코드 (item_code)
- 품목명 (item_name)
- 규격 (specification)
추가 입력 필드:
- 수량 (quantity): 숫자
- 단가 (unit_price): 숫자
- 납기일 (delivery_date): 날짜
- 비고 (remarks): 텍스트영역
옵션:
- 레이아웃: 테이블 형식 (Grid)
- 항목 번호 표시: ✓
- 항목 제거 허용: ☐
```
---
## 📊 실제 동작 시나리오
### 시나리오: 수주 등록
#### 1단계: 품목 선택
```
사용자가 품목 테이블에서 3개 선택:
✓ [PD-001] 케이블 100m
✓ [PD-002] 커넥터 50개
✓ [PD-003] 단자대 20개
```
#### 2단계: "다음" 버튼 클릭
```javascript
// 자동으로 일어나는 일:
1. 같은 화면에서 table-list 컴포넌트 찾기
→ componentType === "table-list"
→ tableName === "item_info"
2. modalDataStore에서 데이터 확인
→ modalData = [
{ id: "PD-001", originalData: {...} },
{ id: "PD-002", originalData: {...} },
{ id: "PD-003", originalData: {...} }
]
3. 모달 열기 + URL 파라미터 전달
→ URL: /screen/detail-input?dataSourceId=item_info
```
#### 3단계: 상세 정보 입력
```
자동으로 표시됨:
┌───────────────────────────────────────────────────────┐
│ 상세 정보 입력 │
├───────────────────────────────────────────────────────┤
│ # │ 품목코드 │ 품목명 │ 수량 │ 단가 │ 납기일 │
├───┼──────────┼────────────┼──────┼────────┼─────────┤
│ 1 │ PD-001 │ 케이블100m │ [ ] │ [ ] │ [ ] │
│ 2 │ PD-002 │ 커넥터50개 │ [ ] │ [ ] │ [ ] │
│ 3 │ PD-003 │ 단자대20개 │ [ ] │ [ ] │ [ ] │
└───────────────────────────────────────────────────────┘
사용자가 수량, 단가, 납기일만 입력하면 끝!
```
---
## 🎨 UI 미리보기
### Button 설정 화면
```
버튼 액션
├─ 데이터 전달 + 모달 열기 🆕
└─ 데이터 전달 + 모달 설정
├─ 데이터 소스 ID (선택사항)
│ [ ]
│ ✨ 비워두면 같은 화면의 TableList를 자동으로 감지합니다
│ 직접 지정하려면 테이블명을 입력하세요 (예: item_info)
├─ 모달 제목
│ [상세 정보 입력 ]
├─ 모달 크기
│ [큰 (Large) - 권장 ▼]
└─ 대상 화면 선택
[화면을 선택하세요... ▼]
```
### SelectedItemsDetailInput 설정 화면
```
데이터 소스 ID (자동 설정됨)
[ ]
✨ URL 파라미터에서 자동으로 가져옵니다 (Button이 전달)
테스트용으로 직접 입력하려면 테이블명을 입력하세요
```
---
## 🔍 자동화 원리
### 1. TableList → modalDataStore
```typescript
// TableListComponent.tsx
const handleRowSelection = (rowKey: string, checked: boolean) => {
// ... 선택 처리 ...
// 🆕 자동으로 스토어에 저장
const { useModalDataStore } = await import("@/stores/modalDataStore");
const dataSourceId = tableName; // item_info
const modalDataItems = selectedRowsData.map(row => ({
id: row.item_code,
originalData: row,
additionalData: {}
}));
useModalDataStore.getState().setData(dataSourceId, modalDataItems);
};
```
### 2. Button → TableList 자동 감지
```typescript
// buttonActions.ts - handleOpenModalWithData()
let dataSourceId = config.dataSourceId;
// 🆕 비워있으면 자동 감지
if (!dataSourceId && context.allComponents) {
const tableListComponent = context.allComponents.find(
(comp) => comp.componentType === "table-list" && comp.componentConfig?.tableName
);
if (tableListComponent) {
dataSourceId = tableListComponent.componentConfig.tableName;
console.log("✨ TableList 자동 감지:", dataSourceId);
}
}
// 🆕 URL 파라미터로 전달
const modalEvent = new CustomEvent("openScreenModal", {
detail: {
screenId: config.targetScreenId,
urlParams: { dataSourceId } // ← URL에 추가
}
});
```
### 3. SelectedItemsDetailInput → URL 읽기
```typescript
// SelectedItemsDetailInputComponent.tsx
import { useSearchParams } from "next/navigation";
const searchParams = useSearchParams();
const urlDataSourceId = searchParams?.get("dataSourceId");
// 🆕 우선순위: URL > 설정 > component.id
const dataSourceId = useMemo(
() => urlDataSourceId || componentConfig.dataSourceId || component.id,
[urlDataSourceId, componentConfig.dataSourceId, component.id]
);
// 🆕 스토어에서 데이터 로드
const dataRegistry = useModalDataStore((state) => state.dataRegistry);
const modalData = dataRegistry[dataSourceId] || [];
```
---
## 🧪 테스트 시나리오
### 기본 테스트
1. **화면 편집기 열기**
2. **첫 번째 화면 (선택) 만들기**:
- TableList 추가 (item_info)
- Button 추가 (버튼 액션: "데이터 전달 + 모달 열기")
- **dataSourceId는 비워둠!**
3. **두 번째 화면 (상세 입력) 만들기**:
- SelectedItemsDetailInput 추가
- **dataSourceId는 비워둠!**
- 표시 컬럼 설정
- 추가 입력 필드 설정
4. **할당된 화면에서 테스트**:
- 품목 3개 선택
- "다음" 버튼 클릭
- 상세 입력 화면에서 데이터 확인 ✅
### 고급 테스트 (직접 지정)
```yaml
시나리오: 여러 TableList가 있는 화면
화면 구성:
- TableList (item_info) ← 품목
- TableList (customer_info) ← 고객
- Button (품목 상세입력) ← dataSourceId: "item_info"
- Button (고객 상세입력) ← dataSourceId: "customer_info"
```
---
## 🚨 주의사항
### ❌ 잘못된 사용법
```yaml
# 1. 같은 화면에 TableList가 여러 개 있는데 비워둠
TableList 1: item_info
TableList 2: customer_info
Button: dataSourceId = (비워둠) ← 어느 것을 선택해야 할까?
해결: dataSourceId를 명시적으로 지정
```
### ✅ 올바른 사용법
```yaml
# 1. TableList가 1개인 경우
TableList: item_info
Button: dataSourceId = (비워둠) ← 자동 감지 OK!
# 2. TableList가 여러 개인 경우
TableList 1: item_info
TableList 2: customer_info
Button 1: dataSourceId = "item_info" ← 명시
Button 2: dataSourceId = "customer_info" ← 명시
```
---
## 🎯 완료 체크리스트
### 구현 완료 ✅
- [x] TableList → modalDataStore 자동 저장
- [x] Button → TableList 자동 감지
- [x] Button → URL 파라미터 전달
- [x] SelectedItemsDetailInput → URL 자동 읽기
- [x] 설정 패널 UI에 "자동" 힌트 추가
### 사용자 경험 ✅
- [x] dataSourceId 입력 불필요 (자동)
- [x] 일관된 데이터 흐름 (자동)
- [x] 오류 메시지 명확 (자동)
- [x] 직관적인 UI (자동 힌트)
---
## 📝 요약
**이제 사용자는 단 3단계만 하면 됩니다:**
1. **TableList 추가** → 테이블 선택
2. **Button 추가** → 액션 "데이터 전달 + 모달 열기" 선택
3. **SelectedItemsDetailInput 추가** → 필드 설정
**dataSourceId는 자동으로 처리됩니다!** ✨
---
## 🔗 관련 파일
- `frontend/stores/modalDataStore.ts` - 데이터 저장소
- `frontend/lib/utils/buttonActions.ts` - 버튼 액션 (자동 감지)
- `frontend/lib/registry/components/table-list/TableListComponent.tsx` - 자동 저장
- `frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx` - URL 자동 읽기
- `frontend/components/screen/config-panels/ButtonConfigPanel.tsx` - 버튼 설정 UI
- `frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputConfigPanel.tsx` - 상세 입력 설정 UI
---
**🎉 완전 자동화 완료!**