feature/screen-management #39

Merged
kjs merged 20 commits from feature/screen-management into dev 2025-09-18 18:50:55 +09:00
8 changed files with 523 additions and 9 deletions
Showing only changes of commit 049d8ed295 - Show all commits

View File

@ -25,6 +25,7 @@ export class EntityJoinController {
sortBy,
sortOrder = "asc",
enableEntityJoin = true,
additionalJoinColumns, // 추가 조인 컬럼 정보 (JSON 문자열)
userLang, // userLang은 별도로 분리하여 search에 포함되지 않도록 함
...otherParams
} = req.query;
@ -49,6 +50,21 @@ export class EntityJoinController {
}
}
// 추가 조인 컬럼 정보 처리
let parsedAdditionalJoinColumns: any[] = [];
if (additionalJoinColumns) {
try {
parsedAdditionalJoinColumns =
typeof additionalJoinColumns === "string"
? JSON.parse(additionalJoinColumns)
: additionalJoinColumns;
logger.info("추가 조인 컬럼 파싱 완료:", parsedAdditionalJoinColumns);
} catch (error) {
logger.warn("추가 조인 컬럼 파싱 오류:", error);
parsedAdditionalJoinColumns = [];
}
}
const result = await tableManagementService.getTableDataWithEntityJoins(
tableName,
{
@ -62,6 +78,7 @@ export class EntityJoinController {
sortOrder: sortOrder as string,
enableEntityJoin:
enableEntityJoin === "true" || enableEntityJoin === true,
additionalJoinColumns: parsedAdditionalJoinColumns,
}
);
@ -295,6 +312,124 @@ export class EntityJoinController {
}
}
/**
* Entity
* GET /api/table-management/tables/:tableName/entity-join-columns
*/
async getEntityJoinColumns(req: Request, res: Response): Promise<void> {
try {
const { tableName } = req.params;
logger.info(`Entity 조인 컬럼 조회: ${tableName}`);
// 1. 현재 테이블의 Entity 조인 설정 조회
const joinConfigs = await entityJoinService.detectEntityJoins(tableName);
if (joinConfigs.length === 0) {
res.status(200).json({
success: true,
message: "Entity 조인 설정이 없습니다.",
data: {
tableName,
joinTables: [],
availableColumns: [],
},
});
return;
}
// 2. 각 조인 테이블의 컬럼 정보 조회
const joinTablesInfo = await Promise.all(
joinConfigs.map(async (config) => {
try {
const columns =
await tableManagementService.getReferenceTableColumns(
config.referenceTable
);
// 현재 display_column으로 사용 중인 컬럼 제외
const availableColumns = columns.filter(
(col) => col.columnName !== config.displayColumn
);
return {
joinConfig: config,
tableName: config.referenceTable,
currentDisplayColumn: config.displayColumn,
availableColumns: availableColumns.map((col) => ({
columnName: col.columnName,
columnLabel: col.displayName || col.columnName,
dataType: col.dataType,
isNullable: true, // 기본값으로 설정
maxLength: undefined, // 정보가 없으므로 undefined
description: col.displayName,
})),
};
} catch (error) {
logger.warn(
`참조 테이블 컬럼 조회 실패: ${config.referenceTable}`,
error
);
return {
joinConfig: config,
tableName: config.referenceTable,
currentDisplayColumn: config.displayColumn,
availableColumns: [],
error: error instanceof Error ? error.message : "Unknown error",
};
}
})
);
// 3. 사용 가능한 모든 컬럼 목록 생성 (중복 제거)
const allAvailableColumns: Array<{
tableName: string;
columnName: string;
columnLabel: string;
dataType: string;
joinAlias: string;
suggestedLabel: string;
}> = [];
joinTablesInfo.forEach((info) => {
info.availableColumns.forEach((col) => {
const joinAlias = `${info.joinConfig.sourceColumn}_${col.columnName}`;
const suggestedLabel = col.columnLabel; // 라벨명만 사용
allAvailableColumns.push({
tableName: info.tableName,
columnName: col.columnName,
columnLabel: col.columnLabel,
dataType: col.dataType,
joinAlias,
suggestedLabel,
});
});
});
res.status(200).json({
success: true,
message: "Entity 조인 컬럼 조회 성공",
data: {
tableName,
joinTables: joinTablesInfo,
availableColumns: allAvailableColumns,
summary: {
totalJoinTables: joinConfigs.length,
totalAvailableColumns: allAvailableColumns.length,
},
},
});
} catch (error) {
logger.error("Entity 조인 컬럼 조회 실패", error);
res.status(500).json({
success: false,
message: "Entity 조인 컬럼 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
/**
*
* POST /api/table-management/cache/preload

View File

@ -120,6 +120,71 @@ router.put(
// 🎯 참조 테이블 정보
// ========================================
/**
* Entity ()
* GET /api/table-management/tables/:tableName/entity-join-columns
*
* Entity
* .
*
* Response:
* {
* success: true,
* data: {
* tableName: "companies",
* joinTables: [
* {
* joinConfig: { sourceColumn: "writer", referenceTable: "user_info", ... },
* tableName: "user_info",
* currentDisplayColumn: "user_name",
* availableColumns: [
* {
* columnName: "email",
* columnLabel: "이메일",
* dataType: "character varying",
* isNullable: true,
* description: "사용자 이메일"
* },
* {
* columnName: "dept_code",
* columnLabel: "부서코드",
* dataType: "character varying",
* isNullable: false,
* description: "소속 부서"
* }
* ]
* }
* ],
* availableColumns: [
* {
* tableName: "user_info",
* columnName: "email",
* columnLabel: "이메일",
* dataType: "character varying",
* joinAlias: "writer_email",
* suggestedLabel: "writer (이메일)"
* },
* {
* tableName: "user_info",
* columnName: "dept_code",
* columnLabel: "부서코드",
* dataType: "character varying",
* joinAlias: "writer_dept_code",
* suggestedLabel: "writer (부서코드)"
* }
* ],
* summary: {
* totalJoinTables: 1,
* totalAvailableColumns: 2
* }
* }
* }
*/
router.get(
"/tables/:tableName/entity-join-columns",
entityJoinController.getEntityJoinColumns.bind(entityJoinController)
);
/**
*
* GET /api/table-management/reference-tables/:tableName/columns

View File

@ -93,11 +93,11 @@ export class EntityJoinService {
// 기본 SELECT 컬럼들
const baseColumns = selectColumns.map((col) => `main.${col}`).join(", ");
// Entity 조인 컬럼들
// Entity 조인 컬럼들 (COALESCE로 NULL을 빈 문자열로 처리)
const joinColumns = joinConfigs
.map(
(config) =>
`${config.referenceTable.substring(0, 3)}.${config.displayColumn} AS ${config.aliasColumn}`
`COALESCE(${config.referenceTable.substring(0, 3)}.${config.displayColumn}, '') AS ${config.aliasColumn}`
)
.join(", ");

View File

@ -1412,6 +1412,11 @@ export class TableManagementService {
sortBy?: string;
sortOrder?: string;
enableEntityJoin?: boolean;
additionalJoinColumns?: Array<{
sourceTable: string;
sourceColumn: string;
joinAlias: string;
}>;
}
): Promise<EntityJoinResponse> {
const startTime = Date.now();
@ -1432,7 +1437,41 @@ export class TableManagementService {
}
// Entity 조인 설정 감지
const joinConfigs = await entityJoinService.detectEntityJoins(tableName);
let joinConfigs = await entityJoinService.detectEntityJoins(tableName);
// 추가 조인 컬럼 정보가 있으면 조인 설정에 추가
if (
options.additionalJoinColumns &&
options.additionalJoinColumns.length > 0
) {
logger.info(
`추가 조인 컬럼 처리: ${options.additionalJoinColumns.length}`
);
for (const additionalColumn of options.additionalJoinColumns) {
// 기존 조인 설정에서 같은 참조 테이블을 사용하는 설정 찾기
const baseJoinConfig = joinConfigs.find(
(config) => config.referenceTable === additionalColumn.sourceTable
);
if (baseJoinConfig) {
// 추가 조인 컬럼 설정 생성
const additionalJoinConfig: EntityJoinConfig = {
sourceTable: tableName,
sourceColumn: baseJoinConfig.sourceColumn, // 원본 컬럼 (writer)
referenceTable: additionalColumn.sourceTable, // 참조 테이블 (user_info)
referenceColumn: baseJoinConfig.referenceColumn, // 참조 키 (user_id)
displayColumn: additionalColumn.sourceColumn, // 표시할 컬럼 (email)
aliasColumn: additionalColumn.joinAlias, // 별칭 (writer_email)
};
joinConfigs.push(additionalJoinConfig);
logger.info(
`추가 조인 컬럼 설정 추가: ${additionalJoinConfig.aliasColumn}`
);
}
}
}
if (joinConfigs.length === 0) {
logger.info(`Entity 조인 설정이 없음: ${tableName}`);
@ -1624,7 +1663,11 @@ export class TableManagementService {
String(sourceValue)
);
enhancedRow[config.aliasColumn] = lookupValue || sourceValue;
// null이나 undefined인 경우 빈 문자열로 설정
enhancedRow[config.aliasColumn] = lookupValue || "";
} else {
// sourceValue가 없는 경우도 빈 문자열로 설정
enhancedRow[config.aliasColumn] = "";
}
}
@ -1946,11 +1989,18 @@ export class TableManagementService {
const keyValue = row[config.sourceColumn];
if (keyValue) {
const lookupValue = cachedData.get(String(keyValue));
if (lookupValue) {
row[config.aliasColumn] = lookupValue;
}
// null이나 undefined인 경우 빈 문자열로 설정
row[config.aliasColumn] = lookupValue || "";
} else {
// sourceValue가 없는 경우도 빈 문자열로 설정
row[config.aliasColumn] = "";
}
});
} else {
// 캐시가 없는 경우 모든 행에 빈 문자열 설정
enhancedData.forEach((row) => {
row[config.aliasColumn] = "";
});
}
}

View File

@ -62,6 +62,11 @@ export const entityJoinApi = {
sortBy?: string;
sortOrder?: "asc" | "desc";
enableEntityJoin?: boolean;
additionalJoinColumns?: Array<{
sourceTable: string;
sourceColumn: string;
joinAlias: string;
}>;
} = {},
): Promise<EntityJoinResponse> => {
const searchParams = new URLSearchParams();
@ -87,6 +92,7 @@ export const entityJoinApi = {
params: {
...params,
search: params.search ? JSON.stringify(params.search) : undefined,
additionalJoinColumns: params.additionalJoinColumns ? JSON.stringify(params.additionalJoinColumns) : undefined,
},
});
return response.data.data;
@ -153,6 +159,40 @@ export const entityJoinApi = {
await apiClient.delete(`/table-management/cache`, { params });
},
/**
* Entity ()
*/
getEntityJoinColumns: async (
tableName: string,
): Promise<{
tableName: string;
joinTables: Array<{
tableName: string;
currentDisplayColumn: string;
availableColumns: Array<{
columnName: string;
columnLabel: string;
dataType: string;
description?: string;
}>;
}>;
availableColumns: Array<{
tableName: string;
columnName: string;
columnLabel: string;
dataType: string;
joinAlias: string;
suggestedLabel: string;
}>;
summary: {
totalJoinTables: number;
totalAvailableColumns: number;
};
}> => {
const response = await apiClient.get(`/table-management/tables/${tableName}/entity-join-columns`);
return response.data.data;
},
/**
*
*/

View File

@ -206,6 +206,16 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// 🎯 Entity 조인 API 사용 - Entity 조인이 포함된 데이터 조회
console.log("🔗 Entity 조인 데이터 조회 시작:", tableConfig.selectedTable);
// Entity 조인 컬럼 추출 (isEntityJoin === true인 컬럼들)
const entityJoinColumns = tableConfig.columns?.filter((col) => col.isEntityJoin && col.entityJoinInfo) || [];
const additionalJoinColumns = entityJoinColumns.map((col) => ({
sourceTable: col.entityJoinInfo!.sourceTable,
sourceColumn: col.entityJoinInfo!.sourceColumn,
joinAlias: col.entityJoinInfo!.joinAlias,
}));
console.log("🔗 추가 Entity 조인 컬럼:", additionalJoinColumns);
const result = await entityJoinApi.getTableDataWithJoins(tableConfig.selectedTable, {
page: currentPage,
size: localPageSize,
@ -262,6 +272,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
sortBy: sortColumn || undefined,
sortOrder: sortDirection,
enableEntityJoin: true, // 🎯 Entity 조인 활성화
additionalJoinColumns: additionalJoinColumns.length > 0 ? additionalJoinColumns : undefined, // 추가 조인 컬럼
});
if (result) {

View File

@ -12,6 +12,7 @@ import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import { TableListConfig, ColumnConfig } from "./types";
import { entityJoinApi } from "@/lib/api/entityJoin";
import {
Plus,
Trash2,
@ -50,6 +51,27 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
const [availableColumns, setAvailableColumns] = useState<
Array<{ columnName: string; dataType: string; label?: string }>
>([]);
const [entityJoinColumns, setEntityJoinColumns] = useState<{
availableColumns: Array<{
tableName: string;
columnName: string;
columnLabel: string;
dataType: string;
joinAlias: string;
suggestedLabel: string;
}>;
joinTables: Array<{
tableName: string;
currentDisplayColumn: string;
availableColumns: Array<{
columnName: string;
columnLabel: string;
dataType: string;
description?: string;
}>;
}>;
}>({ availableColumns: [], joinTables: [] });
const [loadingEntityJoins, setLoadingEntityJoins] = useState(false);
// 화면 테이블명이 있으면 자동으로 설정
useEffect(() => {
@ -137,6 +159,36 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
}
}, [config.selectedTable, screenTableName, tableColumns]);
// Entity 조인 컬럼 정보 가져오기
useEffect(() => {
const fetchEntityJoinColumns = async () => {
const tableName = config.selectedTable || screenTableName;
if (!tableName) {
setEntityJoinColumns({ availableColumns: [], joinTables: [] });
return;
}
setLoadingEntityJoins(true);
try {
console.log("🔗 Entity 조인 컬럼 정보 가져오기:", tableName);
const result = await entityJoinApi.getEntityJoinColumns(tableName);
console.log("✅ Entity 조인 컬럼 응답:", result);
setEntityJoinColumns({
availableColumns: result.availableColumns || [],
joinTables: result.joinTables || [],
});
} catch (error) {
console.error("❌ Entity 조인 컬럼 조회 오류:", error);
setEntityJoinColumns({ availableColumns: [], joinTables: [] });
} finally {
setLoadingEntityJoins(false);
}
};
fetchEntityJoinColumns();
}, [config.selectedTable, screenTableName]);
const handleChange = (key: keyof TableListConfig, value: any) => {
onChange({ [key]: value });
};
@ -176,6 +228,32 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
handleChange("columns", [...(config.columns || []), newColumn]);
};
// Entity 조인 컬럼 추가
const addEntityJoinColumn = (joinColumn: (typeof entityJoinColumns.availableColumns)[0]) => {
const existingColumn = config.columns?.find((col) => col.columnName === joinColumn.joinAlias);
if (existingColumn) return;
const newColumn: ColumnConfig = {
columnName: joinColumn.joinAlias,
displayName: joinColumn.columnLabel, // 라벨명만 사용
visible: true,
sortable: true,
searchable: true,
align: "left",
format: "text",
order: config.columns?.length || 0,
isEntityJoin: true, // Entity 조인 컬럼임을 표시
entityJoinInfo: {
sourceTable: joinColumn.tableName,
sourceColumn: joinColumn.columnName,
joinAlias: joinColumn.joinAlias,
},
};
handleChange("columns", [...(config.columns || []), newColumn]);
console.log("🔗 Entity 조인 컬럼 추가됨:", newColumn);
};
// 컬럼 제거
const removeColumn = (columnName: string) => {
const updatedColumns = config.columns?.filter((col) => col.columnName !== columnName) || [];
@ -214,7 +292,7 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
<div className="text-sm font-medium"> </div>
<Tabs defaultValue="basic" className="w-full">
<TabsList className="grid w-full grid-cols-5">
<TabsList className="grid w-full grid-cols-6">
<TabsTrigger value="basic" className="flex items-center gap-1">
<Settings className="h-3 w-3" />
@ -223,6 +301,10 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
<Columns className="h-3 w-3" />
</TabsTrigger>
<TabsTrigger value="join-columns" className="flex items-center gap-1">
<Plus className="h-3 w-3" />
</TabsTrigger>
<TabsTrigger value="filter" className="flex items-center gap-1">
<Filter className="h-3 w-3" />
@ -610,6 +692,127 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
)}
</TabsContent>
{/* Entity 조인 컬럼 추가 탭 */}
<TabsContent value="join-columns" className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="text-base">Entity </CardTitle>
<CardDescription>Entity .</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<ScrollArea className="max-h-96 pr-4">
{loadingEntityJoins ? (
<div className="text-muted-foreground py-4 text-center"> ...</div>
) : entityJoinColumns.joinTables.length === 0 ? (
<div className="text-muted-foreground py-8 text-center">
<div className="text-sm">Entity .</div>
<div className="mt-1 text-xs">
'entity' .
</div>
</div>
) : (
<div className="space-y-4">
{/* 조인 테이블별 그룹 */}
{entityJoinColumns.joinTables.map((joinTable, tableIndex) => (
<Card key={tableIndex} className="border-l-4 border-l-blue-500">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-sm">
📊 {joinTable.tableName}
<Badge variant="outline" className="text-xs">
: {joinTable.currentDisplayColumn}
</Badge>
</CardTitle>
</CardHeader>
<CardContent className="pt-0">
{joinTable.availableColumns.length === 0 ? (
<div className="text-muted-foreground py-2 text-sm"> .</div>
) : (
<div className="grid gap-2">
{joinTable.availableColumns.map((column, colIndex) => {
const matchingJoinColumn = entityJoinColumns.availableColumns.find(
(jc) => jc.tableName === joinTable.tableName && jc.columnName === column.columnName,
);
const isAlreadyAdded = config.columns?.some(
(col) => col.columnName === matchingJoinColumn?.joinAlias,
);
return (
<div key={colIndex} className="flex items-center justify-between rounded border p-2">
<div className="flex-1">
<div className="text-sm font-medium">{column.columnLabel}</div>
<div className="text-muted-foreground text-xs">
{column.columnName} ({column.dataType})
</div>
{column.description && (
<div className="text-muted-foreground mt-1 text-xs">{column.description}</div>
)}
</div>
<div className="flex items-center gap-2">
{isAlreadyAdded ? (
<Badge variant="secondary" className="text-xs">
</Badge>
) : (
matchingJoinColumn && (
<Button
size="sm"
variant="outline"
onClick={() => addEntityJoinColumn(matchingJoinColumn)}
className="text-xs"
>
<Plus className="mr-1 h-3 w-3" />
</Button>
)
)}
</div>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
))}
{/* 전체 사용 가능한 컬럼 요약 */}
{entityJoinColumns.availableColumns.length > 0 && (
<Card className="bg-muted/30">
<CardHeader className="pb-3">
<CardTitle className="text-sm">📋 </CardTitle>
</CardHeader>
<CardContent className="pt-0">
<div className="text-muted-foreground mb-2 text-sm">
{entityJoinColumns.availableColumns.length} .
</div>
<div className="flex flex-wrap gap-1">
{entityJoinColumns.availableColumns.map((column, index) => {
const isAlreadyAdded = config.columns?.some((col) => col.columnName === column.joinAlias);
return (
<Badge
key={index}
variant={isAlreadyAdded ? "secondary" : "outline"}
className="cursor-pointer text-xs"
onClick={() => !isAlreadyAdded && addEntityJoinColumn(column)}
>
{column.columnLabel}
{!isAlreadyAdded && <Plus className="ml-1 h-2 w-2" />}
</Badge>
);
})}
</div>
</CardContent>
</Card>
)}
</div>
)}
</ScrollArea>
</CardContent>
</Card>
</TabsContent>
{/* 필터 설정 탭 */}
<TabsContent value="filter" className="space-y-4">
<Card>

View File

@ -2,6 +2,15 @@
import { ComponentConfig } from "@/types/component";
/**
* Entity
*/
export interface EntityJoinInfo {
sourceTable: string;
sourceColumn: string;
joinAlias: string;
}
/**
*
*/
@ -16,7 +25,8 @@ export interface ColumnConfig {
format?: "text" | "number" | "date" | "currency" | "boolean";
order: number;
dataType?: string; // 컬럼 데이터 타입 (검색 컬럼 선택에 사용)
isEntityJoined?: boolean; // 🎯 Entity 조인된 컬럼인지 여부
isEntityJoin?: boolean; // Entity 조인된 컬럼인지 여부
entityJoinInfo?: EntityJoinInfo; // Entity 조인 상세 정보
}
/**