Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into common/feat/dashboard-map

This commit is contained in:
dohyeons 2025-11-13 12:10:54 +09:00
commit bb9124d75b
36 changed files with 5744 additions and 1273 deletions

View File

@ -278,4 +278,117 @@ const hiddenColumns = new Set([
---
## 11. 화면관리 시스템 위젯 개발 가이드
### 위젯 크기 설정의 핵심 원칙
화면관리 시스템에서 위젯을 개발할 때, **크기 제어는 상위 컨테이너(`RealtimePreviewDynamic`)가 담당**합니다.
#### ✅ 올바른 크기 설정 패턴
```tsx
// 위젯 컴포넌트 내부
export function YourWidget({ component }: YourWidgetProps) {
return (
<div
className="flex h-full w-full items-center justify-between gap-2"
style={{
padding: component.style?.padding || "0.75rem",
backgroundColor: component.style?.backgroundColor,
// ❌ width, height, minHeight 등 크기 관련 속성은 제거!
}}
>
{/* 위젯 내용 */}
</div>
);
}
```
#### ❌ 잘못된 크기 설정 패턴
```tsx
// 이렇게 하면 안 됩니다!
<div
style={{
width: component.style?.width || "100%", // ❌ 상위에서 이미 제어함
height: component.style?.height || "80px", // ❌ 상위에서 이미 제어함
minHeight: "80px", // ❌ 내부 컨텐츠가 줄어듦
}}
>
```
### 이유
1. **`RealtimePreviewDynamic`**이 `baseStyle`로 이미 크기를 제어:
```tsx
const baseStyle = {
left: `${position.x}px`,
top: `${position.y}px`,
width: getWidth(), // size.width 사용
height: getHeight(), // size.height 사용
};
```
2. 위젯 내부에서 크기를 다시 설정하면:
- 중복 설정으로 인한 충돌
- 내부 컨텐츠가 설정한 크기보다 작게 표시됨
- 편집기에서 설정한 크기와 실제 렌더링 크기 불일치
### 위젯이 관리해야 할 스타일
위젯 컴포넌트는 **위젯 고유의 스타일**만 관리합니다:
- ✅ `padding`: 내부 여백
- ✅ `backgroundColor`: 배경색
- ✅ `border`, `borderRadius`: 테두리
- ✅ `gap`: 자식 요소 간격
- ✅ `flexDirection`, `alignItems`: 레이아웃 방향
### 위젯 등록 시 defaultSize
```tsx
ComponentRegistry.registerComponent({
id: "your-widget",
name: "위젯 이름",
category: "utility",
defaultSize: { width: 1200, height: 80 }, // 픽셀 단위 (필수)
component: YourWidget,
defaultProps: {
style: {
padding: "0.75rem",
// width, height는 defaultSize로 제어되므로 여기 불필요
},
},
});
```
### 레이아웃 구조
```tsx
// 전체 높이를 차지하고 내부 요소를 정렬
<div className="flex h-full w-full items-center justify-between gap-2">
{/* 왼쪽 컨텐츠 */}
<div className="flex items-center gap-3">{/* ... */}</div>
{/* 오른쪽 버튼들 */}
<div className="flex items-center gap-2 flex-shrink-0">
{/* flex-shrink-0으로 버튼이 줄어들지 않도록 보장 */}
</div>
</div>
```
### 체크리스트
위젯 개발 시 다음을 확인하세요:
- [ ] 위젯 루트 요소에 `h-full w-full` 클래스 사용
- [ ] `width`, `height`, `minHeight` 인라인 스타일 **제거**
- [ ] `padding`, `backgroundColor` 등 위젯 고유 스타일만 관리
- [ ] `defaultSize`에 적절한 기본 크기 설정
- [ ] 양끝 정렬이 필요하면 `justify-between` 사용
- [ ] 줄어들면 안 되는 요소에 `flex-shrink-0` 적용
---
**이 규칙을 지키지 않으면 사용자에게 "확인 안하지?"라는 말을 듣게 됩니다!**

View File

@ -1617,10 +1617,11 @@ export async function getCategoryColumnsByMenu(
logger.info("📥 메뉴별 카테고리 컬럼 조회 요청", { menuObjid, companyCode });
if (!menuObjid) {
return res.status(400).json({
res.status(400).json({
success: false,
message: "메뉴 OBJID가 필요합니다.",
});
return;
}
// 1. 형제 메뉴 조회
@ -1648,11 +1649,12 @@ export async function getCategoryColumnsByMenu(
logger.info("✅ 형제 메뉴 테이블 조회 완료", { tableNames, count: tableNames.length });
if (tableNames.length === 0) {
return res.json({
res.json({
success: true,
data: [],
message: "형제 메뉴에 연결된 테이블이 없습니다.",
});
return;
}
// 3. 테이블들의 카테고리 타입 컬럼 조회 (테이블 라벨 포함)

View File

@ -23,7 +23,8 @@ export interface CodeInfo {
description?: string | null;
sort_order: number;
is_active: string;
company_code: string; // 추가
company_code: string;
menu_objid?: number | null; // 메뉴 기반 코드 관리용
created_date?: Date | null;
created_by?: string | null;
updated_date?: Date | null;

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

@ -24,20 +24,19 @@ export class EntityJoinService {
try {
logger.info(`Entity 컬럼 감지 시작: ${tableName}`);
// column_labels에서 entity 타입인 컬럼들 조회
// column_labels에서 entity 및 category 타입인 컬럼들 조회 (input_type 사용)
const entityColumns = await query<{
column_name: string;
input_type: string;
reference_table: string;
reference_column: string;
display_column: string | null;
}>(
`SELECT column_name, reference_table, reference_column, display_column
`SELECT column_name, input_type, reference_table, reference_column, display_column
FROM column_labels
WHERE table_name = $1
AND web_type = $2
AND reference_table IS NOT NULL
AND reference_column IS NOT NULL`,
[tableName, "entity"]
AND input_type IN ('entity', 'category')`,
[tableName]
);
logger.info(`🔍 Entity 컬럼 조회 결과: ${entityColumns.length}개 발견`);
@ -77,18 +76,34 @@ export class EntityJoinService {
}
for (const column of entityColumns) {
// 카테고리 타입인 경우 자동으로 category_values 테이블 참조 설정
let referenceTable = column.reference_table;
let referenceColumn = column.reference_column;
let displayColumn = column.display_column;
if (column.input_type === 'category') {
// 카테고리 타입: reference 정보가 비어있어도 자동 설정
referenceTable = referenceTable || 'table_column_category_values';
referenceColumn = referenceColumn || 'value_code';
displayColumn = displayColumn || 'value_label';
logger.info(`🏷️ 카테고리 타입 자동 설정: ${column.column_name}`, {
referenceTable,
referenceColumn,
displayColumn,
});
}
logger.info(`🔍 Entity 컬럼 상세 정보:`, {
column_name: column.column_name,
reference_table: column.reference_table,
reference_column: column.reference_column,
display_column: column.display_column,
input_type: column.input_type,
reference_table: referenceTable,
reference_column: referenceColumn,
display_column: displayColumn,
});
if (
!column.column_name ||
!column.reference_table ||
!column.reference_column
) {
if (!column.column_name || !referenceTable || !referenceColumn) {
logger.warn(`⚠️ 필수 정보 누락으로 스킵: ${column.column_name}`);
continue;
}
@ -112,27 +127,28 @@ export class EntityJoinService {
separator,
screenConfig,
});
} else if (column.display_column && column.display_column !== "none") {
} else if (displayColumn && displayColumn !== "none") {
// 기존 설정된 단일 표시 컬럼 사용 (none이 아닌 경우만)
displayColumns = [column.display_column];
displayColumns = [displayColumn];
logger.info(
`🔧 기존 display_column 사용: ${column.column_name}${column.display_column}`
`🔧 기존 display_column 사용: ${column.column_name}${displayColumn}`
);
} else {
// display_column이 "none"이거나 없는 경우 기본 표시 컬럼 설정
// 🚨 display_column이 항상 "none"이므로 이 로직을 기본으로 사용
let defaultDisplayColumn = column.reference_column;
if (column.reference_table === "dept_info") {
let defaultDisplayColumn = referenceColumn;
if (referenceTable === "dept_info") {
defaultDisplayColumn = "dept_name";
} else if (column.reference_table === "company_info") {
} else if (referenceTable === "company_info") {
defaultDisplayColumn = "company_name";
} else if (column.reference_table === "user_info") {
} else if (referenceTable === "user_info") {
defaultDisplayColumn = "user_name";
} else if (referenceTable === "category_values") {
defaultDisplayColumn = "category_name";
}
displayColumns = [defaultDisplayColumn];
logger.info(
`🔧 Entity 조인 기본 표시 컬럼 설정: ${column.column_name}${defaultDisplayColumn} (${column.reference_table})`
`🔧 Entity 조인 기본 표시 컬럼 설정: ${column.column_name}${defaultDisplayColumn} (${referenceTable})`
);
logger.info(`🔍 생성된 displayColumns 배열:`, displayColumns);
}
@ -143,8 +159,8 @@ export class EntityJoinService {
const joinConfig: EntityJoinConfig = {
sourceTable: tableName,
sourceColumn: column.column_name,
referenceTable: column.reference_table,
referenceColumn: column.reference_column,
referenceTable: referenceTable, // 카테고리의 경우 자동 설정된 값 사용
referenceColumn: referenceColumn, // 카테고리의 경우 자동 설정된 값 사용
displayColumns: displayColumns,
displayColumn: displayColumns[0], // 하위 호환성
aliasColumn: aliasColumn,
@ -245,11 +261,14 @@ export class EntityJoinService {
config.displayColumn,
];
const separator = config.separator || " - ";
// 결과 컬럼 배열 (aliasColumn + _label 필드)
const resultColumns: string[] = [];
if (displayColumns.length === 0 || !displayColumns[0]) {
// displayColumns가 빈 배열이거나 첫 번째 값이 null/undefined인 경우
// 조인 테이블의 referenceColumn을 기본값으로 사용
return `COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${config.aliasColumn}`;
resultColumns.push(`COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${config.aliasColumn}`);
} else if (displayColumns.length === 1) {
// 단일 컬럼인 경우
const col = displayColumns[0];
@ -265,12 +284,18 @@ export class EntityJoinService {
"company_name",
"sales_yn",
"status",
"value_label", // table_column_category_values
"user_name", // user_info
].includes(col);
if (isJoinTableColumn) {
return `COALESCE(${alias}.${col}::TEXT, '') AS ${config.aliasColumn}`;
resultColumns.push(`COALESCE(${alias}.${col}::TEXT, '') AS ${config.aliasColumn}`);
// _label 필드도 함께 SELECT (프론트엔드 getColumnUniqueValues용)
// sourceColumn_label 형식으로 추가
resultColumns.push(`COALESCE(${alias}.${col}::TEXT, '') AS ${config.sourceColumn}_label`);
} else {
return `COALESCE(main.${col}::TEXT, '') AS ${config.aliasColumn}`;
resultColumns.push(`COALESCE(main.${col}::TEXT, '') AS ${config.aliasColumn}`);
}
} else {
// 여러 컬럼인 경우 CONCAT으로 연결
@ -291,6 +316,8 @@ export class EntityJoinService {
"company_name",
"sales_yn",
"status",
"value_label", // table_column_category_values
"user_name", // user_info
].includes(col);
if (isJoinTableColumn) {
@ -303,8 +330,11 @@ export class EntityJoinService {
})
.join(` || '${separator}' || `);
return `(${concatParts}) AS ${config.aliasColumn}`;
resultColumns.push(`(${concatParts}) AS ${config.aliasColumn}`);
}
// 모든 resultColumns를 반환
return resultColumns.join(", ");
})
.join(", ");
@ -320,6 +350,12 @@ export class EntityJoinService {
const joinClauses = uniqueReferenceTableConfigs
.map((config) => {
const alias = aliasMap.get(config.referenceTable);
// table_column_category_values는 특별한 조인 조건 필요
if (config.referenceTable === 'table_column_category_values') {
return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn} AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}'`;
}
return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn}`;
})
.join("\n");
@ -380,6 +416,14 @@ export class EntityJoinService {
return "join";
}
// table_column_category_values는 특수 조인 조건이 필요하므로 캐시 불가
if (config.referenceTable === 'table_column_category_values') {
logger.info(
`🎯 table_column_category_values는 캐시 전략 불가: ${config.sourceColumn}`
);
return "join";
}
// 참조 테이블의 캐시 가능성 확인
const displayCol =
config.displayColumn ||

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

@ -161,6 +161,8 @@ class NumberingRuleService {
companyCode: string,
menuObjid?: number
): Promise<NumberingRuleConfig[]> {
let siblingObjids: number[] = []; // catch 블록에서 접근 가능하도록 함수 최상단에 선언
try {
logger.info("메뉴별 사용 가능한 채번 규칙 조회 시작 (메뉴 스코프)", {
companyCode,
@ -170,7 +172,6 @@ class NumberingRuleService {
const pool = getPool();
// 1. 형제 메뉴 OBJID 조회
let siblingObjids: number[] = [];
if (menuObjid) {
siblingObjids = await getSiblingMenuObjids(menuObjid);
logger.info("형제 메뉴 OBJID 목록", { menuObjid, siblingObjids });

View File

@ -117,127 +117,64 @@ class TableCategoryValueService {
if (companyCode === "*") {
// 최고 관리자: 모든 카테고리 값 조회
if (menuObjid && siblingObjids.length > 0) {
// 메뉴 스코프 적용
query = `
SELECT
value_id AS "valueId",
table_name AS "tableName",
column_name AS "columnName",
value_code AS "valueCode",
value_label AS "valueLabel",
value_order AS "valueOrder",
parent_value_id AS "parentValueId",
depth,
description,
color,
icon,
is_active AS "isActive",
is_default AS "isDefault",
company_code AS "companyCode",
menu_objid AS "menuObjid",
created_at AS "createdAt",
updated_at AS "updatedAt",
created_by AS "createdBy",
updated_by AS "updatedBy"
FROM table_column_category_values
WHERE table_name = $1
AND column_name = $2
AND menu_objid = ANY($3)
`;
params = [tableName, columnName, siblingObjids];
} else {
// 테이블 스코프 (하위 호환성)
query = `
SELECT
value_id AS "valueId",
table_name AS "tableName",
column_name AS "columnName",
value_code AS "valueCode",
value_label AS "valueLabel",
value_order AS "valueOrder",
parent_value_id AS "parentValueId",
depth,
description,
color,
icon,
is_active AS "isActive",
is_default AS "isDefault",
company_code AS "companyCode",
menu_objid AS "menuObjid",
created_at AS "createdAt",
updated_at AS "updatedAt",
created_by AS "createdBy",
updated_by AS "updatedBy"
FROM table_column_category_values
WHERE table_name = $1
AND column_name = $2
`;
params = [tableName, columnName];
}
// 메뉴 스코프 제거: 같은 테이블.컬럼 조합은 모든 메뉴에서 공유
query = `
SELECT
value_id AS "valueId",
table_name AS "tableName",
column_name AS "columnName",
value_code AS "valueCode",
value_label AS "valueLabel",
value_order AS "valueOrder",
parent_value_id AS "parentValueId",
depth,
description,
color,
icon,
is_active AS "isActive",
is_default AS "isDefault",
company_code AS "companyCode",
menu_objid AS "menuObjid",
created_at AS "createdAt",
updated_at AS "updatedAt",
created_by AS "createdBy",
updated_by AS "updatedBy"
FROM table_column_category_values
WHERE table_name = $1
AND column_name = $2
`;
params = [tableName, columnName];
logger.info("최고 관리자 카테고리 값 조회");
} else {
// 일반 회사: 자신의 카테고리 값만 조회
if (menuObjid && siblingObjids.length > 0) {
// 메뉴 스코프 적용
query = `
SELECT
value_id AS "valueId",
table_name AS "tableName",
column_name AS "columnName",
value_code AS "valueCode",
value_label AS "valueLabel",
value_order AS "valueOrder",
parent_value_id AS "parentValueId",
depth,
description,
color,
icon,
is_active AS "isActive",
is_default AS "isDefault",
company_code AS "companyCode",
menu_objid AS "menuObjid",
created_at AS "createdAt",
updated_at AS "updatedAt",
created_by AS "createdBy",
updated_by AS "updatedBy"
FROM table_column_category_values
WHERE table_name = $1
AND column_name = $2
AND menu_objid = ANY($3)
AND company_code = $4
`;
params = [tableName, columnName, siblingObjids, companyCode];
} else {
// 테이블 스코프 (하위 호환성)
query = `
SELECT
value_id AS "valueId",
table_name AS "tableName",
column_name AS "columnName",
value_code AS "valueCode",
value_label AS "valueLabel",
value_order AS "valueOrder",
parent_value_id AS "parentValueId",
depth,
description,
color,
icon,
is_active AS "isActive",
is_default AS "isDefault",
company_code AS "companyCode",
menu_objid AS "menuObjid",
created_at AS "createdAt",
updated_at AS "updatedAt",
created_by AS "createdBy",
updated_by AS "updatedBy"
FROM table_column_category_values
WHERE table_name = $1
AND column_name = $2
AND company_code = $3
`;
params = [tableName, columnName, companyCode];
}
// 메뉴 스코프 제거: 같은 테이블.컬럼 조합은 모든 메뉴에서 공유
query = `
SELECT
value_id AS "valueId",
table_name AS "tableName",
column_name AS "columnName",
value_code AS "valueCode",
value_label AS "valueLabel",
value_order AS "valueOrder",
parent_value_id AS "parentValueId",
depth,
description,
color,
icon,
is_active AS "isActive",
is_default AS "isDefault",
company_code AS "companyCode",
menu_objid AS "menuObjid",
created_at AS "createdAt",
updated_at AS "updatedAt",
created_by AS "createdBy",
updated_by AS "updatedBy"
FROM table_column_category_values
WHERE table_name = $1
AND column_name = $2
AND company_code = $3
`;
params = [tableName, columnName, companyCode];
logger.info("회사별 카테고리 값 조회", { companyCode });
}

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,
};
}
@ -1494,6 +1525,7 @@ export class TableManagementService {
search?: Record<string, any>;
sortBy?: string;
sortOrder?: string;
companyCode?: string;
}
): Promise<{
data: any[];
@ -1503,7 +1535,7 @@ export class TableManagementService {
totalPages: number;
}> {
try {
const { page, size, search = {}, sortBy, sortOrder = "asc" } = options;
const { page, size, search = {}, sortBy, sortOrder = "asc", companyCode } = options;
const offset = (page - 1) * size;
logger.info(`테이블 데이터 조회: ${tableName}`, options);
@ -1517,6 +1549,14 @@ export class TableManagementService {
let searchValues: any[] = [];
let paramIndex = 1;
// 멀티테넌시 필터 추가 (company_code)
if (companyCode) {
whereConditions.push(`company_code = $${paramIndex}`);
searchValues.push(companyCode);
paramIndex++;
logger.info(`🔒 멀티테넌시 필터 추가 (기본 조회): company_code = ${companyCode}`);
}
if (search && Object.keys(search).length > 0) {
for (const [column, value] of Object.entries(search)) {
if (value !== null && value !== undefined && value !== "") {
@ -2048,6 +2088,7 @@ export class TableManagementService {
sortBy?: string;
sortOrder?: string;
enableEntityJoin?: boolean;
companyCode?: string; // 멀티테넌시 필터용
additionalJoinColumns?: Array<{
sourceTable: string;
sourceColumn: string;
@ -2213,11 +2254,20 @@ export class TableManagementService {
const selectColumns = columns.data.map((col: any) => col.column_name);
// WHERE 절 구성
const whereClause = await this.buildWhereClause(
let whereClause = await this.buildWhereClause(
tableName,
options.search
);
// 멀티테넌시 필터 추가 (company_code)
if (options.companyCode) {
const companyFilter = `main.company_code = '${options.companyCode.replace(/'/g, "''")}'`;
whereClause = whereClause
? `${whereClause} AND ${companyFilter}`
: companyFilter;
logger.info(`🔒 멀티테넌시 필터 추가 (Entity 조인): company_code = ${options.companyCode}`);
}
// ORDER BY 절 구성
const orderBy = options.sortBy
? `main.${options.sortBy} ${options.sortOrder === "desc" ? "DESC" : "ASC"}`
@ -2343,6 +2393,7 @@ export class TableManagementService {
search?: Record<string, any>;
sortBy?: string;
sortOrder?: string;
companyCode?: string;
},
startTime: number
): Promise<EntityJoinResponse> {
@ -2530,11 +2581,11 @@ export class TableManagementService {
);
}
basicResult = await this.getTableData(tableName, fallbackOptions);
basicResult = await this.getTableData(tableName, { ...fallbackOptions, companyCode: options.companyCode });
}
} else {
// Entity 조인 컬럼 검색이 없는 경우 기존 캐시 방식 사용
basicResult = await this.getTableData(tableName, options);
basicResult = await this.getTableData(tableName, { ...options, companyCode: options.companyCode });
}
// Entity 값들을 캐시에서 룩업하여 변환
@ -2807,10 +2858,14 @@ export class TableManagementService {
}
// 모든 조인이 캐시 가능한 경우: 기본 쿼리 + 캐시 룩업
else {
// whereClause에서 company_code 추출 (멀티테넌시 필터)
const companyCodeMatch = whereClause.match(/main\.company_code\s*=\s*'([^']+)'/);
const companyCode = companyCodeMatch ? companyCodeMatch[1] : undefined;
return await this.executeCachedLookup(
tableName,
cacheableJoins,
{ page: Math.floor(offset / limit) + 1, size: limit, search: {} },
{ page: Math.floor(offset / limit) + 1, size: limit, search: {}, companyCode },
startTime
);
}
@ -2831,6 +2886,13 @@ export class TableManagementService {
const dbJoins: EntityJoinConfig[] = [];
for (const config of joinConfigs) {
// table_column_category_values는 특수 조인 조건이 필요하므로 항상 DB 조인
if (config.referenceTable === 'table_column_category_values') {
dbJoins.push(config);
console.log(`🔗 DB 조인 (특수 조건): ${config.referenceTable}`);
continue;
}
// 캐시 가능성 확인
const cachedData = await referenceCacheService.getCachedReference(
config.referenceTable,

File diff suppressed because it is too large Load Diff

View File

@ -1,11 +1,11 @@
"use client";
import React, { useEffect, useState } from "react";
import React, { useEffect, useState, useMemo } from "react";
import { useParams, useSearchParams } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Loader2 } from "lucide-react";
import { screenApi } from "@/lib/api/screen";
import { ScreenDefinition, LayoutData } from "@/types/screen";
import { ScreenDefinition, LayoutData, ComponentData } from "@/types/screen";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { initializeComponents } from "@/lib/registry/components";
@ -18,8 +18,10 @@ import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRendere
import { ScreenPreviewProvider } from "@/contexts/ScreenPreviewContext";
import { useAuth } from "@/hooks/useAuth"; // 🆕 사용자 정보
import { useResponsive } from "@/lib/hooks/useResponsive"; // 🆕 반응형 감지
import { TableOptionsProvider } from "@/contexts/TableOptionsContext"; // 🆕 테이블 옵션
import { TableSearchWidgetHeightProvider, useTableSearchWidgetHeight } from "@/contexts/TableSearchWidgetHeightContext"; // 🆕 높이 관리
export default function ScreenViewPage() {
function ScreenViewPage() {
const params = useParams();
const searchParams = useSearchParams();
const router = useRouter();
@ -34,6 +36,9 @@ export default function ScreenViewPage() {
// 🆕 모바일 환경 감지
const { isMobile } = useResponsive();
// 🆕 TableSearchWidget 높이 관리
const { getHeightDiff } = useTableSearchWidgetHeight();
const [screen, setScreen] = useState<ScreenDefinition | null>(null);
const [layout, setLayout] = useState<LayoutData | null>(null);
const [loading, setLoading] = useState(true);
@ -298,16 +303,17 @@ export default function ScreenViewPage() {
return (
<ScreenPreviewProvider isPreviewMode={false}>
<div ref={containerRef} className="bg-background flex h-full w-full items-center justify-center overflow-hidden">
{/* 레이아웃 준비 중 로딩 표시 */}
{!layoutReady && (
<div className="from-muted to-muted/50 flex h-full w-full items-center justify-center bg-gradient-to-br">
<div className="border-border bg-background rounded-xl border p-8 text-center shadow-lg">
<Loader2 className="text-primary mx-auto h-8 w-8 animate-spin" />
<p className="text-foreground mt-4 text-sm font-medium"> ...</p>
</div>
</div>
)}
<TableOptionsProvider>
<div ref={containerRef} className="bg-background flex h-full w-full items-center justify-center overflow-hidden">
{/* 레이아웃 준비 중 로딩 표시 */}
{!layoutReady && (
<div className="from-muted to-muted/50 flex h-full w-full items-center justify-center bg-gradient-to-br">
<div className="border-border bg-background rounded-xl border p-8 text-center shadow-lg">
<Loader2 className="text-primary mx-auto h-8 w-8 animate-spin" />
<p className="text-foreground mt-4 text-sm font-medium"> ...</p>
</div>
</div>
)}
{/* 절대 위치 기반 렌더링 (화면관리와 동일한 방식) */}
{layoutReady && layout && layout.components.length > 0 ? (
@ -391,10 +397,49 @@ export default function ScreenViewPage() {
const regularComponents = topLevelComponents.filter((c) => !processedButtonIds.has(c.id));
// TableSearchWidget들을 먼저 찾기
const tableSearchWidgets = regularComponents.filter(
(c) => (c as any).componentId === "table-search-widget"
);
// TableSearchWidget 높이 차이를 계산하여 Y 위치 조정
const adjustedComponents = regularComponents.map((component) => {
const isTableSearchWidget = (component as any).componentId === "table-search-widget";
if (isTableSearchWidget) {
// TableSearchWidget 자체는 조정하지 않음
return component;
}
let totalHeightAdjustment = 0;
for (const widget of tableSearchWidgets) {
// 현재 컴포넌트가 이 위젯 아래에 있는지 확인
const isBelow = component.position.y > widget.position.y;
const heightDiff = getHeightDiff(screenId, widget.id);
if (isBelow && heightDiff > 0) {
totalHeightAdjustment += heightDiff;
}
}
if (totalHeightAdjustment > 0) {
return {
...component,
position: {
...component.position,
y: component.position.y + totalHeightAdjustment,
},
};
}
return component;
});
return (
<>
{/* 일반 컴포넌트들 */}
{regularComponents.map((component) => {
{adjustedComponents.map((component) => {
// 화면 관리 해상도를 사용하므로 위치 조정 불필요
return (
<RealtimePreview
@ -679,33 +724,45 @@ export default function ScreenViewPage() {
</div>
)}
{/* 편집 모달 */}
<EditModal
isOpen={editModalOpen}
onClose={() => {
setEditModalOpen(false);
setEditModalConfig({});
}}
screenId={editModalConfig.screenId}
modalSize={editModalConfig.modalSize}
editData={editModalConfig.editData}
onSave={editModalConfig.onSave}
modalTitle={editModalConfig.modalTitle}
modalDescription={editModalConfig.modalDescription}
onDataChange={(changedFormData) => {
console.log("📝 EditModal에서 데이터 변경 수신:", changedFormData);
// 변경된 데이터를 메인 폼에 반영
setFormData((prev) => {
const updatedFormData = {
...prev,
...changedFormData, // 변경된 필드들만 업데이트
};
console.log("📊 메인 폼 데이터 업데이트:", updatedFormData);
return updatedFormData;
});
}}
/>
</div>
{/* 편집 모달 */}
<EditModal
isOpen={editModalOpen}
onClose={() => {
setEditModalOpen(false);
setEditModalConfig({});
}}
screenId={editModalConfig.screenId}
modalSize={editModalConfig.modalSize}
editData={editModalConfig.editData}
onSave={editModalConfig.onSave}
modalTitle={editModalConfig.modalTitle}
modalDescription={editModalConfig.modalDescription}
onDataChange={(changedFormData) => {
console.log("📝 EditModal에서 데이터 변경 수신:", changedFormData);
// 변경된 데이터를 메인 폼에 반영
setFormData((prev) => {
const updatedFormData = {
...prev,
...changedFormData, // 변경된 필드들만 업데이트
};
console.log("📊 메인 폼 데이터 업데이트:", updatedFormData);
return updatedFormData;
});
}}
/>
</div>
</TableOptionsProvider>
</ScreenPreviewProvider>
);
}
// 실제 컴포넌트를 Provider로 감싸기
function ScreenViewPageWrapper() {
return (
<TableSearchWidgetHeightProvider>
<ScreenViewPage />
</TableSearchWidgetHeightProvider>
);
}
export default ScreenViewPageWrapper;

View File

@ -217,6 +217,18 @@ select:focus-visible {
outline-offset: 2px;
}
/* TableSearchWidget의 SelectTrigger 포커스 스타일 제거 */
[role="combobox"]:focus-visible {
outline: none !important;
box-shadow: none !important;
}
button[role="combobox"]:focus-visible {
outline: none !important;
box-shadow: none !important;
border-color: hsl(var(--input)) !important;
}
/* ===== Scrollbar Styles (Optional) ===== */
/* Webkit 기반 브라우저 (Chrome, Safari, Edge) */
::-webkit-scrollbar {

View File

@ -3,11 +3,11 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
} from "@/components/ui/resizable-dialog";
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Printer, FileDown, FileText } from "lucide-react";
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
@ -895,7 +895,7 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
</div>
</div>
<ResizableDialogFooter>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={isExporting}>
</Button>
@ -911,7 +911,7 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
<FileText className="h-4 w-4" />
{isExporting ? "생성 중..." : "WORD"}
</Button>
</ResizableDialogFooter>
</DialogFooter>
</DialogContent>
</Dialog>
);

View File

@ -4,11 +4,11 @@ import { useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
} from "@/components/ui/resizable-dialog";
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@ -131,7 +131,7 @@ export function SaveAsTemplateModal({ isOpen, onClose, onSave }: SaveAsTemplateM
</div>
</div>
<ResizableDialogFooter>
<DialogFooter>
<Button variant="outline" onClick={handleClose} disabled={isSaving}>
</Button>
@ -145,7 +145,7 @@ export function SaveAsTemplateModal({ isOpen, onClose, onSave }: SaveAsTemplateM
"저장"
)}
</Button>
</ResizableDialogFooter>
</DialogFooter>
</DialogContent>
</Dialog>
);

View File

@ -1,6 +1,6 @@
"use client";
import React, { useState, useEffect, useCallback } from "react";
import React, { useState, useEffect, useCallback, useRef } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
@ -52,6 +52,8 @@ import { FileUpload } from "@/components/screen/widgets/FileUpload";
import { AdvancedSearchFilters } from "./filters/AdvancedSearchFilters";
import { SaveModal } from "./SaveModal";
import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
import { useTableOptions } from "@/contexts/TableOptionsContext";
import { TableFilter, ColumnVisibility } from "@/types/table-options";
// 파일 데이터 타입 정의 (AttachedFileInfo와 호환)
interface FileInfo {
@ -102,6 +104,8 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
}) => {
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인
const { user } = useAuth(); // 사용자 정보 가져오기
const { registerTable, unregisterTable } = useTableOptions(); // Context 훅
const [data, setData] = useState<Record<string, any>[]>([]);
const [loading, setLoading] = useState(false);
const [searchValues, setSearchValues] = useState<Record<string, any>>({});
@ -113,6 +117,11 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
const hasInitializedWidthsRef = useRef(false);
const columnRefs = useRef<Record<string, HTMLTableCellElement | null>>({});
const isResizingRef = useRef(false);
// TableOptions 상태
const [filters, setFilters] = useState<TableFilter[]>([]);
const [grouping, setGrouping] = useState<string[]>([]);
const [columnVisibility, setColumnVisibility] = useState<ColumnVisibility[]>([]);
// SaveModal 상태 (등록/수정 통합)
const [showSaveModal, setShowSaveModal] = useState(false);
@ -147,6 +156,33 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
// 카테고리 값 매핑 캐시 (컬럼명 -> {코드 -> {라벨, 색상}})
const [categoryMappings, setCategoryMappings] = useState<Record<string, Record<string, { label: string; color?: string }>>>({});
// 테이블 등록 (Context에 등록)
const tableId = `datatable-${component.id}`;
useEffect(() => {
if (!component.tableName || !component.columns) return;
registerTable({
tableId,
label: component.title || "데이터 테이블",
tableName: component.tableName,
columns: component.columns.map((col) => ({
columnName: col.field,
columnLabel: col.label,
inputType: col.inputType || "text",
visible: col.visible !== false,
width: col.width || 150,
sortable: col.sortable,
filterable: col.filterable !== false,
})),
onFilterChange: setFilters,
onGroupChange: setGrouping,
onColumnVisibilityChange: setColumnVisibility,
});
return () => unregisterTable(tableId);
}, [component.id, component.tableName, component.columns, component.title]);
// 공통코드 옵션 가져오기
const loadCodeOptions = useCallback(
async (categoryCode: string) => {

View File

@ -46,6 +46,8 @@ import { isFileComponent } from "@/lib/utils/componentTypeUtils";
import { buildGridClasses } from "@/lib/constants/columnSpans";
import { cn } from "@/lib/utils";
import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
import { TableOptionsProvider } from "@/contexts/TableOptionsContext";
import { TableOptionsToolbar } from "./table-options/TableOptionsToolbar";
interface InteractiveScreenViewerProps {
component: ComponentData;
@ -1885,8 +1887,13 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
: component;
return (
<>
<div className="h-full" style={{ width: '100%', height: '100%' }}>
<TableOptionsProvider>
<div className="flex h-full flex-col">
{/* 테이블 옵션 툴바 */}
<TableOptionsToolbar />
{/* 메인 컨텐츠 */}
<div className="h-full flex-1" style={{ width: '100%' }}>
{/* 라벨이 있는 경우 표시 (데이터 테이블 제외) */}
{shouldShowLabel && (
<label className="mb-2 block text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
@ -1897,6 +1904,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
{/* 실제 위젯 - 상위에서 라벨을 렌더링했으므로 자식은 라벨 숨김 */}
<div className="h-full" style={{ width: '100%', height: '100%' }}>{renderInteractiveWidget(componentForRendering)}</div>
</div>
</div>
{/* 개선된 검증 패널 (선택적 표시) */}
@ -1986,6 +1994,6 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
</div>
</DialogContent>
</Dialog>
</>
</TableOptionsProvider>
);
};

View File

@ -683,6 +683,9 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
// ✅ 격자 시스템 잔재 제거: style.width, style.height 무시
const { width: styleWidth, height: styleHeight, ...styleWithoutSize } = style;
// TableSearchWidget의 경우 높이를 자동으로 설정
const isTableSearchWidget = (component as any).componentId === "table-search-widget";
const componentStyle = {
position: "absolute" as const,
left: position?.x || 0,
@ -690,7 +693,8 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
zIndex: position?.z || 1,
...styleWithoutSize, // width/height 제외한 스타일만 먼저 적용
width: size?.width || 200, // size의 픽셀 값이 최종 우선순위
height: size?.height || 10,
height: isTableSearchWidget ? "auto" : (size?.height || 10),
minHeight: isTableSearchWidget ? "48px" : undefined,
};
return (

File diff suppressed because it is too large Load Diff

View File

@ -6,7 +6,7 @@ import { Badge } from "@/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ComponentRegistry } from "@/lib/registry/ComponentRegistry";
import { ComponentDefinition, ComponentCategory } from "@/types/component";
import { Search, Package, Grid, Layers, Palette, Zap, MousePointer, Edit3, BarChart3, Database } from "lucide-react";
import { Search, Package, Grid, Layers, Palette, Zap, MousePointer, Edit3, BarChart3, Database, Wrench } from "lucide-react";
import { TableInfo, ColumnInfo } from "@/types/screen";
import TablesPanel from "./TablesPanel";
@ -64,6 +64,7 @@ export function ComponentsPanel({
action: allComponents.filter((c) => c.category === ComponentCategory.ACTION),
display: allComponents.filter((c) => c.category === ComponentCategory.DISPLAY),
layout: allComponents.filter((c) => c.category === ComponentCategory.LAYOUT),
utility: allComponents.filter((c) => c.category === ComponentCategory.UTILITY), // 🆕 유틸리티 카테고리 추가
};
}, [allComponents]);
@ -184,7 +185,7 @@ export function ComponentsPanel({
{/* 카테고리 탭 */}
<Tabs defaultValue="input" className="flex min-h-0 flex-1 flex-col">
<TabsList className="mb-3 grid h-8 w-full flex-shrink-0 grid-cols-5 gap-1 p-1">
<TabsList className="mb-3 grid h-8 w-full flex-shrink-0 grid-cols-6 gap-1 p-1">
<TabsTrigger
value="tables"
className="flex items-center justify-center gap-0.5 px-0 text-[10px]"
@ -221,6 +222,14 @@ export function ComponentsPanel({
<Layers className="h-3 w-3" />
<span className="hidden"></span>
</TabsTrigger>
<TabsTrigger
value="utility"
className="flex items-center justify-center gap-0.5 px-0 text-[10px]"
title="유틸리티"
>
<Wrench className="h-3 w-3" />
<span className="hidden"></span>
</TabsTrigger>
</TabsList>
{/* 테이블 탭 */}
@ -271,6 +280,13 @@ export function ComponentsPanel({
? getFilteredComponents("layout").map(renderComponentCard)
: renderEmptyState()}
</TabsContent>
{/* 유틸리티 컴포넌트 */}
<TabsContent value="utility" className="mt-0 flex-1 space-y-2 overflow-y-auto">
{getFilteredComponents("utility").length > 0
? getFilteredComponents("utility").map(renderComponentCard)
: renderEmptyState()}
</TabsContent>
</Tabs>
{/* 도움말 */}

View File

@ -0,0 +1,236 @@
import React, { useState, useEffect } from "react";
import { useTableOptions } from "@/contexts/TableOptionsContext";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ScrollArea } from "@/components/ui/scroll-area";
import { GripVertical, Eye, EyeOff } from "lucide-react";
import { ColumnVisibility } from "@/types/table-options";
interface Props {
isOpen: boolean;
onClose: () => void;
}
export const ColumnVisibilityPanel: React.FC<Props> = ({
isOpen,
onClose,
}) => {
const { getTable, selectedTableId } = useTableOptions();
const table = selectedTableId ? getTable(selectedTableId) : undefined;
const [localColumns, setLocalColumns] = useState<ColumnVisibility[]>([]);
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
// 테이블 정보 로드
useEffect(() => {
if (table) {
setLocalColumns(
table.columns.map((col) => ({
columnName: col.columnName,
visible: col.visible,
width: col.width,
order: 0,
}))
);
}
}, [table]);
const handleVisibilityChange = (columnName: string, visible: boolean) => {
setLocalColumns((prev) =>
prev.map((col) =>
col.columnName === columnName ? { ...col, visible } : col
)
);
};
const handleWidthChange = (columnName: string, width: number) => {
setLocalColumns((prev) =>
prev.map((col) =>
col.columnName === columnName ? { ...col, width } : col
)
);
};
const moveColumn = (fromIndex: number, toIndex: number) => {
const newColumns = [...localColumns];
const [movedItem] = newColumns.splice(fromIndex, 1);
newColumns.splice(toIndex, 0, movedItem);
setLocalColumns(newColumns);
};
const handleDragStart = (index: number) => {
setDraggedIndex(index);
};
const handleDragOver = (e: React.DragEvent, index: number) => {
e.preventDefault();
if (draggedIndex === null || draggedIndex === index) return;
moveColumn(draggedIndex, index);
setDraggedIndex(index);
};
const handleDragEnd = () => {
setDraggedIndex(null);
};
const handleApply = () => {
table?.onColumnVisibilityChange(localColumns);
// 컬럼 순서 변경 콜백 호출
if (table?.onColumnOrderChange) {
const newOrder = localColumns
.map((col) => col.columnName)
.filter((name) => name !== "__checkbox__");
table.onColumnOrderChange(newOrder);
}
onClose();
};
const handleReset = () => {
if (table) {
setLocalColumns(
table.columns.map((col) => ({
columnName: col.columnName,
visible: true,
width: 150,
order: 0,
}))
);
}
};
const visibleCount = localColumns.filter((col) => col.visible).length;
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-[95vw] sm:max-w-2xl">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
/, , .
.
</DialogDescription>
</DialogHeader>
<div className="space-y-3 sm:space-y-4">
{/* 상태 표시 */}
<div className="flex items-center justify-between rounded-lg border bg-muted/50 p-3">
<div className="text-xs text-muted-foreground sm:text-sm">
{visibleCount}/{localColumns.length}
</div>
<Button
variant="ghost"
size="sm"
onClick={handleReset}
className="h-7 text-xs"
>
</Button>
</div>
{/* 컬럼 리스트 */}
<ScrollArea className="h-[300px] sm:h-[400px]">
<div className="space-y-2 pr-4">
{localColumns.map((col, index) => {
const columnMeta = table?.columns.find(
(c) => c.columnName === col.columnName
);
return (
<div
key={col.columnName}
draggable
onDragStart={() => handleDragStart(index)}
onDragOver={(e) => handleDragOver(e, index)}
onDragEnd={handleDragEnd}
className="flex items-center gap-3 rounded-lg border bg-background p-3 transition-colors hover:bg-muted/50 cursor-move"
>
{/* 드래그 핸들 */}
<GripVertical className="h-4 w-4 shrink-0 text-muted-foreground" />
{/* 체크박스 */}
<Checkbox
checked={col.visible}
onCheckedChange={(checked) =>
handleVisibilityChange(
col.columnName,
checked as boolean
)
}
/>
{/* 가시성 아이콘 */}
{col.visible ? (
<Eye className="h-4 w-4 shrink-0 text-primary" />
) : (
<EyeOff className="h-4 w-4 shrink-0 text-muted-foreground" />
)}
{/* 컬럼명 */}
<div className="flex-1">
<div className="text-xs font-medium sm:text-sm">
{columnMeta?.columnLabel}
</div>
<div className="text-[10px] text-muted-foreground sm:text-xs">
{col.columnName}
</div>
</div>
{/* 너비 설정 */}
<div className="flex items-center gap-2">
<Label className="text-xs text-muted-foreground">
:
</Label>
<Input
type="number"
value={col.width || 150}
onChange={(e) =>
handleWidthChange(
col.columnName,
parseInt(e.target.value) || 150
)
}
className="h-7 w-16 text-xs sm:h-8 sm:w-20 sm:text-sm"
min={50}
max={500}
/>
</div>
</div>
);
})}
</div>
</ScrollArea>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button
onClick={handleApply}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,368 @@
import React, { useState, useEffect } from "react";
import { useTableOptions } from "@/contexts/TableOptionsContext";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Checkbox } from "@/components/ui/checkbox";
import { Plus, X } from "lucide-react";
import { TableFilter } from "@/types/table-options";
interface Props {
isOpen: boolean;
onClose: () => void;
onFiltersApplied?: (filters: TableFilter[]) => void; // 필터 적용 시 콜백
}
// 필터 타입별 연산자
const operatorsByType: Record<string, Record<string, string>> = {
text: {
contains: "포함",
equals: "같음",
startsWith: "시작",
endsWith: "끝",
notEquals: "같지 않음",
},
number: {
equals: "같음",
gt: "보다 큼",
lt: "보다 작음",
gte: "이상",
lte: "이하",
notEquals: "같지 않음",
},
date: {
equals: "같음",
gt: "이후",
lt: "이전",
gte: "이후 포함",
lte: "이전 포함",
},
select: {
equals: "같음",
notEquals: "같지 않음",
},
};
// 컬럼 필터 설정 인터페이스
interface ColumnFilterConfig {
columnName: string;
columnLabel: string;
inputType: string;
enabled: boolean;
filterType: "text" | "number" | "date" | "select";
width?: number; // 필터 입력 필드 너비 (px)
selectOptions?: Array<{ label: string; value: string }>;
}
export const FilterPanel: React.FC<Props> = ({ isOpen, onClose, onFiltersApplied }) => {
const { getTable, selectedTableId } = useTableOptions();
const table = selectedTableId ? getTable(selectedTableId) : undefined;
const [columnFilters, setColumnFilters] = useState<ColumnFilterConfig[]>([]);
const [selectAll, setSelectAll] = useState(false);
// localStorage에서 저장된 필터 설정 불러오기
useEffect(() => {
if (table?.columns && table?.tableName) {
const storageKey = `table_filters_${table.tableName}`;
const savedFilters = localStorage.getItem(storageKey);
let filters: ColumnFilterConfig[];
if (savedFilters) {
try {
const parsed = JSON.parse(savedFilters) as ColumnFilterConfig[];
// 저장된 설정과 현재 컬럼 병합
filters = table.columns
.filter((col) => col.filterable !== false)
.map((col) => {
const saved = parsed.find((f) => f.columnName === col.columnName);
return saved || {
columnName: col.columnName,
columnLabel: col.columnLabel,
inputType: col.inputType || "text",
enabled: false,
filterType: mapInputTypeToFilterType(col.inputType || "text"),
};
});
} catch (error) {
console.error("저장된 필터 설정 불러오기 실패:", error);
filters = table.columns
.filter((col) => col.filterable !== false)
.map((col) => ({
columnName: col.columnName,
columnLabel: col.columnLabel,
inputType: col.inputType || "text",
enabled: false,
filterType: mapInputTypeToFilterType(col.inputType || "text"),
}));
}
} else {
filters = table.columns
.filter((col) => col.filterable !== false)
.map((col) => ({
columnName: col.columnName,
columnLabel: col.columnLabel,
inputType: col.inputType || "text",
enabled: false,
filterType: mapInputTypeToFilterType(col.inputType || "text"),
}));
}
setColumnFilters(filters);
}
}, [table?.columns, table?.tableName]);
// inputType을 filterType으로 매핑
const mapInputTypeToFilterType = (
inputType: string
): "text" | "number" | "date" | "select" => {
if (inputType.includes("number") || inputType.includes("decimal")) {
return "number";
}
if (inputType.includes("date") || inputType.includes("time")) {
return "date";
}
if (
inputType.includes("select") ||
inputType.includes("code") ||
inputType.includes("category")
) {
return "select";
}
return "text";
};
// 전체 선택/해제
const toggleSelectAll = (checked: boolean) => {
setSelectAll(checked);
setColumnFilters((prev) =>
prev.map((filter) => ({ ...filter, enabled: checked }))
);
};
// 개별 필터 토글
const toggleFilter = (columnName: string) => {
setColumnFilters((prev) =>
prev.map((filter) =>
filter.columnName === columnName
? { ...filter, enabled: !filter.enabled }
: filter
)
);
};
// 필터 타입 변경
const updateFilterType = (
columnName: string,
filterType: "text" | "number" | "date" | "select"
) => {
setColumnFilters((prev) =>
prev.map((filter) =>
filter.columnName === columnName ? { ...filter, filterType } : filter
)
);
};
// 저장
const applyFilters = () => {
// enabled된 필터들만 TableFilter로 변환
const activeFilters: TableFilter[] = columnFilters
.filter((cf) => cf.enabled)
.map((cf) => ({
columnName: cf.columnName,
operator: "contains", // 기본 연산자
value: "",
filterType: cf.filterType,
width: cf.width || 200, // 너비 포함 (기본 200px)
}));
// localStorage에 저장
if (table?.tableName) {
const storageKey = `table_filters_${table.tableName}`;
localStorage.setItem(storageKey, JSON.stringify(columnFilters));
}
table?.onFilterChange(activeFilters);
// 콜백으로 활성화된 필터 정보 전달
onFiltersApplied?.(activeFilters);
onClose();
};
// 초기화 (즉시 저장 및 적용)
const clearFilters = () => {
const clearedFilters = columnFilters.map((filter) => ({
...filter,
enabled: false
}));
setColumnFilters(clearedFilters);
setSelectAll(false);
// localStorage에서 제거
if (table?.tableName) {
const storageKey = `table_filters_${table.tableName}`;
localStorage.removeItem(storageKey);
}
// 빈 필터 배열로 적용
table?.onFilterChange([]);
// 콜백으로 빈 필터 정보 전달
onFiltersApplied?.([]);
// 즉시 닫기
onClose();
};
const enabledCount = columnFilters.filter((f) => f.enabled).length;
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-[95vw] sm:max-w-[600px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
. .
</DialogDescription>
</DialogHeader>
<div className="space-y-3 sm:space-y-4">
{/* 전체 선택/해제 */}
<div className="flex items-center justify-between rounded-lg border bg-muted/30 p-3">
<div className="flex items-center gap-3">
<Checkbox
checked={selectAll}
onCheckedChange={(checked) =>
toggleSelectAll(checked as boolean)
}
/>
<span className="text-sm font-medium"> /</span>
</div>
<div className="text-xs text-muted-foreground">
{enabledCount} / {columnFilters.length}
</div>
</div>
{/* 컬럼 필터 리스트 */}
<ScrollArea className="h-[400px] sm:h-[450px]">
<div className="space-y-2 pr-4">
{columnFilters.map((filter) => (
<div
key={filter.columnName}
className="flex items-center gap-3 rounded-lg border bg-background p-3 transition-colors hover:bg-muted/50"
>
{/* 체크박스 */}
<Checkbox
checked={filter.enabled}
onCheckedChange={() => toggleFilter(filter.columnName)}
/>
{/* 컬럼 정보 */}
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">
{filter.columnLabel}
</div>
<div className="truncate text-xs text-muted-foreground">
{filter.columnName}
</div>
</div>
{/* 필터 타입 선택 */}
<Select
value={filter.filterType}
onValueChange={(val: any) =>
updateFilterType(filter.columnName, val)
}
disabled={!filter.enabled}
>
<SelectTrigger className="h-8 w-[110px] text-xs sm:h-9 sm:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="text"></SelectItem>
<SelectItem value="number"></SelectItem>
<SelectItem value="date"></SelectItem>
<SelectItem value="select"></SelectItem>
</SelectContent>
</Select>
{/* 너비 입력 */}
<Input
type="number"
value={filter.width || 200}
onChange={(e) => {
const newWidth = parseInt(e.target.value) || 200;
setColumnFilters((prev) =>
prev.map((f) =>
f.columnName === filter.columnName
? { ...f, width: newWidth }
: f
)
);
}}
disabled={!filter.enabled}
placeholder="너비"
className="h-8 w-[80px] text-xs sm:h-9 sm:text-sm"
min={50}
max={500}
/>
<span className="text-xs text-muted-foreground">px</span>
</div>
))}
</div>
</ScrollArea>
{/* 안내 메시지 */}
<div className="rounded-lg bg-muted/50 p-3 text-center text-xs text-muted-foreground">
1
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="ghost"
onClick={clearFilters}
className="h-8 text-xs sm:h-10 sm:text-sm"
>
</Button>
<Button
variant="outline"
onClick={onClose}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button
onClick={applyFilters}
disabled={enabledCount === 0}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,221 @@
import React, { useState } from "react";
import { useTableOptions } from "@/contexts/TableOptionsContext";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { ScrollArea } from "@/components/ui/scroll-area";
import { ArrowRight, GripVertical, X } from "lucide-react";
interface Props {
isOpen: boolean;
onClose: () => void;
}
export const GroupingPanel: React.FC<Props> = ({
isOpen,
onClose,
}) => {
const { getTable, selectedTableId } = useTableOptions();
const table = selectedTableId ? getTable(selectedTableId) : undefined;
const [selectedColumns, setSelectedColumns] = useState<string[]>([]);
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
const toggleColumn = (columnName: string) => {
if (selectedColumns.includes(columnName)) {
setSelectedColumns(selectedColumns.filter((c) => c !== columnName));
} else {
setSelectedColumns([...selectedColumns, columnName]);
}
};
const removeColumn = (columnName: string) => {
setSelectedColumns(selectedColumns.filter((c) => c !== columnName));
};
const moveColumn = (fromIndex: number, toIndex: number) => {
const newColumns = [...selectedColumns];
const [movedItem] = newColumns.splice(fromIndex, 1);
newColumns.splice(toIndex, 0, movedItem);
setSelectedColumns(newColumns);
};
const handleDragStart = (index: number) => {
setDraggedIndex(index);
};
const handleDragOver = (e: React.DragEvent, index: number) => {
e.preventDefault();
if (draggedIndex === null || draggedIndex === index) return;
moveColumn(draggedIndex, index);
setDraggedIndex(index);
};
const handleDragEnd = () => {
setDraggedIndex(null);
};
const applyGrouping = () => {
table?.onGroupChange(selectedColumns);
onClose();
};
const clearGrouping = () => {
setSelectedColumns([]);
table?.onGroupChange([]);
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-[95vw] sm:max-w-xl">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
</DialogDescription>
</DialogHeader>
<div className="space-y-3 sm:space-y-4">
{/* 선택된 컬럼 (드래그 가능) */}
{selectedColumns.length > 0 && (
<div>
<div className="mb-2 flex items-center justify-between">
<div className="text-xs font-medium sm:text-sm">
({selectedColumns.length})
</div>
<Button
variant="ghost"
size="sm"
onClick={clearGrouping}
className="h-7 text-xs"
>
</Button>
</div>
<div className="space-y-2">
{selectedColumns.map((colName, index) => {
const col = table?.columns.find(
(c) => c.columnName === colName
);
if (!col) return null;
return (
<div
key={colName}
draggable
onDragStart={() => handleDragStart(index)}
onDragOver={(e) => handleDragOver(e, index)}
onDragEnd={handleDragEnd}
className="flex items-center gap-2 rounded-lg border bg-primary/5 p-2 sm:p-3 transition-colors hover:bg-primary/10 cursor-move"
>
<GripVertical className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<div className="flex h-5 w-5 sm:h-6 sm:w-6 items-center justify-center rounded-full bg-primary text-xs text-primary-foreground flex-shrink-0">
{index + 1}
</div>
<div className="flex-1 min-w-0">
<div className="text-xs font-medium sm:text-sm truncate">
{col.columnLabel}
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => removeColumn(colName)}
className="h-6 w-6 p-0 flex-shrink-0"
>
<X className="h-3 w-3" />
</Button>
</div>
);
})}
</div>
{/* 그룹화 순서 미리보기 */}
<div className="mt-2 rounded-lg border bg-muted/30 p-2">
<div className="flex flex-wrap items-center gap-2 text-xs">
{selectedColumns.map((colName, index) => {
const col = table?.columns.find(
(c) => c.columnName === colName
);
return (
<React.Fragment key={colName}>
<span className="font-medium">{col?.columnLabel}</span>
{index < selectedColumns.length - 1 && (
<ArrowRight className="h-3 w-3 text-muted-foreground" />
)}
</React.Fragment>
);
})}
</div>
</div>
</div>
)}
{/* 사용 가능한 컬럼 */}
<div>
<div className="mb-2 text-xs font-medium sm:text-sm">
</div>
<ScrollArea className={selectedColumns.length > 0 ? "h-[280px] sm:h-[320px]" : "h-[400px] sm:h-[450px]"}>
<div className="space-y-2 pr-4">
{table?.columns
.filter((col) => !selectedColumns.includes(col.columnName))
.map((col) => {
return (
<div
key={col.columnName}
className="flex items-center gap-3 rounded-lg border bg-background p-2 sm:p-3 transition-colors hover:bg-muted/50 cursor-pointer"
onClick={() => toggleColumn(col.columnName)}
>
<Checkbox
checked={false}
onCheckedChange={() => toggleColumn(col.columnName)}
className="flex-shrink-0"
/>
<div className="flex-1 min-w-0">
<div className="text-xs font-medium sm:text-sm truncate">
{col.columnLabel}
</div>
<div className="text-[10px] text-muted-foreground sm:text-xs truncate">
{col.columnName}
</div>
</div>
</div>
);
})}
</div>
</ScrollArea>
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={onClose}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button
onClick={applyGrouping}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,126 @@
import React, { useState } from "react";
import { useTableOptions } from "@/contexts/TableOptionsContext";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Settings, Filter, Layers } from "lucide-react";
import { ColumnVisibilityPanel } from "./ColumnVisibilityPanel";
import { FilterPanel } from "./FilterPanel";
import { GroupingPanel } from "./GroupingPanel";
export const TableOptionsToolbar: React.FC = () => {
const { registeredTables, selectedTableId, setSelectedTableId } =
useTableOptions();
const [columnPanelOpen, setColumnPanelOpen] = useState(false);
const [filterPanelOpen, setFilterPanelOpen] = useState(false);
const [groupPanelOpen, setGroupPanelOpen] = useState(false);
const tableList = Array.from(registeredTables.values());
const selectedTable = selectedTableId
? registeredTables.get(selectedTableId)
: null;
// 테이블이 없으면 표시하지 않음
if (tableList.length === 0) {
return null;
}
return (
<div className="flex items-center gap-2 border-b bg-background p-2">
{/* 테이블 선택 (2개 이상일 때만 표시) */}
{tableList.length > 1 && (
<Select
value={selectedTableId || ""}
onValueChange={setSelectedTableId}
>
<SelectTrigger className="h-8 w-48 text-xs sm:h-9 sm:w-64 sm:text-sm">
<SelectValue placeholder="테이블 선택" />
</SelectTrigger>
<SelectContent>
{tableList.map((table) => (
<SelectItem key={table.tableId} value={table.tableId}>
{table.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{/* 테이블이 1개일 때는 이름만 표시 */}
{tableList.length === 1 && (
<div className="text-xs font-medium sm:text-sm">
{tableList[0].label}
</div>
)}
{/* 컬럼 수 표시 */}
<div className="text-xs text-muted-foreground sm:text-sm">
{selectedTable?.columns.length || 0}
</div>
<div className="flex-1" />
{/* 옵션 버튼들 */}
<Button
variant="outline"
size="sm"
onClick={() => setColumnPanelOpen(true)}
className="h-8 text-xs sm:h-9 sm:text-sm"
disabled={!selectedTableId}
>
<Settings className="mr-2 h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setFilterPanelOpen(true)}
className="h-8 text-xs sm:h-9 sm:text-sm"
disabled={!selectedTableId}
>
<Filter className="mr-2 h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setGroupPanelOpen(true)}
className="h-8 text-xs sm:h-9 sm:text-sm"
disabled={!selectedTableId}
>
<Layers className="mr-2 h-4 w-4" />
</Button>
{/* 패널들 */}
{selectedTableId && (
<>
<ColumnVisibilityPanel
tableId={selectedTableId}
open={columnPanelOpen}
onOpenChange={setColumnPanelOpen}
/>
<FilterPanel
tableId={selectedTableId}
open={filterPanelOpen}
onOpenChange={setFilterPanelOpen}
/>
<GroupingPanel
tableId={selectedTableId}
open={groupPanelOpen}
onOpenChange={setGroupPanelOpen}
/>
</>
)}
</div>
);
};

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

@ -39,6 +39,8 @@ import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
import { useAuth } from "@/hooks/useAuth";
import { useTableOptions } from "@/contexts/TableOptionsContext";
import { TableFilter, ColumnVisibility } from "@/types/table-options";
// 그룹화된 데이터 인터페이스
interface GroupedData {
@ -65,6 +67,12 @@ export function FlowWidget({
}: FlowWidgetProps) {
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인
const { user } = useAuth(); // 사용자 정보 가져오기
const { registerTable, unregisterTable } = useTableOptions(); // Context 훅
// TableOptions 상태
const [filters, setFilters] = useState<TableFilter[]>([]);
const [grouping, setGrouping] = useState<string[]>([]);
const [columnVisibility, setColumnVisibility] = useState<ColumnVisibility[]>([]);
// 숫자 포맷팅 함수
const formatValue = (value: any): string => {
@ -301,6 +309,36 @@ export function FlowWidget({
toast.success("그룹이 해제되었습니다");
}, [groupSettingKey]);
// 테이블 등록 (선택된 스텝이 있을 때)
useEffect(() => {
if (!selectedStepId || !stepDataColumns || stepDataColumns.length === 0) {
return;
}
const tableId = `flow-widget-${component.id}-step-${selectedStepId}`;
const currentStep = steps.find((s) => s.id === selectedStepId);
registerTable({
tableId,
label: `${flowName || "플로우"} - ${currentStep?.name || "스텝"}`,
tableName: "flow_step_data",
columns: stepDataColumns.map((col) => ({
columnName: col,
columnLabel: columnLabels[col] || col,
inputType: "text",
visible: true,
width: 150,
sortable: true,
filterable: true,
})),
onFilterChange: setFilters,
onGroupChange: setGrouping,
onColumnVisibilityChange: setColumnVisibility,
});
return () => unregisterTable(tableId);
}, [selectedStepId, stepDataColumns, columnLabels, flowName, steps, component.id]);
// 🆕 데이터 그룹화
const groupedData = useMemo((): GroupedData[] => {
const dataToGroup = filteredData.length > 0 ? filteredData : stepData;

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

@ -0,0 +1,125 @@
import React, {
createContext,
useContext,
useState,
useCallback,
ReactNode,
} from "react";
import {
TableRegistration,
TableOptionsContextValue,
} from "@/types/table-options";
const TableOptionsContext = createContext<TableOptionsContextValue | undefined>(
undefined
);
export const TableOptionsProvider: React.FC<{ children: ReactNode }> = ({
children,
}) => {
const [registeredTables, setRegisteredTables] = useState<
Map<string, TableRegistration>
>(new Map());
const [selectedTableId, setSelectedTableId] = useState<string | null>(null);
/**
*
*/
const registerTable = useCallback((registration: TableRegistration) => {
setRegisteredTables((prev) => {
const newMap = new Map(prev);
newMap.set(registration.tableId, registration);
// 첫 번째 테이블이면 자동 선택
if (newMap.size === 1) {
setSelectedTableId(registration.tableId);
}
return newMap;
});
}, []);
/**
*
*/
const unregisterTable = useCallback(
(tableId: string) => {
setRegisteredTables((prev) => {
const newMap = new Map(prev);
const removed = newMap.delete(tableId);
if (removed) {
// 선택된 테이블이 제거되면 첫 번째 테이블 선택
if (selectedTableId === tableId) {
const firstTableId = newMap.keys().next().value;
setSelectedTableId(firstTableId || null);
}
}
return newMap;
});
},
[selectedTableId]
);
/**
*
*/
const getTable = useCallback(
(tableId: string) => {
return registeredTables.get(tableId);
},
[registeredTables]
);
/**
*
*/
const updateTableDataCount = useCallback((tableId: string, count: number) => {
setRegisteredTables((prev) => {
const table = prev.get(tableId);
if (table) {
// 기존 테이블 정보에 dataCount만 업데이트
const updatedTable = { ...table, dataCount: count };
const newMap = new Map(prev);
newMap.set(tableId, updatedTable);
console.log("🔄 [TableOptionsContext] 데이터 건수 업데이트:", {
tableId,
count,
updated: true,
});
return newMap;
}
console.warn("⚠️ [TableOptionsContext] 테이블을 찾을 수 없음:", tableId);
return prev;
});
}, []);
return (
<TableOptionsContext.Provider
value={{
registeredTables,
registerTable,
unregisterTable,
getTable,
updateTableDataCount,
selectedTableId,
setSelectedTableId,
}}
>
{children}
</TableOptionsContext.Provider>
);
};
/**
* Context Hook
*/
export const useTableOptions = () => {
const context = useContext(TableOptionsContext);
if (!context) {
throw new Error("useTableOptions must be used within TableOptionsProvider");
}
return context;
};

View File

@ -0,0 +1,87 @@
"use client";
import React, { createContext, useContext, useState, useCallback } from "react";
interface WidgetHeight {
screenId: number;
componentId: string;
height: number;
originalHeight: number; // 디자이너에서 설정한 원래 높이
}
interface TableSearchWidgetHeightContextValue {
widgetHeights: Map<string, WidgetHeight>;
setWidgetHeight: (screenId: number, componentId: string, height: number, originalHeight: number) => void;
getWidgetHeight: (screenId: number, componentId: string) => WidgetHeight | undefined;
getHeightDiff: (screenId: number, componentId: string) => number; // 실제 높이 - 원래 높이
}
const TableSearchWidgetHeightContext = createContext<TableSearchWidgetHeightContextValue | undefined>(
undefined
);
export function TableSearchWidgetHeightProvider({ children }: { children: React.ReactNode }) {
const [widgetHeights, setWidgetHeights] = useState<Map<string, WidgetHeight>>(new Map());
const setWidgetHeight = useCallback(
(screenId: number, componentId: string, height: number, originalHeight: number) => {
const key = `${screenId}_${componentId}`;
setWidgetHeights((prev) => {
const newMap = new Map(prev);
newMap.set(key, {
screenId,
componentId,
height,
originalHeight,
});
return newMap;
});
},
[]
);
const getWidgetHeight = useCallback(
(screenId: number, componentId: string): WidgetHeight | undefined => {
const key = `${screenId}_${componentId}`;
return widgetHeights.get(key);
},
[widgetHeights]
);
const getHeightDiff = useCallback(
(screenId: number, componentId: string): number => {
const widgetHeight = getWidgetHeight(screenId, componentId);
if (!widgetHeight) return 0;
const diff = widgetHeight.height - widgetHeight.originalHeight;
return diff;
},
[getWidgetHeight]
);
return (
<TableSearchWidgetHeightContext.Provider
value={{
widgetHeights,
setWidgetHeight,
getWidgetHeight,
getHeightDiff,
}}
>
{children}
</TableSearchWidgetHeightContext.Provider>
);
}
export function useTableSearchWidgetHeight() {
const context = useContext(TableSearchWidgetHeightContext);
if (!context) {
throw new Error(
"useTableSearchWidgetHeight must be used within TableSearchWidgetHeightProvider"
);
}
return context;
}

View File

@ -42,6 +42,7 @@ import "./repeater-field-group/RepeaterFieldGroupRenderer";
import "./flow-widget/FlowWidgetRenderer";
import "./numbering-rule/NumberingRuleRenderer";
import "./category-manager/CategoryManagerRenderer";
import "./table-search-widget"; // 🆕 테이블 검색 필터 위젯
/**
*

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,10 +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
@ -37,6 +41,16 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
const minLeftWidth = componentConfig.minLeftWidth || 200;
const minRightWidth = componentConfig.minRightWidth || 300;
// TableOptions Context
const { registerTable, unregisterTable } = useTableOptions();
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[]>([]);
// 데이터 상태
const [leftData, setLeftData] = useState<any[]>([]);
const [rightData, setRightData] = useState<any[] | any>(null); // 조인 모드는 배열, 상세 모드는 객체
@ -149,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;
@ -156,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) {
@ -185,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(
@ -272,6 +370,102 @@ 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}`;
// 🔧 화면에 표시되는 컬럼 사용 (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;
// 테이블명이 있으면 등록
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?.columns, leftColumnLabels, component.title, isDesignMode, getLeftColumnUniqueValues]);
// 우측 테이블은 검색 컴포넌트 등록 제외 (좌측 마스터 테이블만 검색 가능)
// 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(() => {
const loadLeftColumnLabels = async () => {
@ -713,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) {
@ -721,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;
@ -860,6 +1099,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
</div>
) : (
(() => {
// 🔧 로컬 검색 필터 적용
const filteredData = leftSearchQuery
? leftData.filter((item) => {
const searchLower = leftSearchQuery.toLowerCase();
@ -870,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,
@ -883,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

@ -45,6 +45,9 @@ import { AdvancedSearchFilters } from "@/components/screen/filters/AdvancedSearc
import { SingleTableWithSticky } from "./SingleTableWithSticky";
import { CardModeRenderer } from "./CardModeRenderer";
import { TableOptionsModal } from "@/components/common/TableOptionsModal";
import { useTableOptions } from "@/contexts/TableOptionsContext";
import { TableFilter, ColumnVisibility } from "@/types/table-options";
import { useAuth } from "@/hooks/useAuth";
// ========================================
// 인터페이스
@ -243,6 +246,72 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// 상태 관리
// ========================================
// 사용자 정보 (props에서 받거나 useAuth에서 가져오기)
const { userId: authUserId } = useAuth();
const currentUserId = userId || authUserId;
// TableOptions Context
const { registerTable, unregisterTable, updateTableDataCount } = useTableOptions();
const [filters, setFilters] = useState<TableFilter[]>([]);
const [grouping, setGrouping] = useState<string[]>([]);
const [columnVisibility, setColumnVisibility] = useState<ColumnVisibility[]>([]);
// filters가 변경되면 searchValues 업데이트 (실시간 검색)
useEffect(() => {
const newSearchValues: Record<string, any> = {};
filters.forEach((filter) => {
if (filter.value) {
newSearchValues[filter.columnName] = filter.value;
}
});
console.log("🔍 [TableListComponent] filters → searchValues:", {
filters: filters.length,
searchValues: newSearchValues,
});
setSearchValues(newSearchValues);
setCurrentPage(1); // 필터 변경 시 첫 페이지로
}, [filters]);
// grouping이 변경되면 groupByColumns 업데이트
useEffect(() => {
setGroupByColumns(grouping);
}, [grouping]);
// 초기 로드 시 localStorage에서 저장된 설정 불러오기
useEffect(() => {
if (tableConfig.selectedTable && currentUserId) {
const storageKey = `table_column_visibility_${tableConfig.selectedTable}_${currentUserId}`;
const savedSettings = localStorage.getItem(storageKey);
if (savedSettings) {
try {
const parsed = JSON.parse(savedSettings) as ColumnVisibility[];
setColumnVisibility(parsed);
} catch (error) {
console.error("저장된 컬럼 설정 불러오기 실패:", error);
}
}
}
}, [tableConfig.selectedTable, currentUserId]);
// columnVisibility 변경 시 컬럼 순서 및 가시성 적용
useEffect(() => {
if (columnVisibility.length > 0) {
const newOrder = columnVisibility
.map((cv) => cv.columnName)
.filter((name) => name !== "__checkbox__"); // 체크박스 제외
setColumnOrder(newOrder);
// localStorage에 저장 (사용자별)
if (tableConfig.selectedTable && currentUserId) {
const storageKey = `table_column_visibility_${tableConfig.selectedTable}_${currentUserId}`;
localStorage.setItem(storageKey, JSON.stringify(columnVisibility));
}
}
}, [columnVisibility, tableConfig.selectedTable, currentUserId]);
const [data, setData] = useState<Record<string, any>[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@ -288,6 +357,156 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const [viewMode, setViewMode] = useState<"table" | "card" | "grouped-card">("table");
const [frozenColumns, setFrozenColumns] = useState<string[]>([]);
// 테이블 등록 (Context에 등록)
const tableId = `table-list-${component.id}`;
useEffect(() => {
// tableConfig.columns를 직접 사용 (displayColumns는 비어있을 수 있음)
const columnsToRegister = (tableConfig.columns || [])
.filter((col) => col.visible !== false && col.columnName !== "__checkbox__");
if (!tableConfig.selectedTable || !columnsToRegister || columnsToRegister.length === 0) {
return;
}
// 컬럼의 고유 값 조회 함수
const getColumnUniqueValues = async (columnName: string) => {
console.log("🔍 [getColumnUniqueValues] 호출됨:", {
columnName,
dataLength: data.length,
columnMeta: columnMeta[columnName],
sampleData: data[0],
});
const meta = columnMeta[columnName];
const inputType = meta?.inputType || "text";
// 카테고리 타입인 경우 전체 정의된 값 조회 (백엔드 API)
if (inputType === "category") {
try {
console.log("🔍 [getColumnUniqueValues] 카테고리 전체 값 조회:", {
tableName: tableConfig.selectedTable,
columnName,
});
// API 클라이언트 사용 (쿠키 인증 자동 처리)
const { apiClient } = await import("@/lib/api/client");
const response = await apiClient.get(
`/table-categories/${tableConfig.selectedTable}/${columnName}/values`
);
if (response.data.success && response.data.data) {
const categoryOptions = response.data.data.map((item: any) => ({
value: item.valueCode, // 카멜케이스
label: item.valueLabel, // 카멜케이스
}));
console.log("✅ [getColumnUniqueValues] 카테고리 전체 값:", {
columnName,
count: categoryOptions.length,
options: categoryOptions,
});
return categoryOptions;
} else {
console.warn("⚠️ [getColumnUniqueValues] 응답 형식 오류:", response.data);
}
} catch (error: any) {
console.error("❌ [getColumnUniqueValues] 카테고리 조회 실패:", {
error: error.message,
response: error.response?.data,
status: error.response?.status,
columnName,
tableName: tableConfig.selectedTable,
});
// 에러 시 현재 데이터 기반으로 fallback
}
}
// 일반 타입 또는 카테고리 조회 실패 시: 현재 데이터 기반
const isLabelType = ["category", "entity", "code"].includes(inputType);
const labelField = isLabelType ? `${columnName}_name` : columnName;
console.log("🔍 [getColumnUniqueValues] 데이터 기반 조회:", {
columnName,
inputType,
isLabelType,
labelField,
hasLabelField: data[0] && labelField in data[0],
sampleLabelValue: data[0] ? data[0][labelField] : undefined,
});
// 현재 로드된 데이터에서 고유 값 추출
const uniqueValuesMap = new Map<string, string>(); // value -> label
data.forEach((row) => {
const value = row[columnName];
if (value !== null && value !== undefined && value !== "") {
// 백엔드 조인된 _name 필드 사용 (없으면 원본 값)
const label = isLabelType && row[labelField] ? row[labelField] : String(value);
uniqueValuesMap.set(String(value), label);
}
});
// Map을 배열로 변환하고 라벨 기준으로 정렬
const result = Array.from(uniqueValuesMap.entries())
.map(([value, label]) => ({
value: value,
label: label,
}))
.sort((a, b) => a.label.localeCompare(b.label));
console.log("✅ [getColumnUniqueValues] 데이터 기반 결과:", {
columnName,
inputType,
isLabelType,
labelField,
uniqueCount: result.length,
values: result,
});
return result;
};
const registration = {
tableId,
label: tableLabel || tableConfig.selectedTable,
tableName: tableConfig.selectedTable,
dataCount: totalItems || data.length, // 초기 데이터 건수 포함
columns: columnsToRegister.map((col) => ({
columnName: col.columnName || col.field,
columnLabel: columnLabels[col.columnName] || col.displayName || col.label || col.columnName || col.field,
inputType: columnMeta[col.columnName]?.inputType || "text",
visible: col.visible !== false,
width: columnWidths[col.columnName] || col.width || 150,
sortable: col.sortable !== false,
filterable: col.searchable !== false,
})),
onFilterChange: setFilters,
onGroupChange: setGrouping,
onColumnVisibilityChange: setColumnVisibility,
getColumnUniqueValues, // 고유 값 조회 함수 등록
};
registerTable(registration);
return () => {
unregisterTable(tableId);
};
}, [
tableId,
tableConfig.selectedTable,
tableConfig.columns,
columnLabels,
columnMeta, // columnMeta가 변경되면 재등록 (inputType 정보 필요)
columnWidths,
tableLabel,
data, // 데이터 자체가 변경되면 재등록 (고유 값 조회용)
totalItems, // 전체 항목 수가 변경되면 재등록
registerTable,
unregisterTable,
]);
// 🆕 초기 로드 시 localStorage에서 컬럼 순서 불러오기
useEffect(() => {
if (!tableConfig.selectedTable || !userId) return;
@ -481,42 +700,20 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
.filter(([_, meta]) => meta.inputType === "category")
.map(([columnName, _]) => columnName);
console.log("🔍 [TableList] 카테고리 컬럼 추출:", {
columnMeta,
categoryColumns: cols,
columnMetaKeys: Object.keys(columnMeta),
});
return cols;
}, [columnMeta]);
// 카테고리 매핑 로드 (columnMeta 변경 시 즉시 실행)
useEffect(() => {
const loadCategoryMappings = async () => {
console.log("🔄 [TableList] loadCategoryMappings 트리거:", {
hasTable: !!tableConfig.selectedTable,
table: tableConfig.selectedTable,
categoryColumnsLength: categoryColumns.length,
categoryColumns,
columnMetaKeys: Object.keys(columnMeta),
});
if (!tableConfig.selectedTable) {
console.log("⏭️ [TableList] 테이블 선택 안됨, 카테고리 매핑 로드 스킵");
return;
}
if (categoryColumns.length === 0) {
console.log("⏭️ [TableList] 카테고리 컬럼 없음, 카테고리 매핑 로드 스킵");
setCategoryMappings({});
return;
}
console.log("🚀 [TableList] 카테고리 매핑 로드 시작:", {
table: tableConfig.selectedTable,
categoryColumns,
columnMetaKeys: Object.keys(columnMeta),
});
try {
const mappings: Record<string, Record<string, { label: string; color?: string }>> = {};
@ -952,8 +1149,18 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const visibleColumns = useMemo(() => {
let cols = (tableConfig.columns || []).filter((col) => col.visible !== false);
// columnVisibility가 있으면 가시성 적용
if (columnVisibility.length > 0) {
cols = cols.filter((col) => {
const visibilityConfig = columnVisibility.find((cv) => cv.columnName === col.columnName);
return visibilityConfig ? visibilityConfig.visible : true;
});
}
// 체크박스 컬럼 (나중에 위치 결정)
let checkboxCol: ColumnConfig | null = null;
if (tableConfig.checkbox?.enabled) {
const checkboxCol: ColumnConfig = {
checkboxCol = {
columnName: "__checkbox__",
displayName: "",
visible: true,
@ -963,15 +1170,9 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
align: "center",
order: -1,
};
if (tableConfig.checkbox.position === "right") {
cols = [...cols, checkboxCol];
} else {
cols = [checkboxCol, ...cols];
}
}
// columnOrder 상태가 있으면 그 순서대로 정렬
// columnOrder 상태가 있으면 그 순서대로 정렬 (체크박스 제외)
if (columnOrder.length > 0) {
const orderedCols = columnOrder
.map((colName) => cols.find((c) => c.columnName === colName))
@ -980,17 +1181,22 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// columnOrder에 없는 새로운 컬럼들 추가
const remainingCols = cols.filter((c) => !columnOrder.includes(c.columnName));
console.log("🔄 columnOrder 기반 정렬:", {
columnOrder,
orderedColsCount: orderedCols.length,
remainingColsCount: remainingCols.length,
});
return [...orderedCols, ...remainingCols];
cols = [...orderedCols, ...remainingCols];
} else {
cols = cols.sort((a, b) => (a.order || 0) - (b.order || 0));
}
return cols.sort((a, b) => (a.order || 0) - (b.order || 0));
}, [tableConfig.columns, tableConfig.checkbox, columnOrder]);
// 체크박스를 맨 앞 또는 맨 뒤에 추가
if (checkboxCol) {
if (tableConfig.checkbox.position === "right") {
cols = [...cols, checkboxCol];
} else {
cols = [checkboxCol, ...cols];
}
}
return cols;
}, [tableConfig.columns, tableConfig.checkbox, columnOrder, columnVisibility]);
// 🆕 visibleColumns가 변경될 때마다 현재 컬럼 순서를 부모에게 전달
const lastColumnOrderRef = useRef<string>("");
@ -1451,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(" > ");
@ -1476,7 +1693,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
count: items.length,
};
});
}, [data, groupByColumns, columnLabels]);
}, [data, groupByColumns, columnLabels, columnMeta]);
// 저장된 그룹 설정 불러오기
useEffect(() => {
@ -1659,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 && (
@ -1839,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

@ -0,0 +1,418 @@
"use client";
import React, { useState, useEffect, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Settings, Filter, Layers, X } from "lucide-react";
import { useTableOptions } from "@/contexts/TableOptionsContext";
import { useTableSearchWidgetHeight } from "@/contexts/TableSearchWidgetHeightContext";
import { ColumnVisibilityPanel } from "@/components/screen/table-options/ColumnVisibilityPanel";
import { FilterPanel } from "@/components/screen/table-options/FilterPanel";
import { GroupingPanel } from "@/components/screen/table-options/GroupingPanel";
import { TableFilter } from "@/types/table-options";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
interface TableSearchWidgetProps {
component: {
id: string;
title?: string;
style?: {
width?: string;
height?: string;
padding?: string;
backgroundColor?: string;
};
componentConfig?: {
autoSelectFirstTable?: boolean; // 첫 번째 테이블 자동 선택 여부
showTableSelector?: boolean; // 테이블 선택 드롭다운 표시 여부
};
};
screenId?: number; // 화면 ID
onHeightChange?: (height: number) => void; // 높이 변화 콜백
}
export function TableSearchWidget({ component, screenId, onHeightChange }: TableSearchWidgetProps) {
const { registeredTables, selectedTableId, setSelectedTableId, getTable } = useTableOptions();
// 높이 관리 context (실제 화면에서만 사용)
let setWidgetHeight:
| ((screenId: number, componentId: string, height: number, originalHeight: number) => void)
| undefined;
try {
const heightContext = useTableSearchWidgetHeight();
setWidgetHeight = heightContext.setWidgetHeight;
} catch (e) {
// Context가 없으면 (디자이너 모드) 무시
setWidgetHeight = undefined;
}
const [columnVisibilityOpen, setColumnVisibilityOpen] = useState(false);
const [filterOpen, setFilterOpen] = useState(false);
const [groupingOpen, setGroupingOpen] = useState(false);
// 활성화된 필터 목록
const [activeFilters, setActiveFilters] = useState<TableFilter[]>([]);
const [filterValues, setFilterValues] = useState<Record<string, string>>({});
// select 타입 필터의 옵션들
const [selectOptions, setSelectOptions] = useState<Record<string, Array<{ label: string; value: string }>>>({});
// 선택된 값의 라벨 저장 (데이터 없을 때도 라벨 유지)
const [selectedLabels, setSelectedLabels] = useState<Record<string, string>>({});
// 높이 감지를 위한 ref
const containerRef = useRef<HTMLDivElement>(null);
const autoSelectFirstTable = component.componentConfig?.autoSelectFirstTable ?? true;
const showTableSelector = component.componentConfig?.showTableSelector ?? true;
// Map을 배열로 변환
const tableList = Array.from(registeredTables.values());
const currentTable = selectedTableId ? getTable(selectedTableId) : undefined;
// 첫 번째 테이블 자동 선택
useEffect(() => {
const tables = Array.from(registeredTables.values());
if (autoSelectFirstTable && tables.length > 0 && !selectedTableId) {
setSelectedTableId(tables[0].tableId);
}
}, [registeredTables, selectedTableId, autoSelectFirstTable, setSelectedTableId]);
// 현재 테이블의 저장된 필터 불러오기
useEffect(() => {
if (currentTable?.tableName) {
const storageKey = `table_filters_${currentTable.tableName}`;
const savedFilters = localStorage.getItem(storageKey);
if (savedFilters) {
try {
const parsed = JSON.parse(savedFilters) as Array<{
columnName: string;
columnLabel: string;
inputType: string;
enabled: boolean;
filterType: "text" | "number" | "date" | "select";
width?: number;
}>;
// enabled된 필터들만 activeFilters로 설정
const activeFiltersList: TableFilter[] = parsed
.filter((f) => f.enabled)
.map((f) => ({
columnName: f.columnName,
operator: "contains",
value: "",
filterType: f.filterType,
width: f.width || 200, // 저장된 너비 포함
}));
setActiveFilters(activeFiltersList);
} catch (error) {
console.error("저장된 필터 불러오기 실패:", error);
}
}
}
}, [currentTable?.tableName]);
// select 옵션 초기 로드 (한 번만 실행, 이후 유지)
useEffect(() => {
if (!currentTable?.getColumnUniqueValues || activeFilters.length === 0) {
return;
}
const loadSelectOptions = async () => {
const selectFilters = activeFilters.filter((f) => f.filterType === "select");
if (selectFilters.length === 0) {
return;
}
const newOptions: Record<string, Array<{ label: string; value: string }>> = { ...selectOptions };
for (const filter of selectFilters) {
// 이미 로드된 옵션이 있으면 스킵 (초기값 유지)
if (newOptions[filter.columnName] && newOptions[filter.columnName].length > 0) {
continue;
}
try {
const options = await currentTable.getColumnUniqueValues(filter.columnName);
newOptions[filter.columnName] = options;
} catch (error) {
console.error("❌ [TableSearchWidget] select 옵션 로드 실패:", filter.columnName, error);
}
}
setSelectOptions(newOptions);
};
loadSelectOptions();
}, [activeFilters, currentTable?.tableName, currentTable?.getColumnUniqueValues]); // dataCount 제거, tableName으로 변경
// 높이 변화 감지 및 알림 (실제 화면에서만)
useEffect(() => {
if (!containerRef.current || !screenId || !setWidgetHeight) return;
// 컴포넌트의 원래 높이 (디자이너에서 설정한 높이)
const originalHeight = (component as any).size?.height || 50;
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const newHeight = entry.contentRect.height;
// Context에 높이 저장 (다른 컴포넌트 위치 조정에 사용)
setWidgetHeight(screenId, component.id, newHeight, originalHeight);
// localStorage에 높이 저장 (새로고침 시 복원용)
localStorage.setItem(
`table_search_widget_height_screen_${screenId}_${component.id}`,
JSON.stringify({ height: newHeight, originalHeight }),
);
// 콜백이 있으면 호출
if (onHeightChange) {
onHeightChange(newHeight);
}
}
});
resizeObserver.observe(containerRef.current);
return () => {
resizeObserver.disconnect();
};
}, [screenId, component.id, setWidgetHeight, onHeightChange]);
// 화면 로딩 시 저장된 높이 복원
useEffect(() => {
if (!screenId || !setWidgetHeight) return;
const storageKey = `table_search_widget_height_screen_${screenId}_${component.id}`;
const savedData = localStorage.getItem(storageKey);
if (savedData) {
try {
const { height, originalHeight } = JSON.parse(savedData);
setWidgetHeight(screenId, component.id, height, originalHeight);
} catch (error) {
console.error("저장된 높이 복원 실패:", error);
}
}
}, [screenId, component.id, setWidgetHeight]);
const hasMultipleTables = tableList.length > 1;
// 필터 값 변경 핸들러
const handleFilterChange = (columnName: string, value: string) => {
const newValues = {
...filterValues,
[columnName]: value,
};
setFilterValues(newValues);
// 실시간 검색: 값 변경 시 즉시 필터 적용
applyFilters(newValues);
};
// 필터 적용 함수
const applyFilters = (values: Record<string, string> = filterValues) => {
// 빈 값이 아닌 필터만 적용
const filtersWithValues = activeFilters
.map((filter) => ({
...filter,
value: values[filter.columnName] || "",
}))
.filter((f) => f.value !== "");
currentTable?.onFilterChange(filtersWithValues);
};
// 필터 초기화
const handleResetFilters = () => {
setFilterValues({});
setSelectedLabels({});
currentTable?.onFilterChange([]);
};
// 필터 입력 필드 렌더링
const renderFilterInput = (filter: TableFilter) => {
const column = currentTable?.columns.find((c) => c.columnName === filter.columnName);
const value = filterValues[filter.columnName] || "";
const width = filter.width || 200; // 기본 너비 200px
switch (filter.filterType) {
case "date":
return (
<Input
type="date"
value={value}
onChange={(e) => handleFilterChange(filter.columnName, e.target.value)}
className="h-9 text-xs focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:outline-none sm:text-sm"
style={{ width: `${width}px`, height: "36px", minHeight: "36px", outline: "none", boxShadow: "none" }}
placeholder={column?.columnLabel}
/>
);
case "number":
return (
<Input
type="number"
value={value}
onChange={(e) => handleFilterChange(filter.columnName, e.target.value)}
className="h-9 text-xs focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:outline-none sm:text-sm"
style={{ width: `${width}px`, height: "36px", minHeight: "36px", outline: "none", boxShadow: "none" }}
placeholder={column?.columnLabel}
/>
);
case "select": {
let options = selectOptions[filter.columnName] || [];
// 현재 선택된 값이 옵션 목록에 없으면 추가 (데이터 없을 때도 선택값 유지)
if (value && !options.find((opt) => opt.value === value)) {
const savedLabel = selectedLabels[filter.columnName] || value;
options = [{ value, label: savedLabel }, ...options];
}
// 중복 제거 (value 기준)
const uniqueOptions = options.reduce(
(acc, option) => {
if (!acc.find((opt) => opt.value === option.value)) {
acc.push(option);
}
return acc;
},
[] as Array<{ value: string; label: string }>,
);
return (
<Select
value={value}
onValueChange={(val) => {
// 선택한 값의 라벨 저장
const selectedOption = uniqueOptions.find((opt) => opt.value === val);
if (selectedOption) {
setSelectedLabels((prev) => ({
...prev,
[filter.columnName]: selectedOption.label,
}));
}
handleFilterChange(filter.columnName, val);
}}
>
<SelectTrigger
className="h-9 min-h-9 text-xs focus:ring-0 focus:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:outline-none sm:text-sm"
style={{ width: `${width}px`, height: "36px", minHeight: "36px", outline: "none", boxShadow: "none" }}
>
<SelectValue placeholder={column?.columnLabel || "선택"} />
</SelectTrigger>
<SelectContent>
{uniqueOptions.length === 0 ? (
<div className="text-muted-foreground px-2 py-1.5 text-xs"> </div>
) : (
uniqueOptions.map((option, index) => (
<SelectItem key={`${filter.columnName}-${option.value}-${index}`} value={option.value}>
{option.label}
</SelectItem>
))
)}
</SelectContent>
</Select>
);
}
default: // text
return (
<Input
type="text"
value={value}
onChange={(e) => handleFilterChange(filter.columnName, e.target.value)}
className="h-9 text-xs focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:outline-none sm:text-sm"
style={{ width: `${width}px`, height: "36px", minHeight: "36px", outline: "none", boxShadow: "none" }}
placeholder={column?.columnLabel}
/>
);
}
};
return (
<div
ref={containerRef}
className="bg-card flex w-full flex-wrap items-center gap-2 border-b"
style={{
padding: component.style?.padding || "0.75rem",
backgroundColor: component.style?.backgroundColor,
minHeight: "48px",
}}
>
{/* 필터 입력 필드들 */}
{activeFilters.length > 0 && (
<div className="flex flex-1 flex-wrap items-center gap-2">
{activeFilters.map((filter) => (
<div key={filter.columnName}>{renderFilterInput(filter)}</div>
))}
{/* 초기화 버튼 */}
<Button variant="outline" size="sm" onClick={handleResetFilters} className="h-9 shrink-0 text-xs sm:text-sm">
<X className="mr-1 h-3 w-3 sm:h-4 sm:w-4" />
</Button>
</div>
)}
{/* 필터가 없을 때는 빈 공간 */}
{activeFilters.length === 0 && <div className="flex-1" />}
{/* 오른쪽: 데이터 건수 + 설정 버튼들 */}
<div className="flex flex-shrink-0 items-center gap-2">
{/* 데이터 건수 표시 */}
{currentTable?.dataCount !== undefined && (
<div className="bg-muted text-muted-foreground rounded-md px-3 py-1.5 text-xs font-medium sm:text-sm">
{currentTable.dataCount.toLocaleString()}
</div>
)}
<Button
variant="outline"
size="sm"
onClick={() => setColumnVisibilityOpen(true)}
disabled={!selectedTableId}
className="h-8 text-xs sm:h-9 sm:text-sm"
>
<Settings className="mr-1 h-3 w-3 sm:h-4 sm:w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setFilterOpen(true)}
disabled={!selectedTableId}
className="h-8 text-xs sm:h-9 sm:text-sm"
>
<Filter className="mr-1 h-3 w-3 sm:h-4 sm:w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setGroupingOpen(true)}
disabled={!selectedTableId}
className="h-8 text-xs sm:h-9 sm:text-sm"
>
<Layers className="mr-1 h-3 w-3 sm:h-4 sm:w-4" />
</Button>
</div>
{/* 패널들 */}
<ColumnVisibilityPanel isOpen={columnVisibilityOpen} onClose={() => setColumnVisibilityOpen(false)} />
<FilterPanel
isOpen={filterOpen}
onClose={() => setFilterOpen(false)}
onFiltersApplied={(filters) => setActiveFilters(filters)}
/>
<GroupingPanel isOpen={groupingOpen} onClose={() => setGroupingOpen(false)} />
</div>
);
}

View File

@ -0,0 +1,78 @@
"use client";
import React, { useState, useEffect } from "react";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
interface TableSearchWidgetConfigPanelProps {
component: any;
onUpdateProperty: (property: string, value: any) => void;
}
export function TableSearchWidgetConfigPanel({
component,
onUpdateProperty,
}: TableSearchWidgetConfigPanelProps) {
const [localAutoSelect, setLocalAutoSelect] = useState(
component.componentConfig?.autoSelectFirstTable ?? true
);
const [localShowSelector, setLocalShowSelector] = useState(
component.componentConfig?.showTableSelector ?? true
);
useEffect(() => {
setLocalAutoSelect(component.componentConfig?.autoSelectFirstTable ?? true);
setLocalShowSelector(component.componentConfig?.showTableSelector ?? true);
}, [component.componentConfig]);
return (
<div className="space-y-4">
<div className="space-y-2">
<h3 className="text-sm font-semibold"> </h3>
<p className="text-xs text-muted-foreground">
, , .
</p>
</div>
{/* 첫 번째 테이블 자동 선택 */}
<div className="flex items-center space-x-2">
<Checkbox
id="autoSelectFirstTable"
checked={localAutoSelect}
onCheckedChange={(checked) => {
setLocalAutoSelect(checked as boolean);
onUpdateProperty("componentConfig.autoSelectFirstTable", checked);
}}
/>
<Label htmlFor="autoSelectFirstTable" className="text-xs sm:text-sm cursor-pointer">
</Label>
</div>
{/* 테이블 선택 드롭다운 표시 */}
<div className="flex items-center space-x-2">
<Checkbox
id="showTableSelector"
checked={localShowSelector}
onCheckedChange={(checked) => {
setLocalShowSelector(checked as boolean);
onUpdateProperty("componentConfig.showTableSelector", checked);
}}
/>
<Label htmlFor="showTableSelector" className="text-xs sm:text-sm cursor-pointer">
( )
</Label>
</div>
<div className="rounded-md bg-muted p-3 text-xs">
<p className="font-medium mb-1">:</p>
<ul className="list-disc list-inside space-y-1 text-muted-foreground">
<li> , , </li>
<li> </li>
<li> </li>
</ul>
</div>
</div>
);
}

View File

@ -0,0 +1,9 @@
import React from "react";
import { TableSearchWidget } from "./TableSearchWidget";
export class TableSearchWidgetRenderer {
static render(component: any) {
return <TableSearchWidget component={component} />;
}
}

View File

@ -0,0 +1,41 @@
"use client";
import { ComponentRegistry } from "../../ComponentRegistry";
import { TableSearchWidget } from "./TableSearchWidget";
import { TableSearchWidgetRenderer } from "./TableSearchWidgetRenderer";
import { TableSearchWidgetConfigPanel } from "./TableSearchWidgetConfigPanel";
// 검색 필터 위젯 등록
ComponentRegistry.registerComponent({
id: "table-search-widget",
name: "검색 필터",
nameEng: "Table Search Widget",
category: "utility", // 유틸리티 컴포넌트로 분류
description: "화면 내 테이블을 자동 감지하여 검색, 필터, 그룹 기능을 제공하는 위젯",
icon: "Search",
tags: ["table", "search", "filter", "group", "search-widget"],
webType: "custom",
defaultSize: { width: 1920, height: 80 }, // 픽셀 단위: 전체 너비 × 80px 높이
component: TableSearchWidget,
defaultProps: {
title: "테이블 검색",
style: {
width: "100%",
height: "80px",
padding: "0.75rem",
},
componentConfig: {
autoSelectFirstTable: true,
showTableSelector: true,
},
},
renderer: TableSearchWidgetRenderer.render,
configPanel: TableSearchWidgetConfigPanel,
version: "1.0.0",
author: "WACE",
});
export { TableSearchWidget } from "./TableSearchWidget";
export { TableSearchWidgetRenderer } from "./TableSearchWidgetRenderer";
export { TableSearchWidgetConfigPanel } from "./TableSearchWidgetConfigPanel";

View File

@ -0,0 +1,80 @@
/**
*
*/
/**
*
*/
export interface TableFilter {
columnName: string;
operator:
| "equals"
| "contains"
| "startsWith"
| "endsWith"
| "gt"
| "lt"
| "gte"
| "lte"
| "notEquals";
value: string | number | boolean;
filterType?: "text" | "number" | "date" | "select"; // 필터 입력 타입
width?: number; // 필터 입력 필드 너비 (px)
}
/**
*
*/
export interface ColumnVisibility {
columnName: string;
visible: boolean;
width?: number;
order?: number;
fixed?: boolean; // 좌측 고정 여부
}
/**
*
*/
export interface TableColumn {
columnName: string;
columnLabel: string;
inputType: string;
visible: boolean;
width: number;
sortable?: boolean;
filterable?: boolean;
}
/**
*
*/
export interface TableRegistration {
tableId: string; // 고유 ID (예: "table-list-123")
label: string; // 사용자에게 보이는 이름 (예: "품목 관리")
tableName: string; // 실제 DB 테이블명 (예: "item_info")
columns: TableColumn[];
dataCount?: number; // 현재 표시된 데이터 건수
// 콜백 함수들
onFilterChange: (filters: TableFilter[]) => void;
onGroupChange: (groups: string[]) => void;
onColumnVisibilityChange: (columns: ColumnVisibility[]) => void;
// 데이터 조회 함수 (선택 타입 필터용)
getColumnUniqueValues?: (columnName: string) => Promise<Array<{ label: string; value: string }>>;
}
/**
* Context
*/
export interface TableOptionsContextValue {
registeredTables: Map<string, TableRegistration>;
registerTable: (registration: TableRegistration) => void;
unregisterTable: (tableId: string) => void;
getTable: (tableId: string) => TableRegistration | undefined;
updateTableDataCount: (tableId: string, count: number) => void; // 데이터 건수 업데이트
selectedTableId: string | null;
setSelectedTableId: (tableId: string | null) => void;
}