Compare commits
10 Commits
f046493960
...
41404e021e
| Author | SHA1 | Date |
|---|---|---|
|
|
41404e021e | |
|
|
4cdc72e360 | |
|
|
a883187889 | |
|
|
6d1743c524 | |
|
|
5c205753e2 | |
|
|
71fd3f5ee7 | |
|
|
58870237b6 | |
|
|
33ba13b070 | |
|
|
73049c4162 | |
|
|
c6941bc41f |
|
|
@ -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` 적용
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
**이 규칙을 지키지 않으면 사용자에게 "확인 안하지?"라는 말을 듣게 됩니다!**
|
**이 규칙을 지키지 않으면 사용자에게 "확인 안하지?"라는 말을 듣게 됩니다!**
|
||||||
|
|
|
||||||
|
|
@ -24,20 +24,19 @@ export class EntityJoinService {
|
||||||
try {
|
try {
|
||||||
logger.info(`Entity 컬럼 감지 시작: ${tableName}`);
|
logger.info(`Entity 컬럼 감지 시작: ${tableName}`);
|
||||||
|
|
||||||
// column_labels에서 entity 타입인 컬럼들 조회
|
// column_labels에서 entity 및 category 타입인 컬럼들 조회 (input_type 사용)
|
||||||
const entityColumns = await query<{
|
const entityColumns = await query<{
|
||||||
column_name: string;
|
column_name: string;
|
||||||
|
input_type: string;
|
||||||
reference_table: string;
|
reference_table: string;
|
||||||
reference_column: string;
|
reference_column: string;
|
||||||
display_column: string | null;
|
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
|
FROM column_labels
|
||||||
WHERE table_name = $1
|
WHERE table_name = $1
|
||||||
AND web_type = $2
|
AND input_type IN ('entity', 'category')`,
|
||||||
AND reference_table IS NOT NULL
|
[tableName]
|
||||||
AND reference_column IS NOT NULL`,
|
|
||||||
[tableName, "entity"]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
logger.info(`🔍 Entity 컬럼 조회 결과: ${entityColumns.length}개 발견`);
|
logger.info(`🔍 Entity 컬럼 조회 결과: ${entityColumns.length}개 발견`);
|
||||||
|
|
@ -77,18 +76,34 @@ export class EntityJoinService {
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const column of entityColumns) {
|
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 컬럼 상세 정보:`, {
|
logger.info(`🔍 Entity 컬럼 상세 정보:`, {
|
||||||
column_name: column.column_name,
|
column_name: column.column_name,
|
||||||
reference_table: column.reference_table,
|
input_type: column.input_type,
|
||||||
reference_column: column.reference_column,
|
reference_table: referenceTable,
|
||||||
display_column: column.display_column,
|
reference_column: referenceColumn,
|
||||||
|
display_column: displayColumn,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (
|
if (!column.column_name || !referenceTable || !referenceColumn) {
|
||||||
!column.column_name ||
|
logger.warn(`⚠️ 필수 정보 누락으로 스킵: ${column.column_name}`);
|
||||||
!column.reference_table ||
|
|
||||||
!column.reference_column
|
|
||||||
) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -112,27 +127,28 @@ export class EntityJoinService {
|
||||||
separator,
|
separator,
|
||||||
screenConfig,
|
screenConfig,
|
||||||
});
|
});
|
||||||
} else if (column.display_column && column.display_column !== "none") {
|
} else if (displayColumn && displayColumn !== "none") {
|
||||||
// 기존 설정된 단일 표시 컬럼 사용 (none이 아닌 경우만)
|
// 기존 설정된 단일 표시 컬럼 사용 (none이 아닌 경우만)
|
||||||
displayColumns = [column.display_column];
|
displayColumns = [displayColumn];
|
||||||
logger.info(
|
logger.info(
|
||||||
`🔧 기존 display_column 사용: ${column.column_name} → ${column.display_column}`
|
`🔧 기존 display_column 사용: ${column.column_name} → ${displayColumn}`
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// display_column이 "none"이거나 없는 경우 기본 표시 컬럼 설정
|
// display_column이 "none"이거나 없는 경우 기본 표시 컬럼 설정
|
||||||
// 🚨 display_column이 항상 "none"이므로 이 로직을 기본으로 사용
|
let defaultDisplayColumn = referenceColumn;
|
||||||
let defaultDisplayColumn = column.reference_column;
|
if (referenceTable === "dept_info") {
|
||||||
if (column.reference_table === "dept_info") {
|
|
||||||
defaultDisplayColumn = "dept_name";
|
defaultDisplayColumn = "dept_name";
|
||||||
} else if (column.reference_table === "company_info") {
|
} else if (referenceTable === "company_info") {
|
||||||
defaultDisplayColumn = "company_name";
|
defaultDisplayColumn = "company_name";
|
||||||
} else if (column.reference_table === "user_info") {
|
} else if (referenceTable === "user_info") {
|
||||||
defaultDisplayColumn = "user_name";
|
defaultDisplayColumn = "user_name";
|
||||||
|
} else if (referenceTable === "category_values") {
|
||||||
|
defaultDisplayColumn = "category_name";
|
||||||
}
|
}
|
||||||
|
|
||||||
displayColumns = [defaultDisplayColumn];
|
displayColumns = [defaultDisplayColumn];
|
||||||
logger.info(
|
logger.info(
|
||||||
`🔧 Entity 조인 기본 표시 컬럼 설정: ${column.column_name} → ${defaultDisplayColumn} (${column.reference_table})`
|
`🔧 Entity 조인 기본 표시 컬럼 설정: ${column.column_name} → ${defaultDisplayColumn} (${referenceTable})`
|
||||||
);
|
);
|
||||||
logger.info(`🔍 생성된 displayColumns 배열:`, displayColumns);
|
logger.info(`🔍 생성된 displayColumns 배열:`, displayColumns);
|
||||||
}
|
}
|
||||||
|
|
@ -143,8 +159,8 @@ export class EntityJoinService {
|
||||||
const joinConfig: EntityJoinConfig = {
|
const joinConfig: EntityJoinConfig = {
|
||||||
sourceTable: tableName,
|
sourceTable: tableName,
|
||||||
sourceColumn: column.column_name,
|
sourceColumn: column.column_name,
|
||||||
referenceTable: column.reference_table,
|
referenceTable: referenceTable, // 카테고리의 경우 자동 설정된 값 사용
|
||||||
referenceColumn: column.reference_column,
|
referenceColumn: referenceColumn, // 카테고리의 경우 자동 설정된 값 사용
|
||||||
displayColumns: displayColumns,
|
displayColumns: displayColumns,
|
||||||
displayColumn: displayColumns[0], // 하위 호환성
|
displayColumn: displayColumns[0], // 하위 호환성
|
||||||
aliasColumn: aliasColumn,
|
aliasColumn: aliasColumn,
|
||||||
|
|
@ -246,10 +262,13 @@ export class EntityJoinService {
|
||||||
];
|
];
|
||||||
const separator = config.separator || " - ";
|
const separator = config.separator || " - ";
|
||||||
|
|
||||||
|
// 결과 컬럼 배열 (aliasColumn + _label 필드)
|
||||||
|
const resultColumns: string[] = [];
|
||||||
|
|
||||||
if (displayColumns.length === 0 || !displayColumns[0]) {
|
if (displayColumns.length === 0 || !displayColumns[0]) {
|
||||||
// displayColumns가 빈 배열이거나 첫 번째 값이 null/undefined인 경우
|
// displayColumns가 빈 배열이거나 첫 번째 값이 null/undefined인 경우
|
||||||
// 조인 테이블의 referenceColumn을 기본값으로 사용
|
// 조인 테이블의 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) {
|
} else if (displayColumns.length === 1) {
|
||||||
// 단일 컬럼인 경우
|
// 단일 컬럼인 경우
|
||||||
const col = displayColumns[0];
|
const col = displayColumns[0];
|
||||||
|
|
@ -265,12 +284,18 @@ export class EntityJoinService {
|
||||||
"company_name",
|
"company_name",
|
||||||
"sales_yn",
|
"sales_yn",
|
||||||
"status",
|
"status",
|
||||||
|
"value_label", // table_column_category_values
|
||||||
|
"user_name", // user_info
|
||||||
].includes(col);
|
].includes(col);
|
||||||
|
|
||||||
if (isJoinTableColumn) {
|
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 {
|
} else {
|
||||||
return `COALESCE(main.${col}::TEXT, '') AS ${config.aliasColumn}`;
|
resultColumns.push(`COALESCE(main.${col}::TEXT, '') AS ${config.aliasColumn}`);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 여러 컬럼인 경우 CONCAT으로 연결
|
// 여러 컬럼인 경우 CONCAT으로 연결
|
||||||
|
|
@ -291,6 +316,8 @@ export class EntityJoinService {
|
||||||
"company_name",
|
"company_name",
|
||||||
"sales_yn",
|
"sales_yn",
|
||||||
"status",
|
"status",
|
||||||
|
"value_label", // table_column_category_values
|
||||||
|
"user_name", // user_info
|
||||||
].includes(col);
|
].includes(col);
|
||||||
|
|
||||||
if (isJoinTableColumn) {
|
if (isJoinTableColumn) {
|
||||||
|
|
@ -303,8 +330,11 @@ export class EntityJoinService {
|
||||||
})
|
})
|
||||||
.join(` || '${separator}' || `);
|
.join(` || '${separator}' || `);
|
||||||
|
|
||||||
return `(${concatParts}) AS ${config.aliasColumn}`;
|
resultColumns.push(`(${concatParts}) AS ${config.aliasColumn}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 모든 resultColumns를 반환
|
||||||
|
return resultColumns.join(", ");
|
||||||
})
|
})
|
||||||
.join(", ");
|
.join(", ");
|
||||||
|
|
||||||
|
|
@ -320,6 +350,12 @@ export class EntityJoinService {
|
||||||
const joinClauses = uniqueReferenceTableConfigs
|
const joinClauses = uniqueReferenceTableConfigs
|
||||||
.map((config) => {
|
.map((config) => {
|
||||||
const alias = aliasMap.get(config.referenceTable);
|
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}`;
|
return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn}`;
|
||||||
})
|
})
|
||||||
.join("\n");
|
.join("\n");
|
||||||
|
|
@ -380,6 +416,14 @@ export class EntityJoinService {
|
||||||
return "join";
|
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 =
|
const displayCol =
|
||||||
config.displayColumn ||
|
config.displayColumn ||
|
||||||
|
|
|
||||||
|
|
@ -1494,6 +1494,7 @@ export class TableManagementService {
|
||||||
search?: Record<string, any>;
|
search?: Record<string, any>;
|
||||||
sortBy?: string;
|
sortBy?: string;
|
||||||
sortOrder?: string;
|
sortOrder?: string;
|
||||||
|
companyCode?: string;
|
||||||
}
|
}
|
||||||
): Promise<{
|
): Promise<{
|
||||||
data: any[];
|
data: any[];
|
||||||
|
|
@ -1503,7 +1504,7 @@ export class TableManagementService {
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
const { page, size, search = {}, sortBy, sortOrder = "asc" } = options;
|
const { page, size, search = {}, sortBy, sortOrder = "asc", companyCode } = options;
|
||||||
const offset = (page - 1) * size;
|
const offset = (page - 1) * size;
|
||||||
|
|
||||||
logger.info(`테이블 데이터 조회: ${tableName}`, options);
|
logger.info(`테이블 데이터 조회: ${tableName}`, options);
|
||||||
|
|
@ -1517,6 +1518,14 @@ export class TableManagementService {
|
||||||
let searchValues: any[] = [];
|
let searchValues: any[] = [];
|
||||||
let paramIndex = 1;
|
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) {
|
if (search && Object.keys(search).length > 0) {
|
||||||
for (const [column, value] of Object.entries(search)) {
|
for (const [column, value] of Object.entries(search)) {
|
||||||
if (value !== null && value !== undefined && value !== "") {
|
if (value !== null && value !== undefined && value !== "") {
|
||||||
|
|
@ -2213,11 +2222,20 @@ export class TableManagementService {
|
||||||
const selectColumns = columns.data.map((col: any) => col.column_name);
|
const selectColumns = columns.data.map((col: any) => col.column_name);
|
||||||
|
|
||||||
// WHERE 절 구성
|
// WHERE 절 구성
|
||||||
const whereClause = await this.buildWhereClause(
|
let whereClause = await this.buildWhereClause(
|
||||||
tableName,
|
tableName,
|
||||||
options.search
|
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 절 구성
|
// ORDER BY 절 구성
|
||||||
const orderBy = options.sortBy
|
const orderBy = options.sortBy
|
||||||
? `main.${options.sortBy} ${options.sortOrder === "desc" ? "DESC" : "ASC"}`
|
? `main.${options.sortBy} ${options.sortOrder === "desc" ? "DESC" : "ASC"}`
|
||||||
|
|
@ -2343,6 +2361,7 @@ export class TableManagementService {
|
||||||
search?: Record<string, any>;
|
search?: Record<string, any>;
|
||||||
sortBy?: string;
|
sortBy?: string;
|
||||||
sortOrder?: string;
|
sortOrder?: string;
|
||||||
|
companyCode?: string;
|
||||||
},
|
},
|
||||||
startTime: number
|
startTime: number
|
||||||
): Promise<EntityJoinResponse> {
|
): Promise<EntityJoinResponse> {
|
||||||
|
|
@ -2530,11 +2549,11 @@ export class TableManagementService {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
basicResult = await this.getTableData(tableName, fallbackOptions);
|
basicResult = await this.getTableData(tableName, { ...fallbackOptions, companyCode: options.companyCode });
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Entity 조인 컬럼 검색이 없는 경우 기존 캐시 방식 사용
|
// Entity 조인 컬럼 검색이 없는 경우 기존 캐시 방식 사용
|
||||||
basicResult = await this.getTableData(tableName, options);
|
basicResult = await this.getTableData(tableName, { ...options, companyCode: options.companyCode });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Entity 값들을 캐시에서 룩업하여 변환
|
// Entity 값들을 캐시에서 룩업하여 변환
|
||||||
|
|
@ -2807,10 +2826,14 @@ export class TableManagementService {
|
||||||
}
|
}
|
||||||
// 모든 조인이 캐시 가능한 경우: 기본 쿼리 + 캐시 룩업
|
// 모든 조인이 캐시 가능한 경우: 기본 쿼리 + 캐시 룩업
|
||||||
else {
|
else {
|
||||||
|
// whereClause에서 company_code 추출 (멀티테넌시 필터)
|
||||||
|
const companyCodeMatch = whereClause.match(/main\.company_code\s*=\s*'([^']+)'/);
|
||||||
|
const companyCode = companyCodeMatch ? companyCodeMatch[1] : undefined;
|
||||||
|
|
||||||
return await this.executeCachedLookup(
|
return await this.executeCachedLookup(
|
||||||
tableName,
|
tableName,
|
||||||
cacheableJoins,
|
cacheableJoins,
|
||||||
{ page: Math.floor(offset / limit) + 1, size: limit, search: {} },
|
{ page: Math.floor(offset / limit) + 1, size: limit, search: {}, companyCode },
|
||||||
startTime
|
startTime
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -2831,6 +2854,13 @@ export class TableManagementService {
|
||||||
const dbJoins: EntityJoinConfig[] = [];
|
const dbJoins: EntityJoinConfig[] = [];
|
||||||
|
|
||||||
for (const config of joinConfigs) {
|
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(
|
const cachedData = await referenceCacheService.getCachedReference(
|
||||||
config.referenceTable,
|
config.referenceTable,
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,11 +1,11 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState, useMemo } from "react";
|
||||||
import { useParams, useSearchParams } from "next/navigation";
|
import { useParams, useSearchParams } from "next/navigation";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
import { screenApi } from "@/lib/api/screen";
|
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 { useRouter } from "next/navigation";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { initializeComponents } from "@/lib/registry/components";
|
import { initializeComponents } from "@/lib/registry/components";
|
||||||
|
|
@ -18,8 +18,10 @@ import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRendere
|
||||||
import { ScreenPreviewProvider } from "@/contexts/ScreenPreviewContext";
|
import { ScreenPreviewProvider } from "@/contexts/ScreenPreviewContext";
|
||||||
import { useAuth } from "@/hooks/useAuth"; // 🆕 사용자 정보
|
import { useAuth } from "@/hooks/useAuth"; // 🆕 사용자 정보
|
||||||
import { useResponsive } from "@/lib/hooks/useResponsive"; // 🆕 반응형 감지
|
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 params = useParams();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
@ -34,6 +36,9 @@ export default function ScreenViewPage() {
|
||||||
// 🆕 모바일 환경 감지
|
// 🆕 모바일 환경 감지
|
||||||
const { isMobile } = useResponsive();
|
const { isMobile } = useResponsive();
|
||||||
|
|
||||||
|
// 🆕 TableSearchWidget 높이 관리
|
||||||
|
const { getHeightDiff } = useTableSearchWidgetHeight();
|
||||||
|
|
||||||
const [screen, setScreen] = useState<ScreenDefinition | null>(null);
|
const [screen, setScreen] = useState<ScreenDefinition | null>(null);
|
||||||
const [layout, setLayout] = useState<LayoutData | null>(null);
|
const [layout, setLayout] = useState<LayoutData | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
@ -298,16 +303,17 @@ export default function ScreenViewPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScreenPreviewProvider isPreviewMode={false}>
|
<ScreenPreviewProvider isPreviewMode={false}>
|
||||||
<div ref={containerRef} className="bg-background flex h-full w-full items-center justify-center overflow-hidden">
|
<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">
|
{!layoutReady && (
|
||||||
<div className="border-border bg-background rounded-xl border p-8 text-center shadow-lg">
|
<div className="from-muted to-muted/50 flex h-full w-full items-center justify-center bg-gradient-to-br">
|
||||||
<Loader2 className="text-primary mx-auto h-8 w-8 animate-spin" />
|
<div className="border-border bg-background rounded-xl border p-8 text-center shadow-lg">
|
||||||
<p className="text-foreground mt-4 text-sm font-medium">화면 준비 중...</p>
|
<Loader2 className="text-primary mx-auto h-8 w-8 animate-spin" />
|
||||||
</div>
|
<p className="text-foreground mt-4 text-sm font-medium">화면 준비 중...</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 절대 위치 기반 렌더링 (화면관리와 동일한 방식) */}
|
{/* 절대 위치 기반 렌더링 (화면관리와 동일한 방식) */}
|
||||||
{layoutReady && layout && layout.components.length > 0 ? (
|
{layoutReady && layout && layout.components.length > 0 ? (
|
||||||
|
|
@ -391,10 +397,49 @@ export default function ScreenViewPage() {
|
||||||
|
|
||||||
const regularComponents = topLevelComponents.filter((c) => !processedButtonIds.has(c.id));
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* 일반 컴포넌트들 */}
|
{/* 일반 컴포넌트들 */}
|
||||||
{regularComponents.map((component) => {
|
{adjustedComponents.map((component) => {
|
||||||
// 화면 관리 해상도를 사용하므로 위치 조정 불필요
|
// 화면 관리 해상도를 사용하므로 위치 조정 불필요
|
||||||
return (
|
return (
|
||||||
<RealtimePreview
|
<RealtimePreview
|
||||||
|
|
@ -679,33 +724,45 @@ export default function ScreenViewPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 편집 모달 */}
|
{/* 편집 모달 */}
|
||||||
<EditModal
|
<EditModal
|
||||||
isOpen={editModalOpen}
|
isOpen={editModalOpen}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setEditModalOpen(false);
|
setEditModalOpen(false);
|
||||||
setEditModalConfig({});
|
setEditModalConfig({});
|
||||||
}}
|
}}
|
||||||
screenId={editModalConfig.screenId}
|
screenId={editModalConfig.screenId}
|
||||||
modalSize={editModalConfig.modalSize}
|
modalSize={editModalConfig.modalSize}
|
||||||
editData={editModalConfig.editData}
|
editData={editModalConfig.editData}
|
||||||
onSave={editModalConfig.onSave}
|
onSave={editModalConfig.onSave}
|
||||||
modalTitle={editModalConfig.modalTitle}
|
modalTitle={editModalConfig.modalTitle}
|
||||||
modalDescription={editModalConfig.modalDescription}
|
modalDescription={editModalConfig.modalDescription}
|
||||||
onDataChange={(changedFormData) => {
|
onDataChange={(changedFormData) => {
|
||||||
console.log("📝 EditModal에서 데이터 변경 수신:", changedFormData);
|
console.log("📝 EditModal에서 데이터 변경 수신:", changedFormData);
|
||||||
// 변경된 데이터를 메인 폼에 반영
|
// 변경된 데이터를 메인 폼에 반영
|
||||||
setFormData((prev) => {
|
setFormData((prev) => {
|
||||||
const updatedFormData = {
|
const updatedFormData = {
|
||||||
...prev,
|
...prev,
|
||||||
...changedFormData, // 변경된 필드들만 업데이트
|
...changedFormData, // 변경된 필드들만 업데이트
|
||||||
};
|
};
|
||||||
console.log("📊 메인 폼 데이터 업데이트:", updatedFormData);
|
console.log("📊 메인 폼 데이터 업데이트:", updatedFormData);
|
||||||
return updatedFormData;
|
return updatedFormData;
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</TableOptionsProvider>
|
||||||
</ScreenPreviewProvider>
|
</ScreenPreviewProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 실제 컴포넌트를 Provider로 감싸기
|
||||||
|
function ScreenViewPageWrapper() {
|
||||||
|
return (
|
||||||
|
<TableSearchWidgetHeightProvider>
|
||||||
|
<ScreenViewPage />
|
||||||
|
</TableSearchWidgetHeightProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ScreenViewPageWrapper;
|
||||||
|
|
|
||||||
|
|
@ -217,6 +217,18 @@ select:focus-visible {
|
||||||
outline-offset: 2px;
|
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) ===== */
|
/* ===== Scrollbar Styles (Optional) ===== */
|
||||||
/* Webkit 기반 브라우저 (Chrome, Safari, Edge) */
|
/* Webkit 기반 브라우저 (Chrome, Safari, Edge) */
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback } from "react";
|
import React, { useState, useEffect, useCallback, useRef } from "react";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
|
@ -52,6 +52,8 @@ import { FileUpload } from "@/components/screen/widgets/FileUpload";
|
||||||
import { AdvancedSearchFilters } from "./filters/AdvancedSearchFilters";
|
import { AdvancedSearchFilters } from "./filters/AdvancedSearchFilters";
|
||||||
import { SaveModal } from "./SaveModal";
|
import { SaveModal } from "./SaveModal";
|
||||||
import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
|
import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
|
||||||
|
import { useTableOptions } from "@/contexts/TableOptionsContext";
|
||||||
|
import { TableFilter, ColumnVisibility } from "@/types/table-options";
|
||||||
|
|
||||||
// 파일 데이터 타입 정의 (AttachedFileInfo와 호환)
|
// 파일 데이터 타입 정의 (AttachedFileInfo와 호환)
|
||||||
interface FileInfo {
|
interface FileInfo {
|
||||||
|
|
@ -102,6 +104,8 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
}) => {
|
}) => {
|
||||||
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인
|
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인
|
||||||
const { user } = useAuth(); // 사용자 정보 가져오기
|
const { user } = useAuth(); // 사용자 정보 가져오기
|
||||||
|
const { registerTable, unregisterTable } = useTableOptions(); // Context 훅
|
||||||
|
|
||||||
const [data, setData] = useState<Record<string, any>[]>([]);
|
const [data, setData] = useState<Record<string, any>[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [searchValues, setSearchValues] = useState<Record<string, any>>({});
|
const [searchValues, setSearchValues] = useState<Record<string, any>>({});
|
||||||
|
|
@ -114,6 +118,11 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
const columnRefs = useRef<Record<string, HTMLTableCellElement | null>>({});
|
const columnRefs = useRef<Record<string, HTMLTableCellElement | null>>({});
|
||||||
const isResizingRef = useRef(false);
|
const isResizingRef = useRef(false);
|
||||||
|
|
||||||
|
// TableOptions 상태
|
||||||
|
const [filters, setFilters] = useState<TableFilter[]>([]);
|
||||||
|
const [grouping, setGrouping] = useState<string[]>([]);
|
||||||
|
const [columnVisibility, setColumnVisibility] = useState<ColumnVisibility[]>([]);
|
||||||
|
|
||||||
// SaveModal 상태 (등록/수정 통합)
|
// SaveModal 상태 (등록/수정 통합)
|
||||||
const [showSaveModal, setShowSaveModal] = useState(false);
|
const [showSaveModal, setShowSaveModal] = useState(false);
|
||||||
const [saveModalData, setSaveModalData] = useState<Record<string, any> | undefined>(undefined);
|
const [saveModalData, setSaveModalData] = useState<Record<string, any> | undefined>(undefined);
|
||||||
|
|
@ -147,6 +156,33 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
// 카테고리 값 매핑 캐시 (컬럼명 -> {코드 -> {라벨, 색상}})
|
// 카테고리 값 매핑 캐시 (컬럼명 -> {코드 -> {라벨, 색상}})
|
||||||
const [categoryMappings, setCategoryMappings] = useState<Record<string, Record<string, { label: string; color?: string }>>>({});
|
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(
|
const loadCodeOptions = useCallback(
|
||||||
async (categoryCode: string) => {
|
async (categoryCode: string) => {
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,8 @@ import { isFileComponent } from "@/lib/utils/componentTypeUtils";
|
||||||
import { buildGridClasses } from "@/lib/constants/columnSpans";
|
import { buildGridClasses } from "@/lib/constants/columnSpans";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
|
import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
|
||||||
|
import { TableOptionsProvider } from "@/contexts/TableOptionsContext";
|
||||||
|
import { TableOptionsToolbar } from "./table-options/TableOptionsToolbar";
|
||||||
|
|
||||||
interface InteractiveScreenViewerProps {
|
interface InteractiveScreenViewerProps {
|
||||||
component: ComponentData;
|
component: ComponentData;
|
||||||
|
|
@ -1885,8 +1887,13 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
: component;
|
: component;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<TableOptionsProvider>
|
||||||
<div className="h-full" style={{ width: '100%', height: '100%' }}>
|
<div className="flex h-full flex-col">
|
||||||
|
{/* 테이블 옵션 툴바 */}
|
||||||
|
<TableOptionsToolbar />
|
||||||
|
|
||||||
|
{/* 메인 컨텐츠 */}
|
||||||
|
<div className="h-full flex-1" style={{ width: '100%' }}>
|
||||||
{/* 라벨이 있는 경우 표시 (데이터 테이블 제외) */}
|
{/* 라벨이 있는 경우 표시 (데이터 테이블 제외) */}
|
||||||
{shouldShowLabel && (
|
{shouldShowLabel && (
|
||||||
<label className="mb-2 block text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
|
<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 className="h-full" style={{ width: '100%', height: '100%' }}>{renderInteractiveWidget(componentForRendering)}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 개선된 검증 패널 (선택적 표시) */}
|
{/* 개선된 검증 패널 (선택적 표시) */}
|
||||||
|
|
@ -1986,6 +1994,6 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</>
|
</TableOptionsProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -683,6 +683,9 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
||||||
// ✅ 격자 시스템 잔재 제거: style.width, style.height 무시
|
// ✅ 격자 시스템 잔재 제거: style.width, style.height 무시
|
||||||
const { width: styleWidth, height: styleHeight, ...styleWithoutSize } = style;
|
const { width: styleWidth, height: styleHeight, ...styleWithoutSize } = style;
|
||||||
|
|
||||||
|
// TableSearchWidget의 경우 높이를 자동으로 설정
|
||||||
|
const isTableSearchWidget = (component as any).componentId === "table-search-widget";
|
||||||
|
|
||||||
const componentStyle = {
|
const componentStyle = {
|
||||||
position: "absolute" as const,
|
position: "absolute" as const,
|
||||||
left: position?.x || 0,
|
left: position?.x || 0,
|
||||||
|
|
@ -690,7 +693,8 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
||||||
zIndex: position?.z || 1,
|
zIndex: position?.z || 1,
|
||||||
...styleWithoutSize, // width/height 제외한 스타일만 먼저 적용
|
...styleWithoutSize, // width/height 제외한 스타일만 먼저 적용
|
||||||
width: size?.width || 200, // size의 픽셀 값이 최종 우선순위
|
width: size?.width || 200, // size의 픽셀 값이 최종 우선순위
|
||||||
height: size?.height || 10,
|
height: isTableSearchWidget ? "auto" : (size?.height || 10),
|
||||||
|
minHeight: isTableSearchWidget ? "48px" : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -6,7 +6,7 @@ import { Badge } from "@/components/ui/badge";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { ComponentRegistry } from "@/lib/registry/ComponentRegistry";
|
import { ComponentRegistry } from "@/lib/registry/ComponentRegistry";
|
||||||
import { ComponentDefinition, ComponentCategory } from "@/types/component";
|
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 { TableInfo, ColumnInfo } from "@/types/screen";
|
||||||
import TablesPanel from "./TablesPanel";
|
import TablesPanel from "./TablesPanel";
|
||||||
|
|
||||||
|
|
@ -64,6 +64,7 @@ export function ComponentsPanel({
|
||||||
action: allComponents.filter((c) => c.category === ComponentCategory.ACTION),
|
action: allComponents.filter((c) => c.category === ComponentCategory.ACTION),
|
||||||
display: allComponents.filter((c) => c.category === ComponentCategory.DISPLAY),
|
display: allComponents.filter((c) => c.category === ComponentCategory.DISPLAY),
|
||||||
layout: allComponents.filter((c) => c.category === ComponentCategory.LAYOUT),
|
layout: allComponents.filter((c) => c.category === ComponentCategory.LAYOUT),
|
||||||
|
utility: allComponents.filter((c) => c.category === ComponentCategory.UTILITY), // 🆕 유틸리티 카테고리 추가
|
||||||
};
|
};
|
||||||
}, [allComponents]);
|
}, [allComponents]);
|
||||||
|
|
||||||
|
|
@ -184,7 +185,7 @@ export function ComponentsPanel({
|
||||||
|
|
||||||
{/* 카테고리 탭 */}
|
{/* 카테고리 탭 */}
|
||||||
<Tabs defaultValue="input" className="flex min-h-0 flex-1 flex-col">
|
<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
|
<TabsTrigger
|
||||||
value="tables"
|
value="tables"
|
||||||
className="flex items-center justify-center gap-0.5 px-0 text-[10px]"
|
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" />
|
<Layers className="h-3 w-3" />
|
||||||
<span className="hidden">레이아웃</span>
|
<span className="hidden">레이아웃</span>
|
||||||
</TabsTrigger>
|
</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>
|
</TabsList>
|
||||||
|
|
||||||
{/* 테이블 탭 */}
|
{/* 테이블 탭 */}
|
||||||
|
|
@ -271,6 +280,13 @@ export function ComponentsPanel({
|
||||||
? getFilteredComponents("layout").map(renderComponentCard)
|
? getFilteredComponents("layout").map(renderComponentCard)
|
||||||
: renderEmptyState()}
|
: renderEmptyState()}
|
||||||
</TabsContent>
|
</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>
|
</Tabs>
|
||||||
|
|
||||||
{/* 도움말 */}
|
{/* 도움말 */}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,227 @@
|
||||||
|
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);
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -39,6 +39,8 @@ import { Label } from "@/components/ui/label";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
|
import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
|
import { useTableOptions } from "@/contexts/TableOptionsContext";
|
||||||
|
import { TableFilter, ColumnVisibility } from "@/types/table-options";
|
||||||
|
|
||||||
// 그룹화된 데이터 인터페이스
|
// 그룹화된 데이터 인터페이스
|
||||||
interface GroupedData {
|
interface GroupedData {
|
||||||
|
|
@ -65,6 +67,12 @@ export function FlowWidget({
|
||||||
}: FlowWidgetProps) {
|
}: FlowWidgetProps) {
|
||||||
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인
|
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인
|
||||||
const { user } = useAuth(); // 사용자 정보 가져오기
|
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 => {
|
const formatValue = (value: any): string => {
|
||||||
|
|
@ -301,6 +309,36 @@ export function FlowWidget({
|
||||||
toast.success("그룹이 해제되었습니다");
|
toast.success("그룹이 해제되었습니다");
|
||||||
}, [groupSettingKey]);
|
}, [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 groupedData = useMemo((): GroupedData[] => {
|
||||||
const dataToGroup = filteredData.length > 0 ? filteredData : stepData;
|
const dataToGroup = filteredData.length > 0 ? filteredData : stepData;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -42,6 +42,7 @@ import "./repeater-field-group/RepeaterFieldGroupRenderer";
|
||||||
import "./flow-widget/FlowWidgetRenderer";
|
import "./flow-widget/FlowWidgetRenderer";
|
||||||
import "./numbering-rule/NumberingRuleRenderer";
|
import "./numbering-rule/NumberingRuleRenderer";
|
||||||
import "./category-manager/CategoryManagerRenderer";
|
import "./category-manager/CategoryManagerRenderer";
|
||||||
|
import "./table-search-widget"; // 🆕 테이블 검색 필터 위젯
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 컴포넌트 초기화 함수
|
* 컴포넌트 초기화 함수
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@ import { useToast } from "@/hooks/use-toast";
|
||||||
import { tableTypeApi } from "@/lib/api/screen";
|
import { tableTypeApi } from "@/lib/api/screen";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { useTableOptions } from "@/contexts/TableOptionsContext";
|
||||||
|
import { TableFilter, ColumnVisibility } from "@/types/table-options";
|
||||||
|
|
||||||
export interface SplitPanelLayoutComponentProps extends ComponentRendererProps {
|
export interface SplitPanelLayoutComponentProps extends ComponentRendererProps {
|
||||||
// 추가 props
|
// 추가 props
|
||||||
|
|
@ -37,6 +39,15 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
const minLeftWidth = componentConfig.minLeftWidth || 200;
|
const minLeftWidth = componentConfig.minLeftWidth || 200;
|
||||||
const minRightWidth = componentConfig.minRightWidth || 300;
|
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 [rightFilters, setRightFilters] = useState<TableFilter[]>([]);
|
||||||
|
const [rightGrouping, setRightGrouping] = useState<string[]>([]);
|
||||||
|
const [rightColumnVisibility, setRightColumnVisibility] = useState<ColumnVisibility[]>([]);
|
||||||
|
|
||||||
// 데이터 상태
|
// 데이터 상태
|
||||||
const [leftData, setLeftData] = useState<any[]>([]);
|
const [leftData, setLeftData] = useState<any[]>([]);
|
||||||
const [rightData, setRightData] = useState<any[] | any>(null); // 조인 모드는 배열, 상세 모드는 객체
|
const [rightData, setRightData] = useState<any[] | any>(null); // 조인 모드는 배열, 상세 모드는 객체
|
||||||
|
|
@ -272,6 +283,68 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
[rightTableColumns],
|
[rightTableColumns],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 좌측 테이블 등록 (Context에 등록)
|
||||||
|
useEffect(() => {
|
||||||
|
const leftTableName = componentConfig.leftPanel?.tableName;
|
||||||
|
if (!leftTableName || isDesignMode) return;
|
||||||
|
|
||||||
|
const leftTableId = `split-panel-left-${component.id}`;
|
||||||
|
const leftColumns = componentConfig.leftPanel?.displayColumns || [];
|
||||||
|
|
||||||
|
if (leftColumns.length > 0) {
|
||||||
|
registerTable({
|
||||||
|
tableId: leftTableId,
|
||||||
|
label: `${component.title || "분할 패널"} (좌측)`,
|
||||||
|
tableName: leftTableName,
|
||||||
|
columns: leftColumns.map((col: string) => ({
|
||||||
|
columnName: col,
|
||||||
|
columnLabel: leftColumnLabels[col] || col,
|
||||||
|
inputType: "text",
|
||||||
|
visible: true,
|
||||||
|
width: 150,
|
||||||
|
sortable: true,
|
||||||
|
filterable: true,
|
||||||
|
})),
|
||||||
|
onFilterChange: setLeftFilters,
|
||||||
|
onGroupChange: setLeftGrouping,
|
||||||
|
onColumnVisibilityChange: setLeftColumnVisibility,
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => unregisterTable(leftTableId);
|
||||||
|
}
|
||||||
|
}, [component.id, componentConfig.leftPanel?.tableName, componentConfig.leftPanel?.displayColumns, leftColumnLabels, component.title, isDesignMode]);
|
||||||
|
|
||||||
|
// 우측 테이블 등록 (Context에 등록)
|
||||||
|
useEffect(() => {
|
||||||
|
const rightTableName = componentConfig.rightPanel?.tableName;
|
||||||
|
if (!rightTableName || isDesignMode) return;
|
||||||
|
|
||||||
|
const rightTableId = `split-panel-right-${component.id}`;
|
||||||
|
const rightColumns = rightTableColumns.map((col: any) => col.columnName || col.column_name).filter(Boolean);
|
||||||
|
|
||||||
|
if (rightColumns.length > 0) {
|
||||||
|
registerTable({
|
||||||
|
tableId: rightTableId,
|
||||||
|
label: `${component.title || "분할 패널"} (우측)`,
|
||||||
|
tableName: rightTableName,
|
||||||
|
columns: rightColumns.map((col: string) => ({
|
||||||
|
columnName: col,
|
||||||
|
columnLabel: rightColumnLabels[col] || col,
|
||||||
|
inputType: "text",
|
||||||
|
visible: true,
|
||||||
|
width: 150,
|
||||||
|
sortable: true,
|
||||||
|
filterable: true,
|
||||||
|
})),
|
||||||
|
onFilterChange: setRightFilters,
|
||||||
|
onGroupChange: setRightGrouping,
|
||||||
|
onColumnVisibilityChange: setRightColumnVisibility,
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => unregisterTable(rightTableId);
|
||||||
|
}
|
||||||
|
}, [component.id, componentConfig.rightPanel?.tableName, rightTableColumns, rightColumnLabels, component.title, isDesignMode]);
|
||||||
|
|
||||||
// 좌측 테이블 컬럼 라벨 로드
|
// 좌측 테이블 컬럼 라벨 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadLeftColumnLabels = async () => {
|
const loadLeftColumnLabels = async () => {
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,9 @@ import { AdvancedSearchFilters } from "@/components/screen/filters/AdvancedSearc
|
||||||
import { SingleTableWithSticky } from "./SingleTableWithSticky";
|
import { SingleTableWithSticky } from "./SingleTableWithSticky";
|
||||||
import { CardModeRenderer } from "./CardModeRenderer";
|
import { CardModeRenderer } from "./CardModeRenderer";
|
||||||
import { TableOptionsModal } from "@/components/common/TableOptionsModal";
|
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,67 @@ 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]);
|
||||||
|
|
||||||
|
// 초기 로드 시 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 [data, setData] = useState<Record<string, any>[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
@ -288,6 +352,156 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
const [viewMode, setViewMode] = useState<"table" | "card" | "grouped-card">("table");
|
const [viewMode, setViewMode] = useState<"table" | "card" | "grouped-card">("table");
|
||||||
const [frozenColumns, setFrozenColumns] = useState<string[]>([]);
|
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에서 컬럼 순서 불러오기
|
// 🆕 초기 로드 시 localStorage에서 컬럼 순서 불러오기
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!tableConfig.selectedTable || !userId) return;
|
if (!tableConfig.selectedTable || !userId) return;
|
||||||
|
|
@ -481,43 +695,21 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
.filter(([_, meta]) => meta.inputType === "category")
|
.filter(([_, meta]) => meta.inputType === "category")
|
||||||
.map(([columnName, _]) => columnName);
|
.map(([columnName, _]) => columnName);
|
||||||
|
|
||||||
console.log("🔍 [TableList] 카테고리 컬럼 추출:", {
|
|
||||||
columnMeta,
|
|
||||||
categoryColumns: cols,
|
|
||||||
columnMetaKeys: Object.keys(columnMeta),
|
|
||||||
});
|
|
||||||
|
|
||||||
return cols;
|
return cols;
|
||||||
}, [columnMeta]);
|
}, [columnMeta]);
|
||||||
|
|
||||||
// 카테고리 매핑 로드 (columnMeta 변경 시 즉시 실행)
|
// 카테고리 매핑 로드 (columnMeta 변경 시 즉시 실행)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadCategoryMappings = async () => {
|
const loadCategoryMappings = async () => {
|
||||||
console.log("🔄 [TableList] loadCategoryMappings 트리거:", {
|
|
||||||
hasTable: !!tableConfig.selectedTable,
|
|
||||||
table: tableConfig.selectedTable,
|
|
||||||
categoryColumnsLength: categoryColumns.length,
|
|
||||||
categoryColumns,
|
|
||||||
columnMetaKeys: Object.keys(columnMeta),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!tableConfig.selectedTable) {
|
if (!tableConfig.selectedTable) {
|
||||||
console.log("⏭️ [TableList] 테이블 선택 안됨, 카테고리 매핑 로드 스킵");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (categoryColumns.length === 0) {
|
if (categoryColumns.length === 0) {
|
||||||
console.log("⏭️ [TableList] 카테고리 컬럼 없음, 카테고리 매핑 로드 스킵");
|
|
||||||
setCategoryMappings({});
|
setCategoryMappings({});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("🚀 [TableList] 카테고리 매핑 로드 시작:", {
|
|
||||||
table: tableConfig.selectedTable,
|
|
||||||
categoryColumns,
|
|
||||||
columnMetaKeys: Object.keys(columnMeta),
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const mappings: Record<string, Record<string, { label: string; color?: string }>> = {};
|
const mappings: Record<string, Record<string, { label: string; color?: string }>> = {};
|
||||||
|
|
||||||
|
|
@ -952,8 +1144,18 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
const visibleColumns = useMemo(() => {
|
const visibleColumns = useMemo(() => {
|
||||||
let cols = (tableConfig.columns || []).filter((col) => col.visible !== false);
|
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) {
|
if (tableConfig.checkbox?.enabled) {
|
||||||
const checkboxCol: ColumnConfig = {
|
checkboxCol = {
|
||||||
columnName: "__checkbox__",
|
columnName: "__checkbox__",
|
||||||
displayName: "",
|
displayName: "",
|
||||||
visible: true,
|
visible: true,
|
||||||
|
|
@ -963,15 +1165,9 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
align: "center",
|
align: "center",
|
||||||
order: -1,
|
order: -1,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (tableConfig.checkbox.position === "right") {
|
|
||||||
cols = [...cols, checkboxCol];
|
|
||||||
} else {
|
|
||||||
cols = [checkboxCol, ...cols];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// columnOrder 상태가 있으면 그 순서대로 정렬
|
// columnOrder 상태가 있으면 그 순서대로 정렬 (체크박스 제외)
|
||||||
if (columnOrder.length > 0) {
|
if (columnOrder.length > 0) {
|
||||||
const orderedCols = columnOrder
|
const orderedCols = columnOrder
|
||||||
.map((colName) => cols.find((c) => c.columnName === colName))
|
.map((colName) => cols.find((c) => c.columnName === colName))
|
||||||
|
|
@ -980,17 +1176,22 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
// columnOrder에 없는 새로운 컬럼들 추가
|
// columnOrder에 없는 새로운 컬럼들 추가
|
||||||
const remainingCols = cols.filter((c) => !columnOrder.includes(c.columnName));
|
const remainingCols = cols.filter((c) => !columnOrder.includes(c.columnName));
|
||||||
|
|
||||||
console.log("🔄 columnOrder 기반 정렬:", {
|
cols = [...orderedCols, ...remainingCols];
|
||||||
columnOrder,
|
} else {
|
||||||
orderedColsCount: orderedCols.length,
|
cols = cols.sort((a, b) => (a.order || 0) - (b.order || 0));
|
||||||
remainingColsCount: remainingCols.length,
|
|
||||||
});
|
|
||||||
|
|
||||||
return [...orderedCols, ...remainingCols];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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가 변경될 때마다 현재 컬럼 순서를 부모에게 전달
|
// 🆕 visibleColumns가 변경될 때마다 현재 컬럼 순서를 부모에게 전달
|
||||||
const lastColumnOrderRef = useRef<string>("");
|
const lastColumnOrderRef = useRef<string>("");
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,431 @@
|
||||||
|
"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 sm:text-sm focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||||
|
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 sm:text-sm focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||||
|
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 sm:text-sm focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||||
|
style={{ width: `${width}px`, height: '36px', minHeight: '36px', outline: 'none', boxShadow: 'none' }}
|
||||||
|
>
|
||||||
|
<SelectValue placeholder={column?.columnLabel || "선택"} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{uniqueOptions.length === 0 ? (
|
||||||
|
<div className="px-2 py-1.5 text-xs text-muted-foreground">
|
||||||
|
옵션 없음
|
||||||
|
</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 sm:text-sm focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||||
|
style={{ width: `${width}px`, height: '36px', minHeight: '36px', outline: 'none', boxShadow: 'none' }}
|
||||||
|
placeholder={column?.columnLabel}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="flex w-full flex-wrap items-center gap-2 border-b bg-card"
|
||||||
|
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 items-center gap-2 flex-shrink-0">
|
||||||
|
{/* 데이터 건수 표시 */}
|
||||||
|
{currentTable?.dataCount !== undefined && (
|
||||||
|
<div className="rounded-md bg-muted px-3 py-1.5 text-xs font-medium text-muted-foreground 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
import React from "react";
|
||||||
|
import { TableSearchWidget } from "./TableSearchWidget";
|
||||||
|
|
||||||
|
export class TableSearchWidgetRenderer {
|
||||||
|
static render(component: any) {
|
||||||
|
return <TableSearchWidget component={component} />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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";
|
||||||
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
Loading…
Reference in New Issue