엔티티조인 읽기전용 컬럼 추가

This commit is contained in:
kjs
2026-01-15 10:39:23 +09:00
parent 71af4dfc6b
commit 19dbe59e3a
8 changed files with 1011 additions and 591 deletions

View File

@@ -629,6 +629,10 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
const fieldName = columnName || comp.id;
const currentValue = formData[fieldName] || "";
// 🆕 엔티티 조인 컬럼은 읽기 전용으로 처리
const isEntityJoin = (comp as any).isEntityJoin === true;
const isReadonly = readonly || isEntityJoin;
// 다국어 라벨 적용 (langKey가 있으면 번역 텍스트 사용)
const compLangKey = (comp as any).langKey;
const label = compLangKey && translations[compLangKey] ? translations[compLangKey] : originalLabel;
@@ -745,7 +749,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
placeholder={isAutoInput ? `자동입력: ${config?.autoValueType}` : finalPlaceholder}
value={displayValue}
onChange={isAutoInput ? undefined : handleInputChange}
disabled={readonly || isAutoInput}
disabled={isReadonly || isAutoInput}
readOnly={isAutoInput}
required={required}
minLength={config?.minLength}
@@ -786,7 +790,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
placeholder={finalPlaceholder}
value={currentValue}
onChange={(e) => updateFormData(fieldName, e.target.valueAsNumber || 0)}
disabled={readonly}
disabled={isReadonly}
required={required}
min={config?.min}
max={config?.max}
@@ -825,7 +829,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
placeholder={finalPlaceholder}
value={currentValue || config?.defaultValue || ""}
onChange={(e) => updateFormData(fieldName, e.target.value)}
disabled={readonly}
disabled={isReadonly}
required={required}
minLength={config?.minLength}
maxLength={config?.maxLength}
@@ -877,7 +881,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
value={currentValue}
onChange={(value) => updateFormData(fieldName, value)}
placeholder={finalPlaceholder}
disabled={readonly}
disabled={isReadonly}
required={required}
/>,
);
@@ -895,7 +899,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
value={currentValue}
onChange={(value) => updateFormData(fieldName, value)}
placeholder={finalPlaceholder}
disabled={readonly}
disabled={isReadonly}
required={required}
/>,
);
@@ -912,7 +916,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
<Select
value={currentValue || config?.defaultValue || ""}
onValueChange={(value) => updateFormData(fieldName, value)}
disabled={readonly}
disabled={isReadonly}
required={required}
>
<SelectTrigger className="h-full w-full">
@@ -959,7 +963,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
id={fieldName}
checked={isChecked}
onCheckedChange={(checked) => updateFormData(fieldName, checked)}
disabled={readonly}
disabled={isReadonly}
required={required}
/>
<label htmlFor={fieldName} className="text-sm">
@@ -1005,7 +1009,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
value=""
checked={selectedValue === ""}
onChange={(e) => updateFormData(fieldName, e.target.value)}
disabled={readonly}
disabled={isReadonly}
required={required}
className="h-4 w-4"
/>
@@ -1023,7 +1027,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
value={option.value}
checked={selectedValue === option.value}
onChange={(e) => updateFormData(fieldName, e.target.value)}
disabled={readonly || option.disabled}
disabled={isReadonly || option.disabled}
required={required}
className="h-4 w-4"
/>
@@ -1064,7 +1068,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
placeholder={finalPlaceholder}
value={currentValue || config?.defaultValue || ""}
onChange={(e) => updateFormData(fieldName, e.target.value)}
disabled={readonly}
disabled={isReadonly}
required={required}
min={config?.minDate}
max={config?.maxDate}
@@ -1081,7 +1085,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
<Button
variant="outline"
className="h-full w-full justify-start text-left font-normal"
disabled={readonly}
disabled={isReadonly}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{dateValue ? format(dateValue, "PPP", { locale: ko }) : config?.defaultValue || finalPlaceholder}
@@ -1124,7 +1128,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
placeholder={finalPlaceholder}
value={currentValue || config?.defaultValue || ""}
onChange={(e) => updateFormData(fieldName, e.target.value)}
disabled={readonly}
disabled={isReadonly}
required={required}
min={config?.minDate}
max={config?.maxDate}
@@ -1301,7 +1305,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
type="file"
data-field={fieldName}
onChange={handleFileChange}
disabled={readonly}
disabled={isReadonly}
required={required}
multiple={config?.multiple}
accept={config?.accept}
@@ -1409,7 +1413,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
<Select
value={currentValue || ""}
onValueChange={(value) => updateFormData(fieldName, value)}
disabled={readonly}
disabled={isReadonly}
required={required}
>
<SelectTrigger className="h-full w-full">
@@ -1947,7 +1951,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
return applyStyles(
<button
onClick={handleButtonClick}
disabled={readonly}
disabled={isReadonly}
className={`focus:ring-ring w-full rounded-md px-3 py-2 text-sm font-medium transition-colors focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:pointer-events-none disabled:opacity-50 ${
hasCustomColors
? ""
@@ -1972,7 +1976,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
placeholder={placeholder || "입력하세요..."}
value={currentValue}
onChange={(e) => updateFormData(fieldName, e.target.value)}
disabled={readonly}
disabled={isReadonly}
required={required}
className="w-full"
style={{ height: "100%" }}

View File

@@ -2727,14 +2727,17 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
componentWidth,
});
// 엔티티 조인 컬럼인 경우 읽기 전용으로 설정
const isEntityJoinColumn = column.isEntityJoin === true;
newComponent = {
id: generateComponentId(),
type: "component", // ✅ Unified 컴포넌트 시스템 사용
label: column.columnLabel || column.columnName,
tableName: table.tableName,
columnName: column.columnName,
required: column.required,
readonly: false,
required: isEntityJoinColumn ? false : column.required, // 조인 컬럼은 필수 아님
readonly: isEntityJoinColumn, // 🆕 엔티티 조인 컬럼은 읽기 전용
parentId: formContainerId, // 폼 컨테이너의 자식으로 설정
componentType: unifiedMapping.componentType, // unified-input, unified-select 등
position: { x: relativeX, y: relativeY, z: 1 } as Position,
@@ -2744,6 +2747,12 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
column.codeCategory && {
codeCategory: column.codeCategory,
}),
// 엔티티 조인 정보 저장
...(isEntityJoinColumn && {
isEntityJoin: true,
entityJoinTable: column.entityJoinTable,
entityJoinColumn: column.entityJoinColumn,
}),
style: {
labelDisplay: false, // 라벨 숨김
labelFontSize: "12px",
@@ -2754,6 +2763,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
componentConfig: {
type: unifiedMapping.componentType, // unified-input, unified-select 등
...unifiedMapping.componentConfig, // Unified 컴포넌트 기본 설정
...(isEntityJoinColumn && { disabled: true }), // 🆕 조인 컬럼은 비활성화
},
};
} else {
@@ -2784,14 +2794,17 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
componentWidth,
});
// 엔티티 조인 컬럼인 경우 읽기 전용으로 설정
const isEntityJoinColumn = column.isEntityJoin === true;
newComponent = {
id: generateComponentId(),
type: "component", // ✅ Unified 컴포넌트 시스템 사용
label: column.columnLabel || column.columnName, // 컬럼 라벨 우선, 없으면 컬럼명
tableName: table.tableName,
columnName: column.columnName,
required: column.required,
readonly: false,
required: isEntityJoinColumn ? false : column.required, // 조인 컬럼은 필수 아님
readonly: isEntityJoinColumn, // 🆕 엔티티 조인 컬럼은 읽기 전용
componentType: unifiedMapping.componentType, // unified-input, unified-select 등
position: { x, y, z: 1 } as Position,
size: { width: componentWidth, height: getDefaultHeight(column.widgetType) },
@@ -2800,6 +2813,12 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
column.codeCategory && {
codeCategory: column.codeCategory,
}),
// 엔티티 조인 정보 저장
...(isEntityJoinColumn && {
isEntityJoin: true,
entityJoinTable: column.entityJoinTable,
entityJoinColumn: column.entityJoinColumn,
}),
style: {
labelDisplay: false, // 라벨 숨김
labelFontSize: "14px",
@@ -2810,6 +2829,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
componentConfig: {
type: unifiedMapping.componentType, // unified-input, unified-select 등
...unifiedMapping.componentConfig, // Unified 컴포넌트 기본 설정
...(isEntityJoinColumn && { disabled: true }), // 🆕 조인 컬럼은 비활성화
},
};
}

View File

@@ -1,9 +1,38 @@
"use client";
import React from "react";
import React, { useState, useEffect } from "react";
import { Badge } from "@/components/ui/badge";
import { Database, Type, Hash, Calendar, CheckSquare, List, AlignLeft, Code, Building, File } from "lucide-react";
import {
Database,
Type,
Hash,
Calendar,
CheckSquare,
List,
AlignLeft,
Code,
Building,
File,
Link2,
ChevronDown,
ChevronRight,
} from "lucide-react";
import { TableInfo, WebType } from "@/types/screen";
import { entityJoinApi } from "@/lib/api/entityJoin";
interface EntityJoinColumn {
columnName: string;
columnLabel: string;
dataType: string;
inputType?: string;
description?: string;
}
interface EntityJoinTable {
tableName: string;
currentDisplayColumn: string;
availableColumns: EntityJoinColumn[];
}
interface TablesPanelProps {
tables: TableInfo[];
@@ -53,15 +82,90 @@ export const TablesPanel: React.FC<TablesPanelProps> = ({
onDragStart,
placedColumns = new Set(),
}) => {
// 엔티티 조인 컬럼 상태
const [entityJoinTables, setEntityJoinTables] = useState<EntityJoinTable[]>([]);
const [loadingEntityJoins, setLoadingEntityJoins] = useState(false);
const [expandedJoinTables, setExpandedJoinTables] = useState<Set<string>>(new Set());
// 시스템 컬럼 목록 (숨김 처리)
const systemColumns = new Set([
'id',
'created_date',
'updated_date',
'writer',
'company_code'
"id",
"created_date",
"updated_date",
"writer",
"company_code",
]);
// 메인 테이블명 추출
const mainTableName = tables[0]?.tableName;
// 엔티티 조인 컬럼 로드
useEffect(() => {
const fetchEntityJoinColumns = async () => {
if (!mainTableName) {
setEntityJoinTables([]);
return;
}
setLoadingEntityJoins(true);
try {
const result = await entityJoinApi.getEntityJoinColumns(mainTableName);
setEntityJoinTables(result.joinTables || []);
// 기본적으로 모든 조인 테이블 펼치기
setExpandedJoinTables(new Set(result.joinTables?.map((t) => t.tableName) || []));
} catch (error) {
console.error("엔티티 조인 컬럼 조회 오류:", error);
setEntityJoinTables([]);
} finally {
setLoadingEntityJoins(false);
}
};
fetchEntityJoinColumns();
}, [mainTableName]);
// 조인 테이블 펼치기/접기 토글
const toggleJoinTable = (tableName: string) => {
setExpandedJoinTables((prev) => {
const newSet = new Set(prev);
if (newSet.has(tableName)) {
newSet.delete(tableName);
} else {
newSet.add(tableName);
}
return newSet;
});
};
// 엔티티 조인 컬럼 드래그 핸들러
const handleEntityJoinDragStart = (
e: React.DragEvent,
joinTable: EntityJoinTable,
column: EntityJoinColumn,
) => {
// "테이블명.컬럼명" 형식으로 컬럼 정보 생성
const fullColumnName = `${joinTable.tableName}.${column.columnName}`;
const columnData = {
columnName: fullColumnName,
columnLabel: column.columnLabel || column.columnName,
dataType: column.dataType,
widgetType: "text" as WebType,
isEntityJoin: true,
entityJoinTable: joinTable.tableName,
entityJoinColumn: column.columnName,
};
// 기존 테이블 정보를 기반으로 가상의 테이블 정보 생성
const virtualTable: TableInfo = {
tableName: mainTableName || "",
tableLabel: tables[0]?.tableLabel || mainTableName || "",
columns: [columnData],
};
onDragStart(e, virtualTable, columnData);
};
// 이미 배치된 컬럼과 시스템 컬럼을 제외한 테이블 정보 생성
const tablesWithAvailableColumns = tables.map((table) => ({
...table,
@@ -126,18 +230,19 @@ export const TablesPanel: React.FC<TablesPanelProps> = ({
{table.columns.map((column) => (
<div
key={column.columnName}
className="hover:bg-accent/50 flex cursor-grab items-center justify-between rounded-md p-2 transition-colors"
className="hover:bg-accent/50 flex cursor-grab items-center gap-2 rounded-md p-2 transition-colors"
draggable
onDragStart={(e) => onDragStart(e, table, column)}
>
<div className="flex min-w-0 flex-1 items-center gap-2">
{getWidgetIcon(column.widgetType)}
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-medium">{column.columnLabel || column.columnName}</div>
<div className="text-muted-foreground truncate text-[10px]">{column.dataType}</div>
{getWidgetIcon(column.widgetType)}
<div className="min-w-0 flex-1">
<div
className="text-xs font-medium"
title={column.columnLabel || column.columnName}
>
{column.columnLabel || column.columnName}
</div>
</div>
<div className="flex flex-shrink-0 items-center gap-1">
<Badge variant="secondary" className="h-4 px-1.5 text-[10px]">
{column.widgetType}
@@ -153,6 +258,103 @@ export const TablesPanel: React.FC<TablesPanelProps> = ({
</div>
</div>
))}
{/* 엔티티 조인 컬럼 섹션 */}
{entityJoinTables.length > 0 && (
<div className="mt-4 space-y-2">
<div className="flex items-center gap-2 px-2 py-1">
<Link2 className="h-3.5 w-3.5 text-cyan-600" />
<span className="text-muted-foreground text-xs font-medium"> </span>
<Badge variant="outline" className="h-4 px-1.5 text-[10px]">
{entityJoinTables.length}
</Badge>
</div>
{entityJoinTables.map((joinTable) => {
const isExpanded = expandedJoinTables.has(joinTable.tableName);
// 검색어로 필터링
const filteredColumns = searchTerm
? joinTable.availableColumns.filter(
(col) =>
col.columnName.toLowerCase().includes(searchTerm.toLowerCase()) ||
col.columnLabel.toLowerCase().includes(searchTerm.toLowerCase()),
)
: joinTable.availableColumns;
// 검색 결과가 없으면 표시하지 않음
if (searchTerm && filteredColumns.length === 0) {
return null;
}
return (
<div key={joinTable.tableName} className="space-y-1">
{/* 조인 테이블 헤더 */}
<div
className="flex cursor-pointer items-center justify-between rounded-md bg-cyan-50 p-2 hover:bg-cyan-100"
onClick={() => toggleJoinTable(joinTable.tableName)}
>
<div className="flex items-center gap-2">
{isExpanded ? (
<ChevronDown className="h-3 w-3 text-cyan-600" />
) : (
<ChevronRight className="h-3 w-3 text-cyan-600" />
)}
<Building className="h-3.5 w-3.5 text-cyan-600" />
<span className="text-xs font-semibold text-cyan-800">{joinTable.tableName}</span>
<Badge variant="secondary" className="h-4 px-1.5 text-[10px]">
{filteredColumns.length}
</Badge>
</div>
</div>
{/* 조인 컬럼 목록 */}
{isExpanded && (
<div className="space-y-1 pl-4">
{filteredColumns.map((column) => {
const fullColumnName = `${joinTable.tableName}.${column.columnName}`;
const isPlaced = placedColumns.has(fullColumnName);
if (isPlaced) return null;
return (
<div
key={column.columnName}
className="flex cursor-grab items-center gap-2 rounded-md border border-cyan-200 bg-cyan-50/50 p-2 transition-colors hover:bg-cyan-100"
draggable
onDragStart={(e) => handleEntityJoinDragStart(e, joinTable, column)}
title="읽기 전용 - 조인된 테이블에서 참조"
>
<Link2 className="h-3 w-3 flex-shrink-0 text-cyan-500" />
<div className="min-w-0 flex-1">
<div className="text-xs font-medium" title={column.columnLabel || column.columnName}>
{column.columnLabel || column.columnName}
</div>
</div>
<div className="flex flex-shrink-0 items-center gap-1">
<Badge variant="secondary" className="h-4 border-gray-300 bg-gray-100 px-1 text-[9px] text-gray-600">
</Badge>
<Badge variant="outline" className="h-4 border-cyan-300 px-1.5 text-[10px]">
{column.inputType || "text"}
</Badge>
</div>
</div>
);
})}
</div>
)}
</div>
);
})}
</div>
)}
{/* 로딩 표시 */}
{loadingEntityJoins && (
<div className="text-muted-foreground flex items-center justify-center py-4 text-xs">
...
</div>
)}
</div>
</div>
</div>