feature/screen-management #202

Merged
kjs merged 11 commits from feature/screen-management into main 2025-11-12 17:52:40 +09:00
10 changed files with 466 additions and 359 deletions

View File

@ -104,7 +104,7 @@ export class DDLExecutionService {
await this.saveTableMetadata(client, tableName, description);
// 5-3. 컬럼 메타데이터 저장
await this.saveColumnMetadata(client, tableName, columns);
await this.saveColumnMetadata(client, tableName, columns, userCompanyCode);
});
// 6. 성공 로그 기록
@ -272,7 +272,7 @@ export class DDLExecutionService {
await client.query(ddlQuery);
// 6-2. 컬럼 메타데이터 저장
await this.saveColumnMetadata(client, tableName, [column]);
await this.saveColumnMetadata(client, tableName, [column], userCompanyCode);
});
// 7. 성공 로그 기록
@ -446,7 +446,8 @@ CREATE TABLE "${tableName}" (${baseColumns},
private async saveColumnMetadata(
client: any,
tableName: string,
columns: CreateColumnDefinition[]
columns: CreateColumnDefinition[],
companyCode: string
): Promise<void> {
// 먼저 table_labels에 테이블 정보가 있는지 확인하고 없으면 생성
await client.query(
@ -508,19 +509,19 @@ CREATE TABLE "${tableName}" (${baseColumns},
await client.query(
`
INSERT INTO table_type_columns (
table_name, column_name, input_type, detail_settings,
table_name, column_name, company_code, input_type, detail_settings,
is_nullable, display_order, created_date, updated_date
) VALUES (
$1, $2, $3, '{}',
'Y', $4, now(), now()
$1, $2, $3, $4, '{}',
'Y', $5, now(), now()
)
ON CONFLICT (table_name, column_name)
ON CONFLICT (table_name, column_name, company_code)
DO UPDATE SET
input_type = $3,
display_order = $4,
input_type = $4,
display_order = $5,
updated_date = now()
`,
[tableName, defaultCol.name, defaultCol.inputType, defaultCol.order]
[tableName, defaultCol.name, companyCode, defaultCol.inputType, defaultCol.order]
);
}
@ -535,20 +536,20 @@ CREATE TABLE "${tableName}" (${baseColumns},
await client.query(
`
INSERT INTO table_type_columns (
table_name, column_name, input_type, detail_settings,
table_name, column_name, company_code, input_type, detail_settings,
is_nullable, display_order, created_date, updated_date
) VALUES (
$1, $2, $3, $4,
'Y', $5, now(), now()
$1, $2, $3, $4, $5,
'Y', $6, now(), now()
)
ON CONFLICT (table_name, column_name)
ON CONFLICT (table_name, column_name, company_code)
DO UPDATE SET
input_type = $3,
detail_settings = $4,
display_order = $5,
input_type = $4,
detail_settings = $5,
display_order = $6,
updated_date = now()
`,
[tableName, column.name, inputType, detailSettings, i]
[tableName, column.name, companyCode, inputType, detailSettings, i]
);
}

View File

@ -36,29 +36,61 @@ export async function getSiblingMenuObjids(menuObjid: number): Promise<number[]>
try {
logger.debug("메뉴 스코프 조회 시작", { menuObjid });
// 1. 현재 메뉴 자신을 포함
const menuObjids = [menuObjid];
// 1. 현재 메뉴 정보 조회 (부모 ID 확인)
const currentMenuQuery = `
SELECT parent_obj_id FROM menu_info
WHERE objid = $1
`;
const currentMenuResult = await pool.query(currentMenuQuery, [menuObjid]);
// 2. 현재 메뉴의 자식 메뉴들 조회
const childrenQuery = `
if (currentMenuResult.rows.length === 0) {
logger.warn("메뉴를 찾을 수 없음, 자기 자신만 반환", { menuObjid });
return [menuObjid];
}
const parentObjId = Number(currentMenuResult.rows[0].parent_obj_id);
// 2. 최상위 메뉴(parent_obj_id = 0)는 자기 자신만 반환
if (parentObjId === 0) {
logger.debug("최상위 메뉴, 자기 자신만 반환", { menuObjid });
return [menuObjid];
}
// 3. 형제 메뉴들 조회 (같은 부모를 가진 메뉴들)
const siblingsQuery = `
SELECT objid FROM menu_info
WHERE parent_obj_id = $1
ORDER BY objid
`;
const childrenResult = await pool.query(childrenQuery, [menuObjid]);
const siblingsResult = await pool.query(siblingsQuery, [parentObjId]);
const childObjids = childrenResult.rows.map((row) => Number(row.objid));
const siblingObjids = siblingsResult.rows.map((row) => Number(row.objid));
// 3. 자신 + 자식을 합쳐서 정렬
const allObjids = Array.from(new Set([...menuObjids, ...childObjids])).sort((a, b) => a - b);
// 4. 각 형제 메뉴(자기 자신 포함)의 자식 메뉴들도 조회
const allObjids = [...siblingObjids];
for (const siblingObjid of siblingObjids) {
const childrenQuery = `
SELECT objid FROM menu_info
WHERE parent_obj_id = $1
ORDER BY objid
`;
const childrenResult = await pool.query(childrenQuery, [siblingObjid]);
const childObjids = childrenResult.rows.map((row) => Number(row.objid));
allObjids.push(...childObjids);
}
// 5. 중복 제거 및 정렬
const uniqueObjids = Array.from(new Set(allObjids)).sort((a, b) => a - b);
logger.debug("메뉴 스코프 조회 완료", {
menuObjid,
childCount: childObjids.length,
totalCount: allObjids.length
menuObjid,
parentObjId,
siblingCount: siblingObjids.length,
totalCount: uniqueObjids.length
});
return allObjids;
return uniqueObjids;
} catch (error: any) {
logger.error("메뉴 스코프 조회 실패", {
menuObjid,

View File

@ -179,7 +179,8 @@ class TableCategoryValueService {
} else {
// 일반 회사: 자신의 카테고리 값만 조회
if (menuObjid && siblingObjids.length > 0) {
// 메뉴 스코프 적용
// 메뉴 스코프 적용 + created_menu_objid 필터링
// 현재 메뉴 스코프(형제 메뉴)에서 생성된 값만 표시
query = `
SELECT
value_id AS "valueId",
@ -197,6 +198,7 @@ class TableCategoryValueService {
is_default AS "isDefault",
company_code AS "companyCode",
menu_objid AS "menuObjid",
created_menu_objid AS "createdMenuObjid",
created_at AS "createdAt",
updated_at AS "updatedAt",
created_by AS "createdBy",
@ -206,6 +208,10 @@ class TableCategoryValueService {
AND column_name = $2
AND menu_objid = ANY($3)
AND company_code = $4
AND (
created_menu_objid = ANY($3) --
OR created_menu_objid IS NULL -- ( )
)
`;
params = [tableName, columnName, siblingObjids, companyCode];
} else {
@ -331,8 +337,8 @@ class TableCategoryValueService {
INSERT INTO table_column_category_values (
table_name, column_name, value_code, value_label, value_order,
parent_value_id, depth, description, color, icon,
is_active, is_default, company_code, menu_objid, created_by
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
is_active, is_default, company_code, menu_objid, created_menu_objid, created_by
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
RETURNING
value_id AS "valueId",
table_name AS "tableName",
@ -349,6 +355,7 @@ class TableCategoryValueService {
is_default AS "isDefault",
company_code AS "companyCode",
menu_objid AS "menuObjid",
created_menu_objid AS "createdMenuObjid",
created_at AS "createdAt",
created_by AS "createdBy"
`;
@ -368,6 +375,7 @@ class TableCategoryValueService {
value.isDefault || false,
companyCode,
menuObjid, // ← 메뉴 OBJID 저장
menuObjid, // ← 🆕 생성 메뉴 OBJID 저장 (같은 값)
userId,
]);

View File

@ -1069,12 +1069,28 @@ export class TableManagementService {
paramCount: number;
} | null> {
try {
// 🔧 {value, operator} 형태의 필터 객체 처리
let actualValue = value;
let operator = "contains"; // 기본값
if (typeof value === "object" && value !== null && "value" in value) {
actualValue = value.value;
operator = value.operator || "contains";
logger.info("🔍 필터 객체 처리:", {
columnName,
originalValue: value,
actualValue,
operator,
});
}
// "__ALL__" 값이거나 빈 값이면 필터 조건을 적용하지 않음
if (
value === "__ALL__" ||
value === "" ||
value === null ||
value === undefined
actualValue === "__ALL__" ||
actualValue === "" ||
actualValue === null ||
actualValue === undefined
) {
return null;
}
@ -1083,12 +1099,22 @@ export class TableManagementService {
const columnInfo = await this.getColumnWebTypeInfo(tableName, columnName);
if (!columnInfo) {
// 컬럼 정보가 없으면 기본 문자열 검색
return {
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
values: [`%${value}%`],
paramCount: 1,
};
// 컬럼 정보가 없으면 operator에 따른 기본 검색
switch (operator) {
case "equals":
return {
whereClause: `${columnName}::text = $${paramIndex}`,
values: [actualValue],
paramCount: 1,
};
case "contains":
default:
return {
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
values: [`%${actualValue}%`],
paramCount: 1,
};
}
}
const webType = columnInfo.webType;
@ -1097,17 +1123,17 @@ export class TableManagementService {
switch (webType) {
case "date":
case "datetime":
return this.buildDateRangeCondition(columnName, value, paramIndex);
return this.buildDateRangeCondition(columnName, actualValue, paramIndex);
case "number":
case "decimal":
return this.buildNumberRangeCondition(columnName, value, paramIndex);
return this.buildNumberRangeCondition(columnName, actualValue, paramIndex);
case "code":
return await this.buildCodeSearchCondition(
tableName,
columnName,
value,
actualValue,
paramIndex
);
@ -1115,15 +1141,15 @@ export class TableManagementService {
return await this.buildEntitySearchCondition(
tableName,
columnName,
value,
actualValue,
paramIndex
);
default:
// 기본 문자열 검색
// 기본 문자열 검색 (actualValue 사용)
return {
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
values: [`%${value}%`],
values: [`%${actualValue}%`],
paramCount: 1,
};
}
@ -1133,9 +1159,14 @@ export class TableManagementService {
error
);
// 오류 시 기본 검색으로 폴백
let fallbackValue = value;
if (typeof value === "object" && value !== null && "value" in value) {
fallbackValue = value.value;
}
return {
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
values: [`%${value}%`],
values: [`%${fallbackValue}%`],
paramCount: 1,
};
}

View File

@ -85,6 +85,15 @@ export const ColumnVisibilityPanel: React.FC<Props> = ({
const handleApply = () => {
table?.onColumnVisibilityChange(localColumns);
// 컬럼 순서 변경 콜백 호출
if (table?.onColumnOrderChange) {
const newOrder = localColumns
.map((col) => col.columnName)
.filter((name) => name !== "__checkbox__");
table.onColumnOrderChange(newOrder);
}
onClose();
};

View File

@ -49,6 +49,7 @@ export function CategoryWidget({ widgetId, tableName, menuObjid, component, ...p
const effectiveMenuObjid = menuObjid || props.menuObjid;
const [selectedColumn, setSelectedColumn] = useState<{
uniqueKey: string; // 테이블명.컬럼명 형식
columnName: string;
columnLabel: string;
tableName: string;
@ -98,10 +99,12 @@ export function CategoryWidget({ widgetId, tableName, menuObjid, component, ...p
<div style={{ width: `${leftWidth}%` }} className="pr-3">
<CategoryColumnList
tableName={tableName}
selectedColumn={selectedColumn?.columnName || null}
onColumnSelect={(columnName, columnLabel, tableName) =>
setSelectedColumn({ columnName, columnLabel, tableName })
}
selectedColumn={selectedColumn?.uniqueKey || null}
onColumnSelect={(uniqueKey, columnLabel, tableName) => {
// uniqueKey는 "테이블명.컬럼명" 형식
const columnName = uniqueKey.split('.')[1];
setSelectedColumn({ uniqueKey, columnName, columnLabel, tableName });
}}
menuObjid={effectiveMenuObjid}
/>
</div>
@ -118,6 +121,7 @@ export function CategoryWidget({ widgetId, tableName, menuObjid, component, ...p
<div style={{ width: `${100 - leftWidth - 1}%` }} className="pl-3">
{selectedColumn ? (
<CategoryValueManager
key={selectedColumn.uniqueKey} // 테이블명.컬럼명으로 컴포넌트 재생성
tableName={selectedColumn.tableName}
columnName={selectedColumn.columnName}
columnLabel={selectedColumn.columnLabel}

View File

@ -147,17 +147,18 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect,
<div className="space-y-2">
{columns.map((column) => {
const uniqueKey = `${column.tableName}.${column.columnName}`;
const isSelected = selectedColumn === uniqueKey; // 테이블명.컬럼명으로 비교
return (
<div
key={uniqueKey}
onClick={() => onColumnSelect(column.columnName, column.columnLabel || column.columnName, column.tableName)}
onClick={() => onColumnSelect(uniqueKey, column.columnLabel || column.columnName, column.tableName)}
className={`cursor-pointer rounded-lg border px-4 py-2 transition-all ${
selectedColumn === column.columnName ? "border-primary bg-primary/10 shadow-sm" : "hover:bg-muted/50"
isSelected ? "border-primary bg-primary/10 shadow-sm" : "hover:bg-muted/50"
}`}
>
<div className="flex items-center gap-2">
<FolderTree
className={`h-4 w-4 ${selectedColumn === column.columnName ? "text-primary" : "text-muted-foreground"}`}
className={`h-4 w-4 ${isSelected ? "text-primary" : "text-muted-foreground"}`}
/>
<div className="flex-1">
<h4 className="text-sm font-semibold">{column.columnLabel || column.columnName}</h4>

View File

@ -1,6 +1,6 @@
"use client";
import React, { useState, useCallback, useEffect } from "react";
import React, { useState, useCallback, useEffect, useMemo } from "react";
import { ComponentRendererProps } from "../../types";
import { SplitPanelLayoutConfig } from "./types";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@ -8,12 +8,14 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Plus, Search, GripVertical, Loader2, ChevronDown, ChevronUp, Save, ChevronRight, Pencil, Trash2 } from "lucide-react";
import { dataApi } from "@/lib/api/data";
import { entityJoinApi } from "@/lib/api/entityJoin";
import { useToast } from "@/hooks/use-toast";
import { tableTypeApi } from "@/lib/api/screen";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { useTableOptions } from "@/contexts/TableOptionsContext";
import { TableFilter, ColumnVisibility } from "@/types/table-options";
import { useAuth } from "@/hooks/useAuth";
export interface SplitPanelLayoutComponentProps extends ComponentRendererProps {
// 추가 props
@ -44,6 +46,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
const [leftFilters, setLeftFilters] = useState<TableFilter[]>([]);
const [leftGrouping, setLeftGrouping] = useState<string[]>([]);
const [leftColumnVisibility, setLeftColumnVisibility] = useState<ColumnVisibility[]>([]);
const [leftColumnOrder, setLeftColumnOrder] = useState<string[]>([]); // 🔧 컬럼 순서
const [rightFilters, setRightFilters] = useState<TableFilter[]>([]);
const [rightGrouping, setRightGrouping] = useState<string[]>([]);
const [rightColumnVisibility, setRightColumnVisibility] = useState<ColumnVisibility[]>([]);
@ -160,6 +163,84 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
return rootItems;
}, [componentConfig.leftPanel?.itemAddConfig]);
// 🔧 사용자 ID 가져오기
const { userId: currentUserId } = useAuth();
// 🔄 필터를 searchValues 형식으로 변환
const searchValues = useMemo(() => {
if (!leftFilters || leftFilters.length === 0) return {};
const values: Record<string, any> = {};
leftFilters.forEach(filter => {
if (filter.value !== undefined && filter.value !== null && filter.value !== '') {
values[filter.columnName] = {
value: filter.value,
operator: filter.operator || 'contains',
};
}
});
return values;
}, [leftFilters]);
// 🔄 컬럼 가시성 및 순서 처리
const visibleLeftColumns = useMemo(() => {
const displayColumns = componentConfig.leftPanel?.columns || [];
if (displayColumns.length === 0) return [];
let columns = displayColumns;
// columnVisibility가 있으면 가시성 적용
if (leftColumnVisibility.length > 0) {
const visibilityMap = new Map(leftColumnVisibility.map(cv => [cv.columnName, cv.visible]));
columns = columns.filter((col: any) => {
const colName = typeof col === 'string' ? col : (col.name || col.columnName);
return visibilityMap.get(colName) !== false;
});
}
// 🔧 컬럼 순서 적용
if (leftColumnOrder.length > 0) {
const orderMap = new Map(leftColumnOrder.map((name, index) => [name, index]));
columns = [...columns].sort((a, b) => {
const aName = typeof a === 'string' ? a : (a.name || a.columnName);
const bName = typeof b === 'string' ? b : (b.name || b.columnName);
const aIndex = orderMap.get(aName) ?? 999;
const bIndex = orderMap.get(bName) ?? 999;
return aIndex - bIndex;
});
}
return columns;
}, [componentConfig.leftPanel?.columns, leftColumnVisibility, leftColumnOrder]);
// 🔄 데이터 그룹화
const groupedLeftData = useMemo(() => {
if (!leftGrouping || leftGrouping.length === 0 || leftData.length === 0) return [];
const grouped = new Map<string, any[]>();
leftData.forEach((item) => {
// 각 그룹 컬럼의 값을 조합하여 그룹 키 생성
const groupKey = leftGrouping.map(col => {
const value = item[col];
// null/undefined 처리
return value === null || value === undefined ? "(비어있음)" : String(value);
}).join(" > ");
if (!grouped.has(groupKey)) {
grouped.set(groupKey, []);
}
grouped.get(groupKey)!.push(item);
});
return Array.from(grouped.entries()).map(([key, items]) => ({
groupKey: key,
items,
count: items.length,
}));
}, [leftData, leftGrouping]);
// 좌측 데이터 로드
const loadLeftData = useCallback(async () => {
const leftTableName = componentConfig.leftPanel?.tableName;
@ -167,12 +248,18 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
setIsLoadingLeft(true);
try {
const result = await dataApi.getTableData(leftTableName, {
// 🎯 필터 조건을 API에 전달 (entityJoinApi 사용)
const filters = Object.keys(searchValues).length > 0 ? searchValues : undefined;
const result = await entityJoinApi.getTableDataWithJoins(leftTableName, {
page: 1,
size: 100,
// searchTerm 제거 - 클라이언트 사이드에서 필터링
search: filters, // 필터 조건 전달
enableEntityJoin: true, // 엔티티 조인 활성화
});
// 가나다순 정렬 (좌측 패널의 표시 컬럼 기준)
const leftColumn = componentConfig.rightPanel?.relation?.leftColumn;
if (leftColumn && result.data.length > 0) {
@ -196,7 +283,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
} finally {
setIsLoadingLeft(false);
}
}, [componentConfig.leftPanel?.tableName, componentConfig.rightPanel?.relation?.leftColumn, isDesignMode, toast, buildHierarchy]);
}, [componentConfig.leftPanel?.tableName, componentConfig.rightPanel?.relation?.leftColumn, isDesignMode, toast, buildHierarchy, searchValues]);
// 우측 데이터 로드
const loadRightData = useCallback(
@ -283,67 +370,101 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
[rightTableColumns],
);
// 🔧 컬럼의 고유값 가져오기 함수
const getLeftColumnUniqueValues = useCallback(async (columnName: string) => {
const leftTableName = componentConfig.leftPanel?.tableName;
if (!leftTableName || leftData.length === 0) return [];
// 현재 로드된 데이터에서 고유값 추출
const uniqueValues = new Set<string>();
leftData.forEach((item) => {
const value = item[columnName];
if (value !== null && value !== undefined && value !== '') {
// _name 필드 우선 사용 (category/entity type)
const displayValue = item[`${columnName}_name`] || value;
uniqueValues.add(String(displayValue));
}
});
return Array.from(uniqueValues).map(value => ({
value: value,
label: value,
}));
}, [componentConfig.leftPanel?.tableName, leftData]);
// 좌측 테이블 등록 (Context에 등록)
useEffect(() => {
const leftTableName = componentConfig.leftPanel?.tableName;
if (!leftTableName || isDesignMode) return;
const leftTableId = `split-panel-left-${component.id}`;
const leftColumns = componentConfig.leftPanel?.displayColumns || [];
// 🔧 화면에 표시되는 컬럼 사용 (columns 속성)
const configuredColumns = componentConfig.leftPanel?.columns || [];
const displayColumns = configuredColumns.map((col: any) => {
if (typeof col === 'string') return col;
return col.columnName || col.name || col;
}).filter(Boolean);
// 화면에 설정된 컬럼이 없으면 등록하지 않음
if (displayColumns.length === 0) return;
if (leftColumns.length > 0) {
registerTable({
tableId: leftTableId,
label: `${component.title || "분할 패널"} (좌측)`,
tableName: leftTableName,
columns: leftColumns.map((col: string) => ({
columnName: col,
columnLabel: leftColumnLabels[col] || col,
inputType: "text",
visible: true,
width: 150,
sortable: true,
filterable: true,
})),
onFilterChange: setLeftFilters,
onGroupChange: setLeftGrouping,
onColumnVisibilityChange: setLeftColumnVisibility,
});
// 테이블명이 있으면 등록
registerTable({
tableId: leftTableId,
label: `${component.title || "분할 패널"} (좌측)`,
tableName: leftTableName,
columns: displayColumns.map((col: string) => ({
columnName: col,
columnLabel: leftColumnLabels[col] || col,
inputType: "text",
visible: true,
width: 150,
sortable: true,
filterable: true,
})),
onFilterChange: setLeftFilters,
onGroupChange: setLeftGrouping,
onColumnVisibilityChange: setLeftColumnVisibility,
onColumnOrderChange: setLeftColumnOrder, // 🔧 컬럼 순서 변경 콜백 추가
getColumnUniqueValues: getLeftColumnUniqueValues, // 🔧 고유값 가져오기 함수 추가
});
return () => unregisterTable(leftTableId);
}
}, [component.id, componentConfig.leftPanel?.tableName, componentConfig.leftPanel?.displayColumns, leftColumnLabels, component.title, isDesignMode]);
return () => unregisterTable(leftTableId);
}, [component.id, componentConfig.leftPanel?.tableName, componentConfig.leftPanel?.columns, leftColumnLabels, component.title, isDesignMode, getLeftColumnUniqueValues]);
// 우측 테이블 등록 (Context에 등록)
useEffect(() => {
const rightTableName = componentConfig.rightPanel?.tableName;
if (!rightTableName || isDesignMode) return;
const rightTableId = `split-panel-right-${component.id}`;
const rightColumns = rightTableColumns.map((col: any) => col.columnName || col.column_name).filter(Boolean);
if (rightColumns.length > 0) {
registerTable({
tableId: rightTableId,
label: `${component.title || "분할 패널"} (우측)`,
tableName: rightTableName,
columns: rightColumns.map((col: string) => ({
columnName: col,
columnLabel: rightColumnLabels[col] || col,
inputType: "text",
visible: true,
width: 150,
sortable: true,
filterable: true,
})),
onFilterChange: setRightFilters,
onGroupChange: setRightGrouping,
onColumnVisibilityChange: setRightColumnVisibility,
});
return () => unregisterTable(rightTableId);
}
}, [component.id, componentConfig.rightPanel?.tableName, rightTableColumns, rightColumnLabels, component.title, isDesignMode]);
// 우측 테이블은 검색 컴포넌트 등록 제외 (좌측 마스터 테이블만 검색 가능)
// useEffect(() => {
// const rightTableName = componentConfig.rightPanel?.tableName;
// if (!rightTableName || isDesignMode) return;
//
// const rightTableId = `split-panel-right-${component.id}`;
// // 🔧 화면에 표시되는 컬럼만 등록 (displayColumns 또는 columns)
// const displayColumns = componentConfig.rightPanel?.columns || [];
// const rightColumns = displayColumns.map((col: any) => col.columnName || col.name || col).filter(Boolean);
//
// if (rightColumns.length > 0) {
// registerTable({
// tableId: rightTableId,
// label: `${component.title || "분할 패널"} (우측)`,
// tableName: rightTableName,
// columns: rightColumns.map((col: string) => ({
// columnName: col,
// columnLabel: rightColumnLabels[col] || col,
// inputType: "text",
// visible: true,
// width: 150,
// sortable: true,
// filterable: true,
// })),
// onFilterChange: setRightFilters,
// onGroupChange: setRightGrouping,
// onColumnVisibilityChange: setRightColumnVisibility,
// });
//
// return () => unregisterTable(rightTableId);
// }
// }, [component.id, componentConfig.rightPanel?.tableName, componentConfig.rightPanel?.columns, rightColumnLabels, component.title, isDesignMode]);
// 좌측 테이블 컬럼 라벨 로드
useEffect(() => {
@ -786,6 +907,43 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
}
}, [addModalPanel, componentConfig, addModalFormData, toast, selectedLeftItem, loadLeftData, loadRightData]);
// 🔧 좌측 컬럼 가시성 설정 저장 및 불러오기
useEffect(() => {
const leftTableName = componentConfig.leftPanel?.tableName;
if (leftTableName && currentUserId) {
// localStorage에서 저장된 설정 불러오기
const storageKey = `table_column_visibility_${leftTableName}_${currentUserId}`;
const savedSettings = localStorage.getItem(storageKey);
if (savedSettings) {
try {
const parsed = JSON.parse(savedSettings) as ColumnVisibility[];
setLeftColumnVisibility(parsed);
} catch (error) {
console.error("저장된 컬럼 설정 불러오기 실패:", error);
}
}
}
}, [componentConfig.leftPanel?.tableName, currentUserId]);
// 🔧 컬럼 가시성 변경 시 localStorage에 저장 및 순서 업데이트
useEffect(() => {
const leftTableName = componentConfig.leftPanel?.tableName;
if (leftColumnVisibility.length > 0 && leftTableName && currentUserId) {
// 순서 업데이트
const newOrder = leftColumnVisibility
.map((cv) => cv.columnName)
.filter((name) => name !== "__checkbox__"); // 체크박스 제외
setLeftColumnOrder(newOrder);
// localStorage에 저장
const storageKey = `table_column_visibility_${leftTableName}_${currentUserId}`;
localStorage.setItem(storageKey, JSON.stringify(leftColumnVisibility));
}
}, [leftColumnVisibility, componentConfig.leftPanel?.tableName, currentUserId]);
// 초기 데이터 로드
useEffect(() => {
if (!isDesignMode && componentConfig.autoLoad !== false) {
@ -794,6 +952,14 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isDesignMode, componentConfig.autoLoad]);
// 🔄 필터 변경 시 데이터 다시 로드
useEffect(() => {
if (!isDesignMode && componentConfig.autoLoad !== false) {
loadLeftData();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [leftFilters]);
// 리사이저 드래그 핸들러
const handleMouseDown = (e: React.MouseEvent) => {
if (!resizable) return;
@ -933,6 +1099,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
</div>
) : (
(() => {
// 🔧 로컬 검색 필터 적용
const filteredData = leftSearchQuery
? leftData.filter((item) => {
const searchLower = leftSearchQuery.toLowerCase();
@ -943,12 +1110,17 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
})
: leftData;
const displayColumns = componentConfig.leftPanel?.columns || [];
const columnsToShow = displayColumns.length > 0
? displayColumns.map(col => ({
...col,
label: leftColumnLabels[col.name] || col.label || col.name
}))
// 🔧 가시성 처리된 컬럼 사용
const columnsToShow = visibleLeftColumns.length > 0
? visibleLeftColumns.map((col: any) => {
const colName = typeof col === 'string' ? col : (col.name || col.columnName);
return {
name: colName,
label: leftColumnLabels[colName] || (typeof col === 'object' ? col.label : null) || colName,
width: typeof col === 'object' ? col.width : 150,
align: (typeof col === 'object' ? col.align : "left") as "left" | "center" | "right"
};
})
: Object.keys(filteredData[0] || {}).filter(key => key !== 'children' && key !== 'level').slice(0, 5).map(key => ({
name: key,
label: leftColumnLabels[key] || key,
@ -956,6 +1128,66 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
align: "left" as const
}));
// 🔧 그룹화된 데이터 렌더링
if (groupedLeftData.length > 0) {
return (
<div className="overflow-auto">
{groupedLeftData.map((group, groupIdx) => (
<div key={groupIdx} className="mb-4">
<div className="bg-gray-100 px-3 py-2 font-semibold text-sm">
{group.groupKey} ({group.count})
</div>
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
{columnsToShow.map((col, idx) => (
<th
key={idx}
className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
style={{ width: col.width ? `${col.width}px` : 'auto', textAlign: col.align || "left" }}
>
{col.label}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-gray-200 bg-white">
{group.items.map((item, idx) => {
const sourceColumn = componentConfig.leftPanel?.itemAddConfig?.sourceColumn || 'id';
const itemId = item[sourceColumn] || item.id || item.ID || idx;
const isSelected = selectedLeftItem && (selectedLeftItem[sourceColumn] === itemId || selectedLeftItem === item);
return (
<tr
key={itemId}
onClick={() => handleLeftItemSelect(item)}
className={`hover:bg-accent cursor-pointer transition-colors ${
isSelected ? "bg-primary/10" : ""
}`}
>
{columnsToShow.map((col, colIdx) => (
<td
key={colIdx}
className="whitespace-nowrap px-3 py-2 text-sm text-gray-900"
style={{ textAlign: col.align || "left" }}
>
{item[col.name] !== null && item[col.name] !== undefined
? String(item[col.name])
: "-"}
</td>
))}
</tr>
);
})}
</tbody>
</table>
</div>
))}
</div>
);
}
// 🔧 일반 테이블 렌더링 (그룹화 없음)
return (
<div className="overflow-auto">
<table className="min-w-full divide-y divide-gray-200">

View File

@ -274,6 +274,11 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
setCurrentPage(1); // 필터 변경 시 첫 페이지로
}, [filters]);
// grouping이 변경되면 groupByColumns 업데이트
useEffect(() => {
setGroupByColumns(grouping);
}, [grouping]);
// 초기 로드 시 localStorage에서 저장된 설정 불러오기
useEffect(() => {
if (tableConfig.selectedTable && currentUserId) {
@ -1652,9 +1657,20 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
data.forEach((item) => {
// 그룹 키 생성: "통화:KRW > 단위:EA"
const keyParts = groupByColumns.map((col) => {
const value = item[col];
// 카테고리/엔티티 타입인 경우 _name 필드 사용
const inputType = columnMeta?.[col]?.inputType;
let displayValue = item[col];
if (inputType === 'category' || inputType === 'entity' || inputType === 'code') {
// _name 필드가 있으면 사용 (예: division_name, writer_name)
const nameField = `${col}_name`;
if (item[nameField] !== undefined && item[nameField] !== null) {
displayValue = item[nameField];
}
}
const label = columnLabels[col] || col;
return `${label}:${value !== null && value !== undefined ? value : "-"}`;
return `${label}:${displayValue !== null && displayValue !== undefined ? displayValue : "-"}`;
});
const groupKey = keyParts.join(" > ");
@ -1677,7 +1693,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
count: items.length,
};
});
}, [data, groupByColumns, columnLabels]);
}, [data, groupByColumns, columnLabels, columnMeta]);
// 저장된 그룹 설정 불러오기
useEffect(() => {
@ -1860,124 +1876,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
if (tableConfig.stickyHeader && !isDesignMode) {
return (
<div {...domProps}>
{tableConfig.filter?.enabled && (
<div className="border-border border-b px-4 py-2 sm:px-6 sm:py-2">
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:gap-3">
<div className="flex-1">
<AdvancedSearchFilters
filters={activeFilters}
searchValues={searchValues}
onSearchValueChange={handleSearchValueChange}
onSearch={handleAdvancedSearch}
onClearFilters={handleClearAdvancedFilters}
/>
</div>
<div className="flex items-center gap-2">
{/* 전체 개수 */}
<div className="hidden sm:block text-sm text-muted-foreground whitespace-nowrap">
<span className="font-semibold text-foreground">{totalItems.toLocaleString()}</span>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setIsTableOptionsOpen(true)}
className="w-full flex-shrink-0 sm:mt-1 sm:w-auto"
>
<TableIcon className="mr-2 h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setIsFilterSettingOpen(true)}
className="w-full flex-shrink-0 sm:mt-1 sm:w-auto"
>
<Settings className="mr-2 h-4 w-4" />
</Button>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className="flex-shrink-0 w-full sm:w-auto sm:mt-1"
>
<Layers className="mr-2 h-4 w-4" />
{groupByColumns.length > 0 && (
<span className="ml-2 rounded-full bg-primary px-2 py-0.5 text-[10px] font-semibold text-primary-foreground">
{groupByColumns.length}
</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-80 p-0" align="end">
<div className="space-y-3 p-4">
<div className="space-y-1">
<h4 className="text-sm font-semibold"> </h4>
<p className="text-xs text-muted-foreground">
</p>
</div>
{/* 컬럼 목록 */}
<div className="max-h-[300px] space-y-2 overflow-y-auto">
{visibleColumns
.filter((col) => col.columnName !== "__checkbox__")
.map((col) => (
<div
key={col.columnName}
className="flex items-center gap-3 rounded p-2 hover:bg-muted/50"
>
<Checkbox
id={`group-dropdown-${col.columnName}`}
checked={groupByColumns.includes(col.columnName)}
onCheckedChange={() => toggleGroupColumn(col.columnName)}
/>
<Label
htmlFor={`group-dropdown-${col.columnName}`}
className="flex-1 cursor-pointer text-xs font-normal"
>
{columnLabels[col.columnName] || col.displayName || col.columnName}
</Label>
</div>
))}
</div>
{/* 선택된 그룹 안내 */}
{groupByColumns.length > 0 && (
<div className="rounded bg-muted/30 p-2 text-xs text-muted-foreground">
<span className="font-semibold text-foreground">
{groupByColumns.map((col) => columnLabels[col] || col).join(" → ")}
</span>
</div>
)}
{/* 초기화 버튼 */}
{groupByColumns.length > 0 && (
<Button
variant="outline"
size="sm"
onClick={() => {
setGroupByColumns([]);
if (groupSettingKey) {
localStorage.removeItem(groupSettingKey);
}
toast.success("그룹 설정이 초기화되었습니다");
}}
className="w-full text-xs"
>
</Button>
)}
</div>
</PopoverContent>
</Popover>
</div>
</div>
</div>
)}
{/* 필터 헤더는 TableSearchWidget으로 이동 */}
{/* 그룹 표시 배지 */}
{groupByColumns.length > 0 && (
@ -2040,125 +1939,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
return (
<>
<div {...domProps}>
{/* 필터 */}
{tableConfig.filter?.enabled && (
<div className="border-border flex-shrink-0 border-b px-4 py-3 sm:px-6 sm:py-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:gap-4">
<div className="flex-1">
<AdvancedSearchFilters
filters={activeFilters}
searchValues={searchValues}
onSearchValueChange={handleSearchValueChange}
onSearch={handleAdvancedSearch}
onClearFilters={handleClearAdvancedFilters}
/>
</div>
<div className="flex items-center gap-2">
{/* 전체 개수 */}
<div className="hidden sm:block text-sm text-muted-foreground whitespace-nowrap">
<span className="font-semibold text-foreground">{totalItems.toLocaleString()}</span>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setIsTableOptionsOpen(true)}
className="w-full flex-shrink-0 sm:mt-1 sm:w-auto"
>
<TableIcon className="mr-2 h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setIsFilterSettingOpen(true)}
className="w-full flex-shrink-0 sm:mt-1 sm:w-auto"
>
<Settings className="mr-2 h-4 w-4" />
</Button>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className="flex-shrink-0 w-full sm:w-auto sm:mt-1"
>
<Layers className="mr-2 h-4 w-4" />
{groupByColumns.length > 0 && (
<span className="ml-2 rounded-full bg-primary px-2 py-0.5 text-[10px] font-semibold text-primary-foreground">
{groupByColumns.length}
</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-80 p-0" align="end">
<div className="space-y-3 p-4">
<div className="space-y-1">
<h4 className="text-sm font-semibold"> </h4>
<p className="text-xs text-muted-foreground">
</p>
</div>
{/* 컬럼 목록 */}
<div className="max-h-[300px] space-y-2 overflow-y-auto">
{visibleColumns
.filter((col) => col.columnName !== "__checkbox__")
.map((col) => (
<div
key={col.columnName}
className="flex items-center gap-3 rounded p-2 hover:bg-muted/50"
>
<Checkbox
id={`group-dropdown-2-${col.columnName}`}
checked={groupByColumns.includes(col.columnName)}
onCheckedChange={() => toggleGroupColumn(col.columnName)}
/>
<Label
htmlFor={`group-dropdown-2-${col.columnName}`}
className="flex-1 cursor-pointer text-xs font-normal"
>
{columnLabels[col.columnName] || col.displayName || col.columnName}
</Label>
</div>
))}
</div>
{/* 선택된 그룹 안내 */}
{groupByColumns.length > 0 && (
<div className="rounded bg-muted/30 p-2 text-xs text-muted-foreground">
<span className="font-semibold text-foreground">
{groupByColumns.map((col) => columnLabels[col] || col).join(" → ")}
</span>
</div>
)}
{/* 초기화 버튼 */}
{groupByColumns.length > 0 && (
<Button
variant="outline"
size="sm"
onClick={() => {
setGroupByColumns([]);
if (groupSettingKey) {
localStorage.removeItem(groupSettingKey);
}
toast.success("그룹 설정이 초기화되었습니다");
}}
className="w-full text-xs"
>
</Button>
)}
</div>
</PopoverContent>
</Popover>
</div>
</div>
</div>
)}
{/* 필터 헤더는 TableSearchWidget으로 이동 */}
{/* 그룹 표시 배지 */}
{groupByColumns.length > 0 && (

View File

@ -76,7 +76,15 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
useEffect(() => {
const tables = Array.from(registeredTables.values());
console.log("🔍 [TableSearchWidget] 테이블 감지:", {
tablesCount: tables.length,
tableIds: tables.map(t => t.tableId),
selectedTableId,
autoSelectFirstTable,
});
if (autoSelectFirstTable && tables.length > 0 && !selectedTableId) {
console.log("✅ [TableSearchWidget] 첫 번째 테이블 자동 선택:", tables[0].tableId);
setSelectedTableId(tables[0].tableId);
}
}, [registeredTables, selectedTableId, autoSelectFirstTable, setSelectedTableId]);