반응형 및 테이블 리스트 컴포넌트 오류 수정

This commit is contained in:
kjs 2025-10-17 15:31:23 +09:00
parent 54e9f45823
commit 2a8081a253
21 changed files with 886 additions and 262 deletions

View File

@ -515,6 +515,7 @@ export class DashboardController {
});
// 외부 API 호출
// @ts-ignore - node-fetch dynamic import
const fetch = (await import("node-fetch")).default;
const response = await fetch(urlObj.toString(), {
method: method.toUpperCase(),

View File

@ -295,6 +295,54 @@ export class DynamicFormService {
}
});
// 📝 RepeaterInput 데이터 처리 (JSON 배열을 개별 레코드로 분해)
const repeaterData: Array<{
data: Record<string, any>[];
targetTable?: string;
componentId: string;
}> = [];
Object.keys(dataToInsert).forEach((key) => {
const value = dataToInsert[key];
// RepeaterInput 데이터인지 확인 (JSON 배열 문자열)
if (
typeof value === "string" &&
value.trim().startsWith("[") &&
value.trim().endsWith("]")
) {
try {
const parsedArray = JSON.parse(value);
if (Array.isArray(parsedArray) && parsedArray.length > 0) {
console.log(
`🔄 RepeaterInput 데이터 감지: ${key}, ${parsedArray.length}개 항목`
);
// 컴포넌트 설정에서 targetTable 추출 (컴포넌트 ID를 통해)
// 프론트엔드에서 { data: [...], targetTable: "..." } 형식으로 전달될 수 있음
let targetTable: string | undefined;
let actualData = parsedArray;
// 첫 번째 항목에 _targetTable이 있는지 확인 (프론트엔드에서 메타데이터 전달)
if (parsedArray[0] && parsedArray[0]._targetTable) {
targetTable = parsedArray[0]._targetTable;
actualData = parsedArray.map(
({ _targetTable, ...item }) => item
);
}
repeaterData.push({
data: actualData,
targetTable,
componentId: key,
});
delete dataToInsert[key]; // 원본 배열 데이터는 제거
}
} catch (parseError) {
console.log(`⚠️ JSON 파싱 실패: ${key}`);
}
}
});
// 존재하지 않는 컬럼 제거
Object.keys(dataToInsert).forEach((key) => {
if (!tableColumns.includes(key)) {
@ -305,6 +353,9 @@ export class DynamicFormService {
}
});
// RepeaterInput 데이터 처리 로직은 메인 저장 후에 처리
// (각 Repeater가 다른 테이블에 저장될 수 있으므로)
console.log("🎯 실제 테이블에 삽입할 데이터:", {
tableName,
dataToInsert,
@ -388,6 +439,111 @@ export class DynamicFormService {
// 결과를 표준 형식으로 변환
const insertedRecord = Array.isArray(result) ? result[0] : result;
// 📝 RepeaterInput 데이터 저장 (각 Repeater를 해당 테이블에 저장)
if (repeaterData.length > 0) {
console.log(
`🔄 RepeaterInput 데이터 저장 시작: ${repeaterData.length}개 Repeater`
);
for (const repeater of repeaterData) {
const targetTableName = repeater.targetTable || tableName;
console.log(
`📝 Repeater "${repeater.componentId}" → 테이블 "${targetTableName}"에 ${repeater.data.length}개 항목 저장`
);
// 대상 테이블의 컬럼 및 기본키 정보 조회
const targetTableColumns =
await this.getTableColumns(targetTableName);
const targetPrimaryKeys = await this.getPrimaryKeys(targetTableName);
// 컬럼명만 추출
const targetColumnNames = targetTableColumns.map(
(col) => col.columnName
);
// 각 항목을 저장
for (let i = 0; i < repeater.data.length; i++) {
const item = repeater.data[i];
const itemData: Record<string, any> = {
...item,
created_by,
updated_by,
regdate: new Date(),
};
// 대상 테이블에 존재하는 컬럼만 필터링
Object.keys(itemData).forEach((key) => {
if (!targetColumnNames.includes(key)) {
delete itemData[key];
}
});
// 타입 변환 적용
Object.keys(itemData).forEach((columnName) => {
const column = targetTableColumns.find(
(col) => col.columnName === columnName
);
if (column) {
itemData[columnName] = this.convertValueForPostgreSQL(
itemData[columnName],
column.dataType
);
}
});
// UPSERT 쿼리 생성
const itemColumns = Object.keys(itemData);
const itemValues: any[] = Object.values(itemData);
const itemPlaceholders = itemValues
.map((_, index) => `$${index + 1}`)
.join(", ");
let itemUpsertQuery: string;
if (targetPrimaryKeys.length > 0) {
const conflictColumns = targetPrimaryKeys.join(", ");
const updateSet = itemColumns
.filter((col) => !targetPrimaryKeys.includes(col))
.map((col) => `${col} = EXCLUDED.${col}`)
.join(", ");
if (updateSet) {
itemUpsertQuery = `
INSERT INTO ${targetTableName} (${itemColumns.join(", ")})
VALUES (${itemPlaceholders})
ON CONFLICT (${conflictColumns})
DO UPDATE SET ${updateSet}
RETURNING *
`;
} else {
itemUpsertQuery = `
INSERT INTO ${targetTableName} (${itemColumns.join(", ")})
VALUES (${itemPlaceholders})
ON CONFLICT (${conflictColumns})
DO NOTHING
RETURNING *
`;
}
} else {
itemUpsertQuery = `
INSERT INTO ${targetTableName} (${itemColumns.join(", ")})
VALUES (${itemPlaceholders})
RETURNING *
`;
}
console.log(
` 📝 항목 ${i + 1}/${repeater.data.length} 저장:`,
itemData
);
await query<any>(itemUpsertQuery, itemValues);
}
console.log(` ✅ Repeater "${repeater.componentId}" 저장 완료`);
}
console.log(`✅ 모든 RepeaterInput 데이터 저장 완료`);
}
// 🔥 조건부 연결 실행 (INSERT 트리거)
try {
if (company_code) {
@ -1114,6 +1270,31 @@ export class DynamicFormService {
}
}
/**
*
*/
async getPrimaryKeys(tableName: string): Promise<string[]> {
try {
console.log("🔑 서비스: 테이블 기본키 조회 시작:", { tableName });
const result = await query<{ column_name: string }>(
`SELECT a.attname AS column_name
FROM pg_index i
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
WHERE i.indrelid = $1::regclass AND i.indisprimary`,
[tableName]
);
const primaryKeys = result.map((row) => row.column_name);
console.log("✅ 서비스: 테이블 기본키 조회 성공:", primaryKeys);
return primaryKeys;
} catch (error) {
console.error("❌ 서비스: 테이블 기본키 조회 실패:", error);
throw new Error(`테이블 기본키 조회 실패: ${error}`);
}
}
/**
* ( )
*/

View File

@ -219,7 +219,11 @@ export class EntityJoinService {
];
const separator = config.separator || " - ";
if (displayColumns.length === 1) {
if (displayColumns.length === 0 || !displayColumns[0]) {
// displayColumns가 빈 배열이거나 첫 번째 값이 null/undefined인 경우
// 조인 테이블의 referenceColumn을 기본값으로 사용
return `COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${config.aliasColumn}`;
} else if (displayColumns.length === 1) {
// 단일 컬럼인 경우
const col = displayColumns[0];
const isJoinTableColumn = [

View File

@ -22,7 +22,7 @@ export default function ScreenViewPage() {
const [layout, setLayout] = useState<LayoutData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [formData, setFormData] = useState<Record<string, any>>({});
const [formData, setFormData] = useState<Record<string, unknown>>({});
// 화면 너비에 따라 Y좌표 유지 여부 결정
const [preserveYPosition, setPreserveYPosition] = useState(true);
@ -34,7 +34,7 @@ export default function ScreenViewPage() {
const [editModalConfig, setEditModalConfig] = useState<{
screenId?: number;
modalSize?: "sm" | "md" | "lg" | "xl" | "full";
editData?: any;
editData?: Record<string, unknown>;
onSave?: () => void;
modalTitle?: string;
modalDescription?: string;
@ -70,11 +70,11 @@ export default function ScreenViewPage() {
setEditModalOpen(true);
};
// @ts-ignore
// @ts-expect-error - CustomEvent type
window.addEventListener("openEditModal", handleOpenEditModal);
return () => {
// @ts-ignore
// @ts-expect-error - CustomEvent type
window.removeEventListener("openEditModal", handleOpenEditModal);
};
}, []);
@ -96,8 +96,18 @@ export default function ScreenViewPage() {
} catch (layoutError) {
console.warn("레이아웃 로드 실패, 빈 레이아웃 사용:", layoutError);
setLayout({
screenId,
components: [],
gridSettings: { columns: 12, gap: 16, padding: 16 },
gridSettings: {
columns: 12,
gap: 16,
padding: 16,
enabled: true,
size: 8,
color: "#e0e0e0",
opacity: 0.5,
snapToGrid: true,
},
});
}
} catch (error) {
@ -174,6 +184,13 @@ export default function ScreenViewPage() {
containerWidth={window.innerWidth}
screenWidth={screenWidth}
preserveYPosition={preserveYPosition}
isDesignMode={false}
formData={formData}
onFormDataChange={(fieldName: string, value: unknown) => {
console.log("📝 page.tsx formData 업데이트:", fieldName, value);
setFormData((prev) => ({ ...prev, [fieldName]: value }));
}}
screenInfo={{ id: screenId, tableName: screen?.tableName }}
/>
) : (
// 빈 화면일 때

View File

@ -10,12 +10,15 @@ import { Breakpoint, BREAKPOINTS } from "@/types/responsive";
import { generateSmartDefaults, ensureResponsiveConfig } from "@/lib/utils/responsiveDefaults";
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
interface ResponsiveLayoutEngineProps {
export interface ResponsiveLayoutEngineProps {
components: ComponentData[];
breakpoint: Breakpoint;
containerWidth: number;
screenWidth?: number;
preserveYPosition?: boolean; // true: Y좌표 유지 (하이브리드), false: 16px 간격 (반응형)
formData?: Record<string, unknown>;
onFormDataChange?: (fieldName: string, value: unknown) => void;
screenInfo?: { id: number; tableName?: string };
}
/**
@ -33,6 +36,9 @@ export const ResponsiveLayoutEngine: React.FC<ResponsiveLayoutEngineProps> = ({
containerWidth,
screenWidth = 1920,
preserveYPosition = false, // 기본값: 반응형 모드 (16px 간격)
formData,
onFormDataChange,
screenInfo,
}) => {
// 1단계: 컴포넌트들을 Y 위치 기준으로 행(row)으로 그룹화
const rows = useMemo(() => {
@ -72,6 +78,18 @@ export const ResponsiveLayoutEngine: React.FC<ResponsiveLayoutEngineProps> = ({
const responsiveComponents = useMemo(() => {
const result = sortedRows.flatMap((row, rowIndex) =>
row.map((comp, compIndex) => {
// 컴포넌트에 gridColumns가 이미 설정되어 있으면 그 값 사용
if ((comp as any).gridColumns !== undefined) {
return {
...comp,
responsiveDisplay: {
gridColumns: (comp as any).gridColumns,
order: compIndex + 1,
hide: false,
},
};
}
// 반응형 설정이 없으면 자동 생성
const compWithConfig = ensureResponsiveConfig(comp, screenWidth);
@ -140,6 +158,7 @@ export const ResponsiveLayoutEngine: React.FC<ResponsiveLayoutEngineProps> = ({
gap: "16px",
padding: "0 16px",
marginTop,
alignItems: "start", // 각 아이템이 원래 높이 유지
}}
>
{rowComponents.map((comp) => (
@ -148,9 +167,19 @@ export const ResponsiveLayoutEngine: React.FC<ResponsiveLayoutEngineProps> = ({
className="responsive-grid-item"
style={{
gridColumn: `span ${comp.responsiveDisplay?.gridColumns || gridColumns}`,
height: "auto", // 자동 높이
}}
>
<DynamicComponentRenderer component={comp} isPreview={true} />
<DynamicComponentRenderer
component={comp}
isPreview={true}
isDesignMode={false}
isInteractive={true}
formData={formData}
onFormDataChange={onFormDataChange}
screenId={screenInfo?.id}
tableName={screenInfo?.tableName}
/>
</div>
))}
</div>

View File

@ -872,7 +872,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
}
}, []);
// 화면의 기본 테이블 정보 로드
// 화면의 기본 테이블 정보 로드 (원래대로 복원)
useEffect(() => {
const loadScreenTable = async () => {
const tableName = selectedScreen?.tableName;
@ -915,7 +915,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
columns,
};
setTables([tableInfo]);
setTables([tableInfo]); // 현재 화면의 테이블만 저장 (원래대로)
} catch (error) {
console.error("화면 테이블 정보 로드 실패:", error);
setTables([]);
@ -1996,7 +1996,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
"accordion-basic": 12, // 아코디언 (100%)
"table-list": 12, // 테이블 리스트 (100%)
"image-display": 4, // 이미지 표시 (33%)
"split-panel-layout": 12, // 분할 패널 레이아웃 (100%)
"split-panel-layout": 6, // 분할 패널 레이아웃 (50%)
// 액션 컴포넌트 (ACTION 카테고리)
"button-basic": 1, // 버튼 (8.33%)

View File

@ -95,7 +95,14 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
}
const newItems = [...items, createEmptyItem()];
setItems(newItems);
onChange?.(newItems);
console.log(" RepeaterInput 항목 추가, onChange 호출:", newItems);
// targetTable이 설정된 경우 각 항목에 메타데이터 추가
const dataWithMeta = config.targetTable
? newItems.map((item) => ({ ...item, _targetTable: config.targetTable }))
: newItems;
onChange?.(dataWithMeta);
};
// 항목 제거
@ -105,7 +112,13 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
}
const newItems = items.filter((_, i) => i !== index);
setItems(newItems);
onChange?.(newItems);
// targetTable이 설정된 경우 각 항목에 메타데이터 추가
const dataWithMeta = config.targetTable
? newItems.map((item) => ({ ...item, _targetTable: config.targetTable }))
: newItems;
onChange?.(dataWithMeta);
// 접힌 상태도 업데이트
const newCollapsed = new Set(collapsedItems);
@ -121,7 +134,19 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
[fieldName]: value,
};
setItems(newItems);
onChange?.(newItems);
console.log("✏️ RepeaterInput 필드 변경, onChange 호출:", {
itemIndex,
fieldName,
value,
newItems,
});
// targetTable이 설정된 경우 각 항목에 메타데이터 추가
const dataWithMeta = config.targetTable
? newItems.map((item) => ({ ...item, _targetTable: config.targetTable }))
: newItems;
onChange?.(dataWithMeta);
};
// 접기/펼치기 토글

View File

@ -18,12 +18,20 @@ export interface RepeaterConfigPanelProps {
config: RepeaterFieldGroupConfig;
onChange: (config: RepeaterFieldGroupConfig) => void;
tableColumns?: ColumnInfo[];
allTables?: Array<{ tableName: string; displayName?: string }>; // 전체 테이블 목록
onTableChange?: (tableName: string) => void; // 테이블 변경 시 해당 테이블의 컬럼 로드
}
/**
*
*/
export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({ config, onChange, tableColumns = [] }) => {
export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
config,
onChange,
tableColumns = [],
allTables = [],
onTableChange,
}) => {
const [localFields, setLocalFields] = useState<RepeaterFieldDefinition[]>(config.fields || []);
const [fieldNamePopoverOpen, setFieldNamePopoverOpen] = useState<Record<number, boolean>>({});
@ -71,8 +79,79 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({ config
handleFieldsChange(newFields);
};
// 테이블 선택 Combobox 상태
const [tableSelectOpen, setTableSelectOpen] = useState(false);
const [tableSearchValue, setTableSearchValue] = useState("");
// 필터링된 테이블 목록
const filteredTables = useMemo(() => {
if (!tableSearchValue) return allTables;
const searchLower = tableSearchValue.toLowerCase();
return allTables.filter(
(table) =>
table.tableName.toLowerCase().includes(searchLower) || table.displayName?.toLowerCase().includes(searchLower),
);
}, [allTables, tableSearchValue]);
// 선택된 테이블 표시명
const selectedTableLabel = useMemo(() => {
if (!config.targetTable) return "테이블을 선택하세요";
const table = allTables.find((t) => t.tableName === config.targetTable);
return table ? table.displayName || table.tableName : config.targetTable;
}, [config.targetTable, allTables]);
return (
<div className="space-y-4">
{/* 대상 테이블 선택 */}
<div className="space-y-2">
<Label className="text-sm font-semibold"> </Label>
<Popover open={tableSelectOpen} onOpenChange={setTableSelectOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={tableSelectOpen}
className="w-full justify-between"
>
{selectedTableLabel}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0" align="start">
<Command>
<CommandInput placeholder="테이블 검색..." value={tableSearchValue} onValueChange={setTableSearchValue} />
<CommandEmpty> .</CommandEmpty>
<CommandGroup className="max-h-64 overflow-auto">
{filteredTables.map((table) => (
<CommandItem
key={table.tableName}
value={table.tableName}
onSelect={(currentValue) => {
handleChange("targetTable", currentValue);
setTableSelectOpen(false);
setTableSearchValue("");
// 선택된 테이블의 컬럼 로드
if (onTableChange) {
onTableChange(currentValue);
}
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
config.targetTable === table.tableName ? "opacity-100" : "opacity-0",
)}
/>
{table.displayName || table.tableName}
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
<p className="text-xs text-gray-500"> .</p>
</div>
{/* 필드 정의 */}
<div className="space-y-3">
<Label className="text-sm font-semibold"> </Label>

View File

@ -77,6 +77,7 @@ export interface DynamicComponentRendererProps {
component: ComponentData;
isSelected?: boolean;
isPreview?: boolean; // 반응형 모드 플래그
isDesignMode?: boolean; // 디자인 모드 여부 (false일 때 데이터 로드)
onClick?: (e?: React.MouseEvent) => void;
onDragStart?: (e: React.DragEvent) => void;
onDragEnd?: () => void;
@ -193,8 +194,34 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
autoGeneration: component.autoGeneration,
hidden: component.hidden,
isInteractive,
isPreview, // 반응형 모드 플래그
isDesignMode: props.isDesignMode, // 디자인 모드 플래그
});
// onChange 핸들러 - 컴포넌트 타입에 따라 다르게 처리
const handleChange = (value: any) => {
console.log("🔄 DynamicComponentRenderer handleChange 호출:", {
componentType,
fieldName,
value,
valueType: typeof value,
isArray: Array.isArray(value),
});
if (onFormDataChange) {
// RepeaterInput 같은 복합 컴포넌트는 전체 데이터를 전달
// 단순 input 컴포넌트는 (fieldName, value) 형태로 전달받음
if (componentType === "repeater-field-group" || componentType === "repeater") {
// fieldName과 함께 전달
console.log("💾 RepeaterInput 데이터 저장:", fieldName, value);
onFormDataChange(fieldName, value);
} else {
// 이미 fieldName이 포함된 경우는 그대로 전달
onFormDataChange(fieldName, value);
}
}
};
// 렌더러 props 구성
const rendererProps = {
component,
@ -215,7 +242,7 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
isInteractive,
formData,
onFormDataChange,
onChange: onFormDataChange, // onChange도 전달
onChange: handleChange, // 개선된 onChange 핸들러 전달
tableName,
onRefresh,
onClose,
@ -237,6 +264,8 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
refreshKey,
// 반응형 모드 플래그 전달
isPreview,
// 디자인 모드 플래그 전달 - isPreview와 명확히 구분
isDesignMode: props.isDesignMode !== undefined ? props.isDesignMode : false,
};
// 렌더러가 클래스인지 함수인지 확인

View File

@ -88,18 +88,19 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
// 삭제 액션 감지 로직 (실제 필드명 사용)
const isDeleteAction = () => {
const deleteKeywords = ['삭제', 'delete', 'remove', '제거', 'del'];
const deleteKeywords = ["삭제", "delete", "remove", "제거", "del"];
return (
component.componentConfig?.action?.type === 'delete' ||
component.config?.action?.type === 'delete' ||
component.webTypeConfig?.actionType === 'delete' ||
component.text?.toLowerCase().includes('삭제') ||
component.text?.toLowerCase().includes('delete') ||
component.label?.toLowerCase().includes('삭제') ||
component.label?.toLowerCase().includes('delete') ||
deleteKeywords.some(keyword =>
component.config?.buttonText?.toLowerCase().includes(keyword) ||
component.config?.text?.toLowerCase().includes(keyword)
component.componentConfig?.action?.type === "delete" ||
component.config?.action?.type === "delete" ||
component.webTypeConfig?.actionType === "delete" ||
component.text?.toLowerCase().includes("삭제") ||
component.text?.toLowerCase().includes("delete") ||
component.label?.toLowerCase().includes("삭제") ||
component.label?.toLowerCase().includes("delete") ||
deleteKeywords.some(
(keyword) =>
component.config?.buttonText?.toLowerCase().includes(keyword) ||
component.config?.text?.toLowerCase().includes(keyword),
)
);
};
@ -109,9 +110,9 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
if (isDeleteAction() && !component.style?.labelColor) {
// 삭제 액션이고 라벨 색상이 설정되지 않은 경우 빨간색으로 자동 설정
if (component.style) {
component.style.labelColor = '#ef4444';
component.style.labelColor = "#ef4444";
} else {
component.style = { labelColor: '#ef4444' };
component.style = { labelColor: "#ef4444" };
}
}
}, [component.componentConfig?.action?.type, component.config?.action?.type, component.webTypeConfig?.actionType]);
@ -125,20 +126,20 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
// 🎨 동적 색상 설정 (속성편집 모달의 "색상" 필드와 연동)
const getLabelColor = () => {
if (isDeleteAction()) {
return component.style?.labelColor || '#ef4444'; // 빨간색 기본값 (Tailwind red-500)
return component.style?.labelColor || "#ef4444"; // 빨간색 기본값 (Tailwind red-500)
}
return component.style?.labelColor || '#212121'; // 검은색 기본값 (shadcn/ui primary)
return component.style?.labelColor || "#212121"; // 검은색 기본값 (shadcn/ui primary)
};
const buttonColor = getLabelColor();
// 그라데이션용 어두운 색상 계산
const getDarkColor = (baseColor: string) => {
const hex = baseColor.replace('#', '');
const hex = baseColor.replace("#", "");
const r = Math.max(0, parseInt(hex.substr(0, 2), 16) - 40);
const g = Math.max(0, parseInt(hex.substr(2, 2), 16) - 40);
const b = Math.max(0, parseInt(hex.substr(4, 2), 16) - 40);
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`;
};
const buttonDarkColor = getDarkColor(buttonColor);
@ -246,6 +247,23 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
currentLoadingToastRef.current = undefined;
}
// 실패한 경우 오류 처리
if (!success) {
console.log("❌ 액션 실패, 오류 토스트 표시");
const errorMessage =
actionConfig.errorMessage ||
(actionConfig.type === "save"
? "저장 중 오류가 발생했습니다."
: actionConfig.type === "delete"
? "삭제 중 오류가 발생했습니다."
: actionConfig.type === "submit"
? "제출 중 오류가 발생했습니다."
: "처리 중 오류가 발생했습니다.");
toast.error(errorMessage);
return;
}
// 성공한 경우에만 성공 토스트 표시
// edit 액션은 조용히 처리 (모달 열기만 하므로 토스트 불필요)
if (actionConfig.type !== "edit") {
const successMessage =
@ -268,24 +286,24 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
// 저장/수정 성공 시 자동 처리
if (actionConfig.type === "save" || actionConfig.type === "edit") {
if (typeof window !== 'undefined') {
if (typeof window !== "undefined") {
// 1. 테이블 새로고침 이벤트 먼저 발송 (모달이 닫히기 전에)
console.log("🔄 저장/수정 후 테이블 새로고침 이벤트 발송");
window.dispatchEvent(new CustomEvent('refreshTable'));
window.dispatchEvent(new CustomEvent("refreshTable"));
// 2. 모달 닫기 (약간의 딜레이)
setTimeout(() => {
// EditModal 내부인지 확인 (isInModal prop 사용)
const isInEditModal = (props as any).isInModal;
if (isInEditModal) {
console.log("🚪 EditModal 닫기 이벤트 발송");
window.dispatchEvent(new CustomEvent('closeEditModal'));
window.dispatchEvent(new CustomEvent("closeEditModal"));
}
// ScreenModal은 항상 닫기
console.log("🚪 ScreenModal 닫기 이벤트 발송");
window.dispatchEvent(new CustomEvent('closeSaveModal'));
window.dispatchEvent(new CustomEvent("closeSaveModal"));
}, 100);
}
}
@ -301,19 +319,8 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
console.error("❌ 버튼 액션 실행 오류:", error);
// 오류 토스트 표시
const errorMessage =
actionConfig.errorMessage ||
(actionConfig.type === "save"
? "저장 중 오류가 발생했습니다."
: actionConfig.type === "delete"
? "삭제 중 오류가 발생했습니다."
: actionConfig.type === "submit"
? "제출 중 오류가 발생했습니다."
: "처리 중 오류가 발생했습니다.");
console.log("💥 오류 토스트 표시:", errorMessage);
toast.error(errorMessage);
// 오류 토스트는 buttonActions.ts에서 이미 표시되므로 여기서는 제거
// (중복 토스트 방지)
}
};
@ -379,7 +386,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
console.log("⚠️ 액션 실행 조건 불만족:", {
isInteractive,
hasAction: !!processedConfig.action,
"이유": !isInteractive ? "인터랙티브 모드 아님" : "액션 없음",
: !isInteractive ? "인터랙티브 모드 아님" : "액션 없음",
});
// 액션이 설정되지 않은 경우 기본 onClick 실행
onClick?.();
@ -479,7 +486,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
maxHeight: "100%",
border: "none",
borderRadius: "8px",
background: componentConfig.disabled
background: componentConfig.disabled
? "linear-gradient(135deg, #e5e7eb 0%, #d1d5db 100%)"
: `linear-gradient(135deg, ${buttonColor} 0%, ${buttonDarkColor} 100%)`,
color: componentConfig.disabled ? "#9ca3af" : "white",
@ -495,9 +502,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
margin: "0",
lineHeight: "1",
minHeight: "36px",
boxShadow: componentConfig.disabled
? "0 1px 2px 0 rgba(0, 0, 0, 0.05)"
: `0 2px 4px 0 ${buttonColor}33`, // 33은 20% 투명도
boxShadow: componentConfig.disabled ? "0 1px 2px 0 rgba(0, 0, 0, 0.05)" : `0 2px 4px 0 ${buttonColor}33`, // 33은 20% 투명도
// isInteractive 모드에서는 사용자 스타일 우선 적용
...(isInteractive && component.style ? component.style : {}),
}}

View File

@ -64,7 +64,7 @@ export class RepeaterFieldGroupRenderer extends AutoRegisteringComponentRenderer
configPanel: RepeaterConfigPanel,
defaultSize: {
width: 600,
height: 400, // 여러 항목과 필드를 표시할 수 있도록 높이 설
height: 200, // 기본 높이 조
},
defaultConfig: {
fields: [], // 빈 배열로 시작 - 사용자가 직접 필드 추가

View File

@ -55,9 +55,9 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
// 컴포넌트 스타일
const componentStyle: React.CSSProperties = isPreview
? {
// 반응형 모드: position relative, width/height 100%
// 반응형 모드: position relative, 그리드 컨테이너가 제공하는 크기 사용
position: "relative",
width: "100%",
// width 제거 - 그리드 컬럼이 결정
height: `${component.style?.height || 600}px`,
border: "1px solid #e5e7eb",
}
@ -257,19 +257,27 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
return (
<div
ref={containerRef}
style={componentStyle}
style={
isPreview
? {
position: "relative",
height: `${component.style?.height || 600}px`,
border: "1px solid #e5e7eb",
}
: componentStyle
}
onClick={(e) => {
if (isDesignMode) {
e.stopPropagation();
onClick?.(e);
}
}}
className="flex overflow-hidden rounded-lg bg-white shadow-sm"
className={`flex overflow-hidden rounded-lg bg-white shadow-sm ${isPreview ? "w-full" : ""}`}
>
{/* 좌측 패널 */}
<div
style={{ width: `${leftWidth}%`, minWidth: `${minLeftWidth}px` }}
className="flex flex-col border-r border-gray-200"
style={{ width: `${leftWidth}%`, minWidth: isPreview ? "0" : `${minLeftWidth}px` }}
className="flex flex-shrink-0 flex-col border-r border-gray-200"
>
<Card className="flex h-full flex-col border-0 shadow-none">
<CardHeader className="border-b border-gray-100 pb-3">
@ -404,7 +412,10 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
)}
{/* 우측 패널 */}
<div style={{ width: `${100 - leftWidth}%`, minWidth: `${minRightWidth}px` }} className="flex flex-col">
<div
style={{ width: `${100 - leftWidth}%`, minWidth: isPreview ? "0" : `${minRightWidth}px` }}
className="flex flex-shrink-0 flex-col"
>
<Card className="flex h-full flex-col border-0 shadow-none">
<CardHeader className="border-b border-gray-100 pb-3">
<div className="flex items-center justify-between">

View File

@ -28,7 +28,7 @@ interface SplitPanelLayoutConfigPanelProps {
export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelProps> = ({
config,
onChange,
tables = [], // 기본값 빈 배열
tables = [], // 기본값 빈 배열 (현재 화면 테이블만)
screenTableName, // 현재 화면의 테이블명
}) => {
const [rightTableOpen, setRightTableOpen] = useState(false);
@ -36,6 +36,32 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
const [rightColumnOpen, setRightColumnOpen] = useState(false);
const [loadedTableColumns, setLoadedTableColumns] = useState<Record<string, ColumnInfo[]>>({});
const [loadingColumns, setLoadingColumns] = useState<Record<string, boolean>>({});
const [allTables, setAllTables] = useState<any[]>([]); // 조인 모드용 전체 테이블 목록
// 관계 타입
const relationshipType = config.rightPanel?.relation?.type || "detail";
// 조인 모드일 때만 전체 테이블 목록 로드
useEffect(() => {
if (relationshipType === "join") {
const loadAllTables = async () => {
try {
const { tableManagementApi } = await import("@/lib/api/tableManagement");
const response = await tableManagementApi.getTableList();
if (response.success && response.data) {
console.log("✅ 분할패널 조인 모드: 전체 테이블 목록 로드", response.data.length, "개");
setAllTables(response.data);
}
} catch (error) {
console.error("❌ 전체 테이블 목록 로드 실패:", error);
}
};
loadAllTables();
} else {
// 상세 모드일 때는 기본 테이블만 사용
setAllTables([]);
}
}, [relationshipType]);
// screenTableName이 변경되면 좌측 패널 테이블을 항상 화면 테이블로 설정
useEffect(() => {
@ -155,8 +181,13 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
);
}
// 관계 타입에 따라 우측 테이블을 자동으로 설정
const relationshipType = config.rightPanel?.relation?.type || "detail";
// 조인 모드에서 우측 테이블 선택 시 사용할 테이블 목록
const availableRightTables = relationshipType === "join" ? allTables : tables;
console.log("📊 분할패널 테이블 목록 상태:");
console.log(" - relationshipType:", relationshipType);
console.log(" - allTables:", allTables.length, "개");
console.log(" - availableRightTables:", availableRightTables.length, "개");
return (
<div className="space-y-6">
@ -285,7 +316,7 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
<CommandInput placeholder="테이블 검색..." />
<CommandEmpty> .</CommandEmpty>
<CommandGroup className="max-h-[200px] overflow-auto">
{tables.map((table) => (
{availableRightTables.map((table) => (
<CommandItem
key={table.tableName}
value={table.tableName}
@ -300,8 +331,8 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
config.rightPanel?.tableName === table.tableName ? "opacity-100" : "opacity-0",
)}
/>
{table.tableName}
<span className="ml-2 text-xs text-gray-500">({table.tableLabel || ""})</span>
{table.displayName || table.tableName}
{table.displayName && <span className="ml-2 text-xs text-gray-500">({table.tableName})</span>}
</CommandItem>
))}
</CommandGroup>

View File

@ -41,7 +41,7 @@ export const SplitPanelLayoutDefinition = createComponentDefinition({
autoLoad: true,
syncSelection: true,
} as SplitPanelLayoutConfig,
defaultSize: { width: 1000, height: 600 },
defaultSize: { width: 800, height: 600 },
configPanel: SplitPanelLayoutConfigPanel,
icon: "PanelLeftRight",
tags: ["분할", "마스터", "디테일", "레이아웃"],

View File

@ -155,14 +155,26 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
onSelectedRowsChange,
onConfigChange,
refreshKey,
tableName, // 화면의 기본 테이블명 (screenInfo에서 전달)
}) => {
// 컴포넌트 설정
const tableConfig = {
...config,
...component.config,
...componentConfig,
// selectedTable이 없으면 화면의 기본 테이블 사용
selectedTable:
componentConfig?.selectedTable || component.config?.selectedTable || config?.selectedTable || tableName,
} as TableListConfig;
console.log("🔍 TableListComponent 초기화:", {
componentConfigSelectedTable: componentConfig?.selectedTable,
componentConfigSelectedTable2: component.config?.selectedTable,
configSelectedTable: config?.selectedTable,
screenTableName: tableName,
finalSelectedTable: tableConfig.selectedTable,
});
// 🎨 동적 색상 설정 (속성편집 모달의 "색상" 필드와 연동)
const buttonColor = component.style?.labelColor || "#212121"; // 기본 파란색
const buttonTextColor = component.config?.buttonTextColor || "#ffffff";
@ -424,20 +436,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
}
};
// 디바운싱된 테이블 데이터 가져오기
const fetchTableDataDebounced = useCallback(
debouncedApiCall(
`fetchTableData_${tableConfig.selectedTable}_${currentPage}_${localPageSize}`,
async () => {
return fetchTableDataInternal();
},
200, // 200ms 디바운스
),
[tableConfig.selectedTable, currentPage, localPageSize, searchTerm, sortColumn, sortDirection, searchValues],
);
// 실제 테이블 데이터 가져오기 함수
const fetchTableDataInternal = async () => {
const fetchTableDataInternal = useCallback(async () => {
if (!tableConfig.selectedTable) {
setData([]);
return;
@ -448,81 +448,54 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
try {
// 🎯 Entity 조인 API 사용 - Entity 조인이 포함된 데이터 조회
console.log("🔗 Entity 조인 데이터 조회 시작:", tableConfig.selectedTable);
// Entity 조인 컬럼 추출 (isEntityJoin === true인 컬럼들)
const entityJoinColumns = tableConfig.columns?.filter((col) => col.isEntityJoin && col.entityJoinInfo) || [];
// 🎯 조인 탭에서 추가한 컬럼들도 포함 (실제로 존재하는 컬럼만)
const joinTabColumns =
tableConfig.columns?.filter(
(col) =>
!col.isEntityJoin &&
col.columnName.includes("_") &&
(col.columnName.includes("dept_code_") ||
col.columnName.includes("_dept_code") ||
col.columnName.includes("_company_") ||
col.columnName.includes("_user_")), // 조인 탭에서 추가한 컬럼 패턴들
) || [];
// 🎯 조인 탭에서 추가한 컬럼들 추출 (additionalJoinInfo가 있는 컬럼들)
const manualJoinColumns =
tableConfig.columns?.filter((col) => {
return col.additionalJoinInfo !== undefined;
}) || [];
console.log(
"🔍 조인 탭 컬럼들:",
joinTabColumns.map((c) => c.columnName),
"🔗 수동 조인 컬럼 감지:",
manualJoinColumns.map((c) => ({
columnName: c.columnName,
additionalJoinInfo: c.additionalJoinInfo,
})),
);
const additionalJoinColumns = [
...entityJoinColumns.map((col) => ({
// 🎯 추가 조인 컬럼 정보 구성
const additionalJoinColumns: Array<{
sourceTable: string;
sourceColumn: string;
joinAlias: string;
referenceTable?: string;
}> = [];
// Entity 조인 컬럼들
entityJoinColumns.forEach((col) => {
additionalJoinColumns.push({
sourceTable: col.entityJoinInfo!.sourceTable,
sourceColumn: col.entityJoinInfo!.sourceColumn,
joinAlias: col.entityJoinInfo!.joinAlias,
})),
// 🎯 조인 탭에서 추가한 컬럼들도 추가 (실제로 존재하는 컬럼만)
...joinTabColumns
.filter((col) => {
// 실제 API 응답에 존재하는 컬럼만 필터링
const validJoinColumns = ["dept_code_name", "dept_name"];
const isValid = validJoinColumns.includes(col.columnName);
if (!isValid) {
console.log(`🔍 조인 탭 컬럼 제외: ${col.columnName} (유효하지 않음)`);
}
return isValid;
})
.map((col) => {
// 실제 존재하는 조인 컬럼만 처리
let sourceTable = tableConfig.selectedTable;
let sourceColumn = col.columnName;
});
});
if (col.columnName === "dept_code_name" || col.columnName === "dept_name") {
sourceTable = "dept_info";
sourceColumn = "dept_code";
}
console.log(`🔍 조인 탭 컬럼 처리: ${col.columnName} -> ${sourceTable}.${sourceColumn}`);
return {
sourceTable: sourceTable || tableConfig.selectedTable || "",
sourceColumn: sourceColumn,
joinAlias: col.columnName,
};
}),
];
// 🎯 화면별 엔티티 표시 설정 생성
const screenEntityConfigs: Record<string, any> = {};
entityJoinColumns.forEach((col) => {
if (col.entityDisplayConfig) {
const sourceColumn = col.entityJoinInfo!.sourceColumn;
screenEntityConfigs[sourceColumn] = {
displayColumns: col.entityDisplayConfig.displayColumns,
separator: col.entityDisplayConfig.separator || " - ",
};
// 수동 조인 컬럼들 - 저장된 조인 정보 사용
manualJoinColumns.forEach((col) => {
if (col.additionalJoinInfo) {
additionalJoinColumns.push({
sourceTable: col.additionalJoinInfo.sourceTable,
sourceColumn: col.additionalJoinInfo.sourceColumn,
joinAlias: col.additionalJoinInfo.joinAlias,
referenceTable: col.additionalJoinInfo.referenceTable,
});
}
});
console.log("🔗 Entity 조인 컬럼:", entityJoinColumns);
console.log("🔗 조인 탭 컬럼:", joinTabColumns);
console.log("🔗 추가 Entity 조인 컬럼:", additionalJoinColumns);
// console.log("🎯 화면별 엔티티 설정:", screenEntityConfigs);
console.log("🔗 최종 추가 조인 컬럼:", additionalJoinColumns);
const result = await entityJoinApi.getTableDataWithJoins(tableConfig.selectedTable, {
page: currentPage,
@ -591,7 +564,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
sortOrder: sortDirection,
enableEntityJoin: true, // 🎯 Entity 조인 활성화
additionalJoinColumns: additionalJoinColumns.length > 0 ? additionalJoinColumns : undefined, // 추가 조인 컬럼
screenEntityConfigs: Object.keys(screenEntityConfigs).length > 0 ? screenEntityConfigs : undefined, // 🎯 화면별 엔티티 설정
});
if (result) {
@ -661,16 +633,16 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const actualApiColumns = Object.keys(result.data[0]);
console.log("🔍 API 응답의 실제 컬럼들:", actualApiColumns);
// 🎯 조인 컬럼 매핑 테이블 (사용자 설정 → API 응답)
// 실제 API 응답에 존재하는 컬럼만 매핑
const newJoinColumnMapping: Record<string, string> = {
dept_code_dept_code: "dept_code", // user_info.dept_code
dept_code_status: "status", // user_info.status (dept_info.status가 조인되지 않음)
dept_code_company_name: "dept_name", // dept_info.dept_name (company_name이 조인되지 않음)
dept_code_name: "dept_code_name", // dept_info.dept_name
dept_name: "dept_name", // dept_info.dept_name
status: "status", // user_info.status
};
// 🎯 조인 컬럼 매핑 테이블 - 동적 생성
// API 응답에 실제로 존재하는 컬럼과 사용자 설정 컬럼을 비교하여 자동 매핑
const newJoinColumnMapping: Record<string, string> = {};
processedColumns.forEach((col) => {
// API 응답에 정확히 일치하는 컬럼이 있으면 그대로 사용
if (actualApiColumns.includes(col.columnName)) {
newJoinColumnMapping[col.columnName] = col.columnName;
}
});
// 🎯 조인 컬럼 매핑 상태 업데이트
setJoinColumnMapping(newJoinColumnMapping);
@ -795,7 +767,37 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
} finally {
setLoading(false);
}
};
}, [
tableConfig.selectedTable,
tableConfig.columns,
currentPage,
localPageSize,
searchTerm,
sortColumn,
sortDirection,
searchValues,
]);
// 디바운싱된 테이블 데이터 가져오기
const fetchTableDataDebounced = useCallback(
debouncedApiCall(
`fetchTableData_${tableConfig.selectedTable}_${currentPage}_${localPageSize}`,
async () => {
return fetchTableDataInternal();
},
200, // 200ms 디바운스
),
[
tableConfig.selectedTable,
currentPage,
localPageSize,
searchTerm,
sortColumn,
sortDirection,
searchValues,
fetchTableDataInternal,
],
);
// 페이지 변경
const handlePageChange = (newPage: number) => {
@ -947,12 +949,37 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
}
}, [columnLabels]);
// 🎯 컬럼 개수와 컬럼명을 문자열로 변환하여 의존성 추적
const columnsKey = useMemo(() => {
if (!tableConfig.columns) return "";
return tableConfig.columns.map((col) => col.columnName).join(",");
}, [tableConfig.columns]);
useEffect(() => {
if (tableConfig.autoLoad && !isDesignMode) {
fetchTableDataDebounced();
// autoLoad가 undefined거나 true일 때 자동 로드 (기본값: true)
const shouldAutoLoad = tableConfig.autoLoad !== false;
console.log("🔍 TableList 데이터 로드 조건 체크:", {
shouldAutoLoad,
isDesignMode,
selectedTable: tableConfig.selectedTable,
autoLoadSetting: tableConfig.autoLoad,
willLoad: shouldAutoLoad && !isDesignMode,
});
if (shouldAutoLoad && !isDesignMode) {
console.log("✅ 테이블 데이터 로드 시작:", tableConfig.selectedTable);
fetchTableDataInternal();
} else {
console.warn("⚠️ 테이블 데이터 로드 차단:", {
reason: !shouldAutoLoad ? "autoLoad=false" : "isDesignMode=true",
shouldAutoLoad,
isDesignMode,
});
}
}, [
tableConfig.selectedTable,
columnsKey, // 🎯 컬럼이 추가/변경될 때 데이터 다시 로드 (문자열 비교)
localPageSize,
currentPage,
searchTerm,
@ -960,6 +987,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
sortDirection,
columnLabels,
searchValues,
fetchTableDataInternal, // 의존성 배열에 추가
]);
// refreshKey 변경 시 테이블 데이터 새로고침
@ -992,7 +1020,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
};
window.addEventListener("refreshTable", handleRefreshTable);
return () => {
window.removeEventListener("refreshTable", handleRefreshTable);
};
@ -1314,35 +1342,18 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
onDragEnd,
};
// 디자인 모드에서의 플레이스홀더
if (isDesignMode && !tableConfig.selectedTable) {
return (
<div style={componentStyle} className={className} {...domProps}>
<div className="flex h-full items-center justify-center rounded-2xl border-2 border-dashed border-blue-200 bg-gradient-to-br from-blue-50/30 to-indigo-50/20">
<div className="p-8 text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-blue-100 to-indigo-100 shadow-sm">
<TableIcon className="h-8 w-8 text-blue-600" />
</div>
<div className="mb-2 text-lg font-semibold text-slate-700"> </div>
<div className="rounded-full bg-white/60 px-4 py-2 text-sm text-slate-500">
</div>
</div>
</div>
</div>
);
}
// 플레이스홀더 제거 - 디자인 모드에서도 바로 테이블 표시
return (
<div
style={{ ...componentStyle, zIndex: 10 }} // 🎯 componentStyle + z-index 추가
className={cn(
"relative overflow-hidden",
"bg-white border border-gray-200/60",
"border border-gray-200/60 bg-white",
"rounded-2xl shadow-sm",
"backdrop-blur-sm",
"transition-all duration-300 ease-out",
isSelected && "ring-2 ring-blue-500/20 shadow-lg shadow-blue-500/10",
isSelected && "shadow-lg ring-2 shadow-blue-500/10 ring-blue-500/20",
className,
)}
{...domProps}
@ -1359,7 +1370,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
>
<div className="flex items-center space-x-4">
{(tableConfig.title || tableLabel) && (
<h3 className="text-xl font-semibold text-gray-800 tracking-tight">{tableConfig.title || tableLabel}</h3>
<h3 className="text-xl font-semibold tracking-tight text-gray-800">{tableConfig.title || tableLabel}</h3>
)}
</div>
@ -1377,16 +1388,14 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
size="sm"
onClick={handleRefresh}
disabled={loading}
className="group relative rounded-xl border-gray-200/60 bg-white/80 backdrop-blur-sm shadow-sm hover:shadow-md transition-all duration-200 hover:bg-gray-50/80"
className="group relative rounded-xl border-gray-200/60 bg-white/80 shadow-sm backdrop-blur-sm transition-all duration-200 hover:bg-gray-50/80 hover:shadow-md"
>
<div className="flex items-center space-x-2">
<div className="relative">
<RefreshCw className={cn("h-4 w-4 text-gray-600", loading && "animate-spin")} />
{loading && <div className="absolute -inset-1 animate-pulse rounded-full bg-blue-100/40"></div>}
</div>
<span className="text-sm font-medium text-gray-700">
{loading ? "새로고침 중..." : "새로고침"}
</span>
<span className="text-sm font-medium text-gray-700">{loading ? "새로고침 중..." : "새로고침"}</span>
</div>
</Button>
</div>
@ -1424,7 +1433,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
{/* 테이블 컨텐츠 */}
<div
className={`w-full overflow-auto flex-1`}
className={`w-full flex-1 overflow-auto`}
style={{
width: "100%",
maxWidth: "100%",
@ -1622,7 +1631,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
<TableCell
key={column.columnName}
className={cn(
"h-12 px-6 py-4 align-middle text-sm transition-all duration-200 text-gray-600",
"h-12 px-6 py-4 align-middle text-sm text-gray-600 transition-all duration-200",
`text-${column.align}`,
)}
style={{
@ -1687,7 +1696,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
</div>
{/* 푸터/페이지네이션 */}
{tableConfig.showFooter && tableConfig.pagination?.enabled && (
{/* showFooter와 pagination.enabled의 기본값은 true */}
{tableConfig.showFooter !== false && tableConfig.pagination?.enabled !== false && (
<div
className="flex flex-col items-center justify-center space-y-4 border-t border-gray-200 bg-gray-100/80 p-6"
style={{
@ -1749,7 +1759,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// 데이터는 useEffect에서 자동으로 다시 로드됨
}}
className="rounded-xl border border-gray-200/60 bg-white/80 backdrop-blur-sm px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition-all duration-200 hover:border-gray-300/60 hover:bg-white hover:shadow-md"
className="rounded-xl border border-gray-200/60 bg-white/80 px-4 py-2 text-sm font-medium text-gray-700 shadow-sm backdrop-blur-sm transition-all duration-200 hover:border-gray-300/60 hover:bg-white hover:shadow-md"
>
{(tableConfig.pagination?.pageSizeOptions || [10, 20, 50, 100]).map((size) => (
<option key={size} value={size}>
@ -1760,13 +1770,13 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
)}
{/* 페이지네이션 버튼 */}
<div className="flex items-center space-x-2 rounded-xl border border-gray-200/60 bg-white/80 backdrop-blur-sm p-1 shadow-sm">
<div className="flex items-center space-x-2 rounded-xl border border-gray-200/60 bg-white/80 p-1 shadow-sm backdrop-blur-sm">
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(1)}
disabled={currentPage === 1}
className="h-8 w-8 p-0 rounded-lg border-gray-200/60 hover:border-gray-300/60 hover:bg-gray-50/80 hover:text-gray-700 disabled:opacity-50 transition-all duration-200"
className="h-8 w-8 rounded-lg border-gray-200/60 p-0 transition-all duration-200 hover:border-gray-300/60 hover:bg-gray-50/80 hover:text-gray-700 disabled:opacity-50"
>
<ChevronsLeft className="h-4 w-4" />
</Button>
@ -1775,7 +1785,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
size="sm"
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1}
className="h-8 w-8 p-0 rounded-lg border-gray-200/60 hover:border-gray-300/60 hover:bg-gray-50/80 hover:text-gray-700 disabled:opacity-50 transition-all duration-200"
className="h-8 w-8 rounded-lg border-gray-200/60 p-0 transition-all duration-200 hover:border-gray-300/60 hover:bg-gray-50/80 hover:text-gray-700 disabled:opacity-50"
>
<ChevronLeft className="h-4 w-4" />
</Button>
@ -1791,7 +1801,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
size="sm"
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage === totalPages}
className="h-8 w-8 p-0 rounded-lg border-gray-200/60 hover:border-gray-300/60 hover:bg-gray-50/80 hover:text-gray-700 disabled:opacity-50 transition-all duration-200"
className="h-8 w-8 rounded-lg border-gray-200/60 p-0 transition-all duration-200 hover:border-gray-300/60 hover:bg-gray-50/80 hover:text-gray-700 disabled:opacity-50"
>
<ChevronRight className="h-4 w-4" />
</Button>
@ -1800,7 +1810,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
size="sm"
onClick={() => handlePageChange(totalPages)}
disabled={currentPage === totalPages}
className="h-8 w-8 p-0 rounded-lg border-gray-200/60 hover:border-gray-300/60 hover:bg-gray-50/80 hover:text-gray-700 disabled:opacity-50 transition-all duration-200"
className="h-8 w-8 rounded-lg border-gray-200/60 p-0 transition-all duration-200 hover:border-gray-300/60 hover:bg-gray-50/80 hover:text-gray-700 disabled:opacity-50"
>
<ChevronsRight className="h-4 w-4" />
</Button>

View File

@ -97,13 +97,20 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
>
>({});
// 화면 테이블명이 있으면 자동으로 설정
// 화면 테이블명이 있으면 자동으로 설정 (초기 한 번만)
useEffect(() => {
if (screenTableName && (!config.selectedTable || config.selectedTable !== screenTableName)) {
console.log("🔄 화면 테이블명 자동 설정:", screenTableName);
onChange({ selectedTable: screenTableName });
if (screenTableName && !config.selectedTable) {
// 기존 config의 모든 속성을 유지하면서 selectedTable만 추가/업데이트
const updatedConfig = {
...config,
selectedTable: screenTableName,
// 컬럼이 있으면 유지, 없으면 빈 배열
columns: config.columns || [],
};
onChange(updatedConfig);
}
}, [screenTableName, config.selectedTable, onChange]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [screenTableName]); // config.selectedTable이 없을 때만 실행되도록 의존성 최소화
// 테이블 목록 가져오기
useEffect(() => {
@ -137,25 +144,32 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
screenTableName,
);
// 컴포넌트에 명시적으로 테이블이 선택되었거나, 화면에 연결된 테이블이 있는 경우에만 컬럼 목록 표시
const shouldShowColumns = config.selectedTable || (screenTableName && config.columns && config.columns.length > 0);
// 컴포넌트에 명시적으로 테이블이 선택되었거나, 화면에 연결된 테이블이 있는 경우 컬럼 목록 표시
const shouldShowColumns = config.selectedTable || screenTableName;
if (!shouldShowColumns) {
console.log("🔧 컬럼 목록 숨김 - 명시적 테이블 선택 또는 설정된 컬럼이 없음");
console.log("🔧 컬럼 목록 숨김 - 테이블이 선택되지 않음");
setAvailableColumns([]);
return;
}
// tableColumns prop을 우선 사용하되, 컴포넌트가 명시적으로 설정되었을 때만
if (tableColumns && tableColumns.length > 0 && (config.selectedTable || config.columns?.length > 0)) {
console.log("🔧 tableColumns prop 사용:", tableColumns);
// tableColumns prop을 우선 사용
if (tableColumns && tableColumns.length > 0) {
const mappedColumns = tableColumns.map((column: any) => ({
columnName: column.columnName || column.name,
dataType: column.dataType || column.type || "text",
label: column.label || column.displayName || column.columnLabel || column.columnName || column.name,
}));
console.log("🏷️ availableColumns 설정됨:", mappedColumns);
setAvailableColumns(mappedColumns);
// selectedTable이 없으면 screenTableName으로 설정
if (!config.selectedTable && screenTableName) {
onChange({
...config,
selectedTable: screenTableName,
columns: config.columns || [],
});
}
} else if (config.selectedTable || screenTableName) {
// API에서 컬럼 정보 가져오기
const fetchColumns = async () => {
@ -190,7 +204,7 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
} else {
setAvailableColumns([]);
}
}, [config.selectedTable, screenTableName, tableColumns, config.columns]);
}, [config.selectedTable, screenTableName, tableColumns]);
// Entity 조인 컬럼 정보 가져오기
useEffect(() => {
@ -235,7 +249,7 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
// hasOnChange: !!onChange,
// onChangeType: typeof onChange,
// });
const parentValue = config[parentKey] as any;
const newConfig = {
[parentKey]: {
@ -243,7 +257,7 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
[childKey]: value,
},
};
// console.log("📤 TableListConfigPanel onChange 호출:", newConfig);
onChange(newConfig);
};
@ -275,8 +289,30 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
// 🎯 조인 컬럼 추가 (조인 탭에서 추가하는 컬럼들은 일반 컬럼으로 처리)
const addEntityColumn = (joinColumn: (typeof entityJoinColumns.availableColumns)[0]) => {
console.log("🔗 조인 컬럼 추가 요청:", {
joinColumn,
joinAlias: joinColumn.joinAlias,
columnLabel: joinColumn.columnLabel,
tableName: joinColumn.tableName,
columnName: joinColumn.columnName,
});
const existingColumn = config.columns?.find((col) => col.columnName === joinColumn.joinAlias);
if (existingColumn) return;
if (existingColumn) {
console.warn("⚠️ 이미 존재하는 컬럼:", joinColumn.joinAlias);
return;
}
// 🎯 joinTables에서 sourceColumn 찾기
const joinTableInfo = entityJoinColumns.joinTables?.find((jt: any) => jt.tableName === joinColumn.tableName);
const sourceColumn = joinTableInfo?.joinConfig?.sourceColumn || "";
console.log("🔍 조인 정보 추출:", {
tableName: joinColumn.tableName,
foundJoinTable: !!joinTableInfo,
sourceColumn,
joinConfig: joinTableInfo?.joinConfig,
});
// 조인 탭에서 추가하는 컬럼들은 일반 컬럼으로 처리 (isEntityJoin: false)
const newColumn: ColumnConfig = {
@ -289,10 +325,21 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
format: "text",
order: config.columns?.length || 0,
isEntityJoin: false, // 조인 탭에서 추가하는 컬럼은 엔티티 타입이 아님
// 🎯 추가 조인 정보 저장
additionalJoinInfo: {
sourceTable: config.selectedTable || screenTableName || "", // 기준 테이블 (예: user_info)
sourceColumn: sourceColumn, // 기준 컬럼 (예: dept_code) - joinTables에서 추출
referenceTable: joinColumn.tableName, // 참조 테이블 (예: dept_info)
joinAlias: joinColumn.joinAlias, // 조인 별칭 (예: dept_code_company_name)
},
};
handleChange("columns", [...(config.columns || []), newColumn]);
console.log("🔗 조인 컬럼 추가됨 (일반 컬럼으로 처리):", newColumn);
console.log("✅ 조인 컬럼 추가 완료:", {
columnName: newColumn.columnName,
displayName: newColumn.displayName,
totalColumns: (config.columns?.length || 0) + 1,
});
};
// 컬럼 제거
@ -309,17 +356,31 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
};
// 🎯 기존 컬럼들을 체크하여 엔티티 타입인 경우 isEntityJoin 플래그 설정
// useRef로 이전 컬럼 개수를 추적하여 새 컬럼 추가 시에만 실행
const prevColumnsLengthRef = React.useRef<number>(0);
useEffect(() => {
const currentLength = config.columns?.length || 0;
const prevLength = prevColumnsLengthRef.current;
console.log("🔍 엔티티 컬럼 감지 useEffect 실행:", {
hasColumns: !!config.columns,
columnsCount: config.columns?.length || 0,
columnsCount: currentLength,
prevColumnsCount: prevLength,
hasTableColumns: !!tableColumns,
tableColumnsCount: tableColumns?.length || 0,
selectedTable: config.selectedTable,
});
if (!config.columns || !tableColumns) {
if (!config.columns || !tableColumns || config.columns.length === 0) {
console.log("⚠️ 컬럼 또는 테이블 컬럼 정보가 없어서 엔티티 감지 스킵");
prevColumnsLengthRef.current = currentLength;
return;
}
// 컬럼 개수가 변경되지 않았고, 이미 체크한 적이 있으면 스킵
if (currentLength === prevLength && prevLength > 0) {
console.log(" 컬럼 개수 변경 없음, 엔티티 감지 스킵");
return;
}
@ -352,14 +413,14 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
...column,
isEntityJoin: true,
entityJoinInfo: {
sourceTable: config.selectedTable || "",
sourceTable: config.selectedTable || screenTableName || "",
sourceColumn: column.columnName,
joinAlias: column.columnName,
},
entityDisplayConfig: {
displayColumns: [], // 빈 배열로 초기화
separator: " - ",
sourceTable: config.selectedTable || "",
sourceTable: config.selectedTable || screenTableName || "",
joinTable: tableColumn.reference_table || tableColumn.referenceTable || "",
},
};
@ -377,7 +438,11 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
} else {
console.log(" 엔티티 컬럼 변경사항 없음");
}
}, [config.columns, tableColumns, config.selectedTable]);
// 현재 컬럼 개수를 저장
prevColumnsLengthRef.current = currentLength;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [config.columns?.length, tableColumns, config.selectedTable]); // 컬럼 개수 변경 시에만 실행
// 🎯 엔티티 컬럼의 표시 컬럼 정보 로드
const loadEntityDisplayConfig = async (column: ColumnConfig) => {
@ -400,6 +465,15 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
// entityDisplayConfig가 없으면 초기화
if (!column.entityDisplayConfig) {
console.log("🔧 entityDisplayConfig 초기화:", column.columnName);
// sourceTable을 결정: entityJoinInfo -> config.selectedTable -> screenTableName 순서
const initialSourceTable = column.entityJoinInfo?.sourceTable || config.selectedTable || screenTableName;
if (!initialSourceTable) {
console.warn("⚠️ sourceTable을 결정할 수 없어서 초기화 실패:", column.columnName);
return;
}
const updatedColumns = config.columns?.map((col) => {
if (col.columnName === column.columnName) {
return {
@ -407,7 +481,7 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
entityDisplayConfig: {
displayColumns: [],
separator: " - ",
sourceTable: config.selectedTable || "",
sourceTable: initialSourceTable,
joinTable: "",
},
};
@ -430,15 +504,34 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
console.log("🔍 entityDisplayConfig 전체 구조:", column.entityDisplayConfig);
console.log("🔍 entityDisplayConfig 키들:", Object.keys(column.entityDisplayConfig));
// sourceTable과 joinTable이 없으면 entityJoinInfo에서 가져오기
let sourceTable = column.entityDisplayConfig.sourceTable;
// sourceTable 결정 우선순위:
// 1. entityDisplayConfig.sourceTable
// 2. entityJoinInfo.sourceTable
// 3. config.selectedTable
// 4. screenTableName
let sourceTable =
column.entityDisplayConfig.sourceTable ||
column.entityJoinInfo?.sourceTable ||
config.selectedTable ||
screenTableName;
let joinTable = column.entityDisplayConfig.joinTable;
if (!sourceTable && column.entityJoinInfo) {
sourceTable = column.entityJoinInfo.sourceTable;
// sourceTable이 여전히 비어있으면 에러
if (!sourceTable) {
console.error("❌ sourceTable이 비어있어서 처리 불가:", {
columnName: column.columnName,
entityDisplayConfig: column.entityDisplayConfig,
entityJoinInfo: column.entityJoinInfo,
configSelectedTable: config.selectedTable,
screenTableName,
});
return;
}
if (!joinTable) {
console.log("✅ sourceTable 결정됨:", sourceTable);
if (!joinTable && sourceTable) {
// joinTable이 없으면 tableTypeApi로 조회해서 설정
try {
console.log("🔍 joinTable이 없어서 tableTypeApi로 조회:", sourceTable);
@ -464,10 +557,15 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
if (updatedColumns) {
handleChange("columns", updatedColumns);
}
} else {
console.warn("⚠️ tableTypeApi에서 조인 테이블 정보를 찾지 못함:", column.columnName);
}
} catch (error) {
console.error("tableTypeApi 컬럼 정보 조회 실패:", error);
console.log("❌ 조회 실패 상세:", { sourceTable, columnName: column.columnName });
}
} else if (!joinTable) {
console.warn("⚠️ sourceTable이 없어서 joinTable 조회 불가:", column.columnName);
}
console.log("🔍 최종 추출한 값:", { sourceTable, joinTable });
@ -789,15 +887,13 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
<div className="space-y-4 border-t pt-4">
<div className="space-y-3">
<Label className="text-sm font-medium"> </Label>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="cards-per-row"> </Label>
<Select
value={config.cardConfig?.cardsPerRow?.toString() || "3"}
onValueChange={(value) =>
handleNestedChange("cardConfig", "cardsPerRow", parseInt(value))
}
onValueChange={(value) => handleNestedChange("cardConfig", "cardsPerRow", parseInt(value))}
>
<SelectTrigger>
<SelectValue />
@ -819,9 +915,7 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
id="card-spacing"
type="number"
value={config.cardConfig?.cardSpacing || 16}
onChange={(e) =>
handleNestedChange("cardConfig", "cardSpacing", parseInt(e.target.value))
}
onChange={(e) => handleNestedChange("cardConfig", "cardSpacing", parseInt(e.target.value))}
min="0"
max="50"
/>
@ -830,15 +924,13 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
<div className="space-y-3">
<Label className="text-sm font-medium"> </Label>
<div className="grid grid-cols-1 gap-3">
<div className="space-y-2">
<Label htmlFor="id-column">ID ( )</Label>
<Select
value={config.cardConfig?.idColumn || ""}
onValueChange={(value) =>
handleNestedChange("cardConfig", "idColumn", value)
}
onValueChange={(value) => handleNestedChange("cardConfig", "idColumn", value)}
>
<SelectTrigger>
<SelectValue placeholder="ID 컬럼 선택" />
@ -857,9 +949,7 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
<Label htmlFor="title-column"> ( )</Label>
<Select
value={config.cardConfig?.titleColumn || ""}
onValueChange={(value) =>
handleNestedChange("cardConfig", "titleColumn", value)
}
onValueChange={(value) => handleNestedChange("cardConfig", "titleColumn", value)}
>
<SelectTrigger>
<SelectValue placeholder="제목 컬럼 선택" />
@ -878,9 +968,7 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
<Label htmlFor="subtitle-column"> ( )</Label>
<Select
value={config.cardConfig?.subtitleColumn || ""}
onValueChange={(value) =>
handleNestedChange("cardConfig", "subtitleColumn", value)
}
onValueChange={(value) => handleNestedChange("cardConfig", "subtitleColumn", value)}
>
<SelectTrigger>
<SelectValue placeholder="서브 제목 컬럼 선택 (선택사항)" />
@ -900,9 +988,7 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
<Label htmlFor="description-column"> </Label>
<Select
value={config.cardConfig?.descriptionColumn || ""}
onValueChange={(value) =>
handleNestedChange("cardConfig", "descriptionColumn", value)
}
onValueChange={(value) => handleNestedChange("cardConfig", "descriptionColumn", value)}
>
<SelectTrigger>
<SelectValue placeholder="설명 컬럼 선택 (선택사항)" />
@ -924,7 +1010,7 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
<Checkbox
id="show-card-actions"
checked={config.cardConfig?.showActions !== false}
onCheckedChange={(checked) =>
onCheckedChange={(checked) =>
handleNestedChange("cardConfig", "showActions", checked as boolean)
}
/>
@ -1270,7 +1356,34 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
<Button
variant="outline"
size="sm"
onClick={() => loadEntityDisplayConfig(column)}
onClick={() => {
// sourceTable 정보가 있는지 확인
const hasSourceTable =
column.entityDisplayConfig?.sourceTable ||
column.entityJoinInfo?.sourceTable ||
config.selectedTable ||
screenTableName;
if (!hasSourceTable) {
console.error("❌ sourceTable 정보를 찾을 수 없어서 컬럼 로드 불가:", {
columnName: column.columnName,
entityDisplayConfig: column.entityDisplayConfig,
entityJoinInfo: column.entityJoinInfo,
configSelectedTable: config.selectedTable,
screenTableName,
});
alert("컬럼 정보를 로드할 수 없습니다. 테이블 정보가 없습니다.");
return;
}
loadEntityDisplayConfig(column);
}}
disabled={
!column.entityDisplayConfig?.sourceTable &&
!column.entityJoinInfo?.sourceTable &&
!config.selectedTable &&
!screenTableName
}
className="h-6 text-xs"
>
<Plus className="mr-1 h-3 w-3" />

View File

@ -72,21 +72,29 @@ export interface ColumnConfig {
// 새로운 기능들
hidden?: boolean; // 숨김 기능 (편집기에서는 연하게, 실제 화면에서는 숨김)
autoGeneration?: AutoGenerationConfig; // 자동생성 설정
// 🎯 추가 조인 컬럼 정보 (조인 탭에서 추가한 컬럼들)
additionalJoinInfo?: {
sourceTable: string; // 원본 테이블
sourceColumn: string; // 원본 컬럼 (예: dept_code)
referenceTable?: string; // 참조 테이블 (예: dept_info)
joinAlias: string; // 조인 별칭 (예: dept_code_company_name)
};
}
/**
*
*/
export interface CardDisplayConfig {
idColumn: string; // ID 컬럼 (사번 등)
titleColumn: string; // 제목 컬럼 (이름 등)
subtitleColumn?: string; // 부제목 컬럼 (부서 등)
idColumn: string; // ID 컬럼 (사번 등)
titleColumn: string; // 제목 컬럼 (이름 등)
subtitleColumn?: string; // 부제목 컬럼 (부서 등)
descriptionColumn?: string; // 설명 컬럼
imageColumn?: string; // 이미지 컬럼
cardsPerRow: number; // 한 행당 카드 수 (기본: 3)
cardSpacing: number; // 카드 간격 (기본: 16px)
showActions: boolean; // 액션 버튼 표시 여부
cardHeight?: number; // 카드 높이 (기본: auto)
imageColumn?: string; // 이미지 컬럼
cardsPerRow: number; // 한 행당 카드 수 (기본: 3)
cardSpacing: number; // 카드 간격 (기본: 16px)
showActions: boolean; // 액션 버튼 표시 여부
cardHeight?: number; // 카드 높이 (기본: auto)
}
/**
@ -163,11 +171,11 @@ export interface CheckboxConfig {
*/
export interface TableListConfig extends ComponentConfig {
// 표시 모드 설정
displayMode?: "table" | "card"; // 기본: "table"
displayMode?: "table" | "card"; // 기본: "table"
// 카드 디스플레이 설정 (displayMode가 "card"일 때 사용)
cardConfig?: CardDisplayConfig;
// 테이블 기본 설정
selectedTable?: string;
tableName?: string;

View File

@ -210,6 +210,12 @@ export class EnhancedFormService {
* ( )
*/
private async getTableColumns(tableName: string): Promise<ColumnInfo[]> {
// tableName이 비어있으면 빈 배열 반환
if (!tableName || tableName.trim() === "") {
console.warn("⚠️ getTableColumns: tableName이 비어있음");
return [];
}
// 캐시 확인
const cached = this.columnCache.get(tableName);
if (cached) {

View File

@ -119,9 +119,12 @@ export const DynamicComponentConfigPanel: React.FC<ComponentConfigPanelProps> =
}) => {
console.log(`🔥 DynamicComponentConfigPanel 렌더링 시작: ${componentId}`);
// 모든 useState를 최상단에 선언 (Hooks 규칙)
const [ConfigPanelComponent, setConfigPanelComponent] = React.useState<React.ComponentType<any> | null>(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [selectedTableColumns, setSelectedTableColumns] = React.useState(tableColumns);
const [allTablesList, setAllTablesList] = React.useState<any[]>([]);
React.useEffect(() => {
let mounted = true;
@ -155,6 +158,29 @@ export const DynamicComponentConfigPanel: React.FC<ComponentConfigPanelProps> =
};
}, [componentId]);
// tableColumns가 변경되면 selectedTableColumns도 업데이트
React.useEffect(() => {
setSelectedTableColumns(tableColumns);
}, [tableColumns]);
// RepeaterConfigPanel인 경우에만 전체 테이블 목록 로드
React.useEffect(() => {
if (componentId === "repeater-field-group") {
const loadAllTables = async () => {
try {
const { tableManagementApi } = await import("@/lib/api/tableManagement");
const response = await tableManagementApi.getTableList();
if (response.success && response.data) {
setAllTablesList(response.data);
}
} catch (error) {
console.error("전체 테이블 목록 로드 실패:", error);
}
};
loadAllTables();
}
}, [componentId]);
if (loading) {
return (
<div className="rounded-md border border-dashed border-gray-300 bg-gray-50 p-4">
@ -199,16 +225,63 @@ export const DynamicComponentConfigPanel: React.FC<ComponentConfigPanelProps> =
tableColumns: Array.isArray(tableColumns) ? tableColumns.length : tableColumns,
tables: Array.isArray(tables) ? tables.length : tables,
tablesType: typeof tables,
tablesDetail: tables, // 전체 테이블 목록 확인
});
// 테이블 변경 핸들러 - 선택된 테이블의 컬럼을 동적으로 로드
const handleTableChange = async (tableName: string) => {
console.log("🔄 테이블 변경:", tableName);
try {
// 먼저 tables에서 찾아보기 (이미 컬럼이 있는 경우)
const existingTable = tables?.find((t) => t.tableName === tableName);
if (existingTable && existingTable.columns && existingTable.columns.length > 0) {
console.log("✅ 캐시된 테이블 컬럼 사용:", existingTable.columns.length, "개");
setSelectedTableColumns(existingTable.columns);
return;
}
// 컬럼이 없으면 tableTypeApi로 조회 (ScreenDesigner와 동일한 방식)
console.log("🔍 테이블 컬럼 API 조회:", tableName);
const { tableTypeApi } = await import("@/lib/api/screen");
const columnsResponse = await tableTypeApi.getColumns(tableName);
console.log("🔍 컬럼 응답 데이터:", columnsResponse);
const columns = (columnsResponse || []).map((col: any) => ({
tableName: col.tableName || tableName,
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,
webType: col.webType || col.web_type,
input_type: col.inputType || col.input_type,
widgetType: col.widgetType || col.widget_type || col.webType || col.web_type,
isNullable: col.isNullable || col.is_nullable,
required: col.required !== undefined ? col.required : col.isNullable === "NO" || col.is_nullable === "NO",
columnDefault: col.columnDefault || col.column_default,
characterMaximumLength: col.characterMaximumLength || col.character_maximum_length,
codeCategory: col.codeCategory || col.code_category,
codeValue: col.codeValue || col.code_value,
}));
console.log("✅ 테이블 컬럼 로드 성공:", columns.length, "개");
setSelectedTableColumns(columns);
} catch (error) {
console.error("❌ 테이블 변경 오류:", error);
// 오류 발생 시 빈 배열
setSelectedTableColumns([]);
}
};
return (
<ConfigPanelComponent
config={config}
onChange={onChange}
onConfigChange={onChange} // TableListConfigPanel을 위한 추가 prop
screenTableName={screenTableName}
tableColumns={tableColumns}
tables={tables} // 전체 테이블 목록 전달
tableColumns={selectedTableColumns} // 동적으로 변경되는 컬럼 전달
tables={tables} // 기본 테이블 목록 (현재 화면의 테이블만)
allTables={componentId === "repeater-field-group" ? allTablesList : tables} // RepeaterConfigPanel만 전체 테이블
onTableChange={handleTableChange} // 테이블 변경 핸들러 전달
/>
);
};

View File

@ -18,11 +18,12 @@ export function generateSmartDefaults(
screenWidth: number = 1920,
rowComponentCount: number = 1, // 같은 행에 있는 컴포넌트 개수
): ResponsiveComponentConfig["responsive"] {
// 특정 컴포넌트는 항상 전체 너비 (split-panel-layout, datatable 등)
const fullWidthComponents = ["split-panel-layout", "datatable", "data-table"];
// 특정 컴포넌트는 항상 전체 너비 (datatable, table-list 등)
const fullWidthComponents = ["datatable", "data-table", "table-list", "repeater-field-group"];
const componentId = (component as any).componentId || (component as any).id;
const componentType = (component as any).componentType || component.type;
if (fullWidthComponents.includes(componentType)) {
if (fullWidthComponents.includes(componentId) || fullWidthComponents.includes(componentType)) {
return {
desktop: {
gridColumns: 12, // 전체 너비

View File

@ -29,6 +29,7 @@ export interface RepeaterFieldDefinition {
*/
export interface RepeaterFieldGroupConfig {
fields: RepeaterFieldDefinition[]; // 반복될 필드 정의
targetTable?: string; // 저장할 대상 테이블 (미지정 시 메인 화면 테이블)
minItems?: number; // 최소 항목 수
maxItems?: number; // 최대 항목 수
addButtonText?: string; // 추가 버튼 텍스트