검색 필터기능 수정사항
This commit is contained in:
parent
e653effac0
commit
da9985cd24
|
|
@ -1019,6 +1019,434 @@ export class TableManagementService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 고급 검색 조건 구성
|
||||
*/
|
||||
private async buildAdvancedSearchCondition(
|
||||
tableName: string,
|
||||
columnName: string,
|
||||
value: any,
|
||||
paramIndex: number
|
||||
): Promise<{
|
||||
whereClause: string;
|
||||
values: any[];
|
||||
paramCount: number;
|
||||
} | null> {
|
||||
try {
|
||||
// "__ALL__" 값이거나 빈 값이면 필터 조건을 적용하지 않음
|
||||
if (
|
||||
value === "__ALL__" ||
|
||||
value === "" ||
|
||||
value === null ||
|
||||
value === undefined
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 컬럼 타입 정보 조회
|
||||
const columnInfo = await this.getColumnWebTypeInfo(tableName, columnName);
|
||||
|
||||
if (!columnInfo) {
|
||||
// 컬럼 정보가 없으면 기본 문자열 검색
|
||||
return {
|
||||
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
|
||||
values: [`%${value}%`],
|
||||
paramCount: 1,
|
||||
};
|
||||
}
|
||||
|
||||
const webType = columnInfo.webType;
|
||||
|
||||
// 웹타입별 검색 조건 구성
|
||||
switch (webType) {
|
||||
case "date":
|
||||
case "datetime":
|
||||
return this.buildDateRangeCondition(columnName, value, paramIndex);
|
||||
|
||||
case "number":
|
||||
case "decimal":
|
||||
return this.buildNumberRangeCondition(columnName, value, paramIndex);
|
||||
|
||||
case "code":
|
||||
return await this.buildCodeSearchCondition(
|
||||
tableName,
|
||||
columnName,
|
||||
value,
|
||||
paramIndex
|
||||
);
|
||||
|
||||
case "entity":
|
||||
return await this.buildEntitySearchCondition(
|
||||
tableName,
|
||||
columnName,
|
||||
value,
|
||||
paramIndex
|
||||
);
|
||||
|
||||
default:
|
||||
// 기본 문자열 검색
|
||||
return {
|
||||
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
|
||||
values: [`%${value}%`],
|
||||
paramCount: 1,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`고급 검색 조건 구성 실패: ${tableName}.${columnName}`,
|
||||
error
|
||||
);
|
||||
// 오류 시 기본 검색으로 폴백
|
||||
return {
|
||||
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
|
||||
values: [`%${value}%`],
|
||||
paramCount: 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 날짜 범위 검색 조건 구성
|
||||
*/
|
||||
private buildDateRangeCondition(
|
||||
columnName: string,
|
||||
value: any,
|
||||
paramIndex: number
|
||||
): {
|
||||
whereClause: string;
|
||||
values: any[];
|
||||
paramCount: number;
|
||||
} {
|
||||
const conditions: string[] = [];
|
||||
const values: any[] = [];
|
||||
let paramCount = 0;
|
||||
|
||||
if (typeof value === "object" && value !== null) {
|
||||
if (value.from) {
|
||||
conditions.push(`${columnName} >= $${paramIndex + paramCount}`);
|
||||
values.push(value.from);
|
||||
paramCount++;
|
||||
}
|
||||
if (value.to) {
|
||||
conditions.push(`${columnName} <= $${paramIndex + paramCount}`);
|
||||
values.push(value.to);
|
||||
paramCount++;
|
||||
}
|
||||
} else if (typeof value === "string" && value.trim() !== "") {
|
||||
// 단일 날짜 검색 (해당 날짜의 데이터)
|
||||
conditions.push(`DATE(${columnName}) = DATE($${paramIndex})`);
|
||||
values.push(value);
|
||||
paramCount = 1;
|
||||
}
|
||||
|
||||
if (conditions.length === 0) {
|
||||
return {
|
||||
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
|
||||
values: [`%${value}%`],
|
||||
paramCount: 1,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
whereClause: `(${conditions.join(" AND ")})`,
|
||||
values,
|
||||
paramCount,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 숫자 범위 검색 조건 구성
|
||||
*/
|
||||
private buildNumberRangeCondition(
|
||||
columnName: string,
|
||||
value: any,
|
||||
paramIndex: number
|
||||
): {
|
||||
whereClause: string;
|
||||
values: any[];
|
||||
paramCount: number;
|
||||
} {
|
||||
const conditions: string[] = [];
|
||||
const values: any[] = [];
|
||||
let paramCount = 0;
|
||||
|
||||
if (typeof value === "object" && value !== null) {
|
||||
if (value.min !== undefined && value.min !== null && value.min !== "") {
|
||||
conditions.push(
|
||||
`${columnName}::numeric >= $${paramIndex + paramCount}`
|
||||
);
|
||||
values.push(parseFloat(value.min));
|
||||
paramCount++;
|
||||
}
|
||||
if (value.max !== undefined && value.max !== null && value.max !== "") {
|
||||
conditions.push(
|
||||
`${columnName}::numeric <= $${paramIndex + paramCount}`
|
||||
);
|
||||
values.push(parseFloat(value.max));
|
||||
paramCount++;
|
||||
}
|
||||
} else if (typeof value === "string" || typeof value === "number") {
|
||||
// 정확한 값 검색
|
||||
conditions.push(`${columnName}::numeric = $${paramIndex}`);
|
||||
values.push(parseFloat(value.toString()));
|
||||
paramCount = 1;
|
||||
}
|
||||
|
||||
if (conditions.length === 0) {
|
||||
return {
|
||||
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
|
||||
values: [`%${value}%`],
|
||||
paramCount: 1,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
whereClause: `(${conditions.join(" AND ")})`,
|
||||
values,
|
||||
paramCount,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 코드 검색 조건 구성
|
||||
*/
|
||||
private async buildCodeSearchCondition(
|
||||
tableName: string,
|
||||
columnName: string,
|
||||
value: any,
|
||||
paramIndex: number
|
||||
): Promise<{
|
||||
whereClause: string;
|
||||
values: any[];
|
||||
paramCount: number;
|
||||
}> {
|
||||
try {
|
||||
const codeTypeInfo = await this.getCodeTypeInfo(tableName, columnName);
|
||||
|
||||
if (!codeTypeInfo.isCodeType || !codeTypeInfo.codeCategory) {
|
||||
// 코드 타입이 아니면 기본 검색
|
||||
return {
|
||||
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
|
||||
values: [`%${value}%`],
|
||||
paramCount: 1,
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof value === "string" && value.trim() !== "") {
|
||||
// 코드값 또는 코드명으로 검색
|
||||
return {
|
||||
whereClause: `(
|
||||
${columnName}::text = $${paramIndex} OR
|
||||
EXISTS (
|
||||
SELECT 1 FROM code_info ci
|
||||
WHERE ci.code_category = $${paramIndex + 1}
|
||||
AND ci.code_value = ${columnName}
|
||||
AND ci.code_name ILIKE $${paramIndex + 2}
|
||||
)
|
||||
)`,
|
||||
values: [value, codeTypeInfo.codeCategory, `%${value}%`],
|
||||
paramCount: 3,
|
||||
};
|
||||
} else {
|
||||
// 정확한 코드값 매칭
|
||||
return {
|
||||
whereClause: `${columnName} = $${paramIndex}`,
|
||||
values: [value],
|
||||
paramCount: 1,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`코드 검색 조건 구성 실패: ${tableName}.${columnName}`,
|
||||
error
|
||||
);
|
||||
return {
|
||||
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
|
||||
values: [`%${value}%`],
|
||||
paramCount: 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 엔티티 검색 조건 구성
|
||||
*/
|
||||
private async buildEntitySearchCondition(
|
||||
tableName: string,
|
||||
columnName: string,
|
||||
value: any,
|
||||
paramIndex: number
|
||||
): Promise<{
|
||||
whereClause: string;
|
||||
values: any[];
|
||||
paramCount: number;
|
||||
}> {
|
||||
try {
|
||||
const entityTypeInfo = await this.getEntityTypeInfo(
|
||||
tableName,
|
||||
columnName
|
||||
);
|
||||
|
||||
if (!entityTypeInfo.isEntityType || !entityTypeInfo.referenceTable) {
|
||||
// 엔티티 타입이 아니면 기본 검색
|
||||
return {
|
||||
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
|
||||
values: [`%${value}%`],
|
||||
paramCount: 1,
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof value === "string" && value.trim() !== "") {
|
||||
const displayColumn = entityTypeInfo.displayColumn || "name";
|
||||
const referenceColumn = entityTypeInfo.referenceColumn || "id";
|
||||
|
||||
// 참조 테이블의 표시 컬럼으로 검색
|
||||
return {
|
||||
whereClause: `EXISTS (
|
||||
SELECT 1 FROM ${entityTypeInfo.referenceTable} ref
|
||||
WHERE ref.${referenceColumn} = ${columnName}
|
||||
AND ref.${displayColumn} ILIKE $${paramIndex}
|
||||
)`,
|
||||
values: [`%${value}%`],
|
||||
paramCount: 1,
|
||||
};
|
||||
} else {
|
||||
// 정확한 참조값 매칭
|
||||
return {
|
||||
whereClause: `${columnName} = $${paramIndex}`,
|
||||
values: [value],
|
||||
paramCount: 1,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`엔티티 검색 조건 구성 실패: ${tableName}.${columnName}`,
|
||||
error
|
||||
);
|
||||
return {
|
||||
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
|
||||
values: [`%${value}%`],
|
||||
paramCount: 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 불린 검색 조건 구성
|
||||
*/
|
||||
private buildBooleanCondition(
|
||||
columnName: string,
|
||||
value: any,
|
||||
paramIndex: number
|
||||
): {
|
||||
whereClause: string;
|
||||
values: any[];
|
||||
paramCount: number;
|
||||
} {
|
||||
if (value === "true" || value === true) {
|
||||
return {
|
||||
whereClause: `${columnName} = true`,
|
||||
values: [],
|
||||
paramCount: 0,
|
||||
};
|
||||
} else if (value === "false" || value === false) {
|
||||
return {
|
||||
whereClause: `${columnName} = false`,
|
||||
values: [],
|
||||
paramCount: 0,
|
||||
};
|
||||
} else {
|
||||
// 기본 검색
|
||||
return {
|
||||
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
|
||||
values: [`%${value}%`],
|
||||
paramCount: 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 컬럼 웹타입 정보 조회
|
||||
*/
|
||||
private async getColumnWebTypeInfo(
|
||||
tableName: string,
|
||||
columnName: string
|
||||
): Promise<{
|
||||
webType: string;
|
||||
codeCategory?: string;
|
||||
referenceTable?: string;
|
||||
referenceColumn?: string;
|
||||
displayColumn?: string;
|
||||
} | null> {
|
||||
try {
|
||||
const result = await prisma.column_labels.findFirst({
|
||||
where: {
|
||||
table_name: tableName,
|
||||
column_name: columnName,
|
||||
},
|
||||
select: {
|
||||
web_type: true,
|
||||
code_category: true,
|
||||
reference_table: true,
|
||||
reference_column: true,
|
||||
display_column: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
webType: result.web_type || "",
|
||||
codeCategory: result.code_category || undefined,
|
||||
referenceTable: result.reference_table || undefined,
|
||||
referenceColumn: result.reference_column || undefined,
|
||||
displayColumn: result.display_column || undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`컬럼 웹타입 정보 조회 실패: ${tableName}.${columnName}`,
|
||||
error
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 엔티티 타입 정보 조회
|
||||
*/
|
||||
private async getEntityTypeInfo(
|
||||
tableName: string,
|
||||
columnName: string
|
||||
): Promise<{
|
||||
isEntityType: boolean;
|
||||
referenceTable?: string;
|
||||
referenceColumn?: string;
|
||||
displayColumn?: string;
|
||||
}> {
|
||||
try {
|
||||
const columnInfo = await this.getColumnWebTypeInfo(tableName, columnName);
|
||||
|
||||
if (!columnInfo || columnInfo.webType !== "entity") {
|
||||
return { isEntityType: false };
|
||||
}
|
||||
|
||||
return {
|
||||
isEntityType: true,
|
||||
referenceTable: columnInfo.referenceTable,
|
||||
referenceColumn: columnInfo.referenceColumn,
|
||||
displayColumn: columnInfo.displayColumn,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`엔티티 타입 정보 조회 실패: ${tableName}.${columnName}`,
|
||||
error
|
||||
);
|
||||
return { isEntityType: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블 데이터 조회 (페이징 + 검색)
|
||||
*/
|
||||
|
|
@ -1071,42 +1499,19 @@ export class TableManagementService {
|
|||
// 안전한 컬럼명 검증 (SQL 인젝션 방지)
|
||||
const safeColumn = column.replace(/[^a-zA-Z0-9_]/g, "");
|
||||
|
||||
if (typeof value === "string") {
|
||||
// 🎯 코드 타입 컬럼의 경우 코드값과 코드명 모두로 검색
|
||||
const codeTypeInfo = await this.getCodeTypeInfo(
|
||||
tableName,
|
||||
safeColumn
|
||||
);
|
||||
// 🎯 고급 필터 처리
|
||||
const condition = await this.buildAdvancedSearchCondition(
|
||||
tableName,
|
||||
safeColumn,
|
||||
value,
|
||||
paramIndex
|
||||
);
|
||||
|
||||
if (codeTypeInfo.isCodeType && codeTypeInfo.codeCategory) {
|
||||
// 코드 타입 컬럼: 코드값 또는 코드명으로 검색
|
||||
// 1) 컬럼 값이 직접 검색어와 일치하는 경우
|
||||
// 2) 컬럼 값이 코드값이고, 해당 코드의 코드명이 검색어와 일치하는 경우
|
||||
whereConditions.push(`(
|
||||
${safeColumn}::text ILIKE $${paramIndex} OR
|
||||
EXISTS (
|
||||
SELECT 1 FROM code_info ci
|
||||
WHERE ci.code_category = $${paramIndex + 1}
|
||||
AND ci.code_value = ${safeColumn}
|
||||
AND ci.code_name ILIKE $${paramIndex + 2}
|
||||
)
|
||||
)`);
|
||||
searchValues.push(`%${value}%`); // 직접 값 검색용
|
||||
searchValues.push(codeTypeInfo.codeCategory); // 코드 카테고리
|
||||
searchValues.push(`%${value}%`); // 코드명 검색용
|
||||
paramIndex += 2; // 추가 파라미터로 인해 인덱스 증가
|
||||
} else {
|
||||
// 일반 컬럼: 기존 방식
|
||||
whereConditions.push(
|
||||
`${safeColumn}::text ILIKE $${paramIndex}`
|
||||
);
|
||||
searchValues.push(`%${value}%`);
|
||||
}
|
||||
} else {
|
||||
whereConditions.push(`${safeColumn} = $${paramIndex}`);
|
||||
searchValues.push(value);
|
||||
if (condition) {
|
||||
whereConditions.push(condition.whereClause);
|
||||
searchValues.push(...condition.values);
|
||||
paramIndex += condition.paramCount;
|
||||
}
|
||||
paramIndex++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1698,7 +2103,10 @@ export class TableManagementService {
|
|||
const selectColumns = columns.data.map((col: any) => col.column_name);
|
||||
|
||||
// WHERE 절 구성
|
||||
const whereClause = this.buildWhereClause(options.search);
|
||||
const whereClause = await this.buildWhereClause(
|
||||
tableName,
|
||||
options.search
|
||||
);
|
||||
|
||||
// ORDER BY 절 구성
|
||||
const orderBy = options.sortBy
|
||||
|
|
@ -2061,21 +2469,77 @@ export class TableManagementService {
|
|||
}
|
||||
|
||||
/**
|
||||
* WHERE 절 구성
|
||||
* WHERE 절 구성 (고급 검색 지원)
|
||||
*/
|
||||
private buildWhereClause(search?: Record<string, any>): string {
|
||||
private async buildWhereClause(
|
||||
tableName: string,
|
||||
search?: Record<string, any>
|
||||
): Promise<string> {
|
||||
if (!search || Object.keys(search).length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const conditions: string[] = [];
|
||||
|
||||
for (const [key, value] of Object.entries(search)) {
|
||||
if (value !== undefined && value !== null && value !== "") {
|
||||
for (const [columnName, value] of Object.entries(search)) {
|
||||
if (
|
||||
value === undefined ||
|
||||
value === null ||
|
||||
value === "" ||
|
||||
value === "__ALL__"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
// 고급 검색 조건 구성
|
||||
const searchCondition = await this.buildAdvancedSearchCondition(
|
||||
tableName,
|
||||
columnName,
|
||||
value,
|
||||
1 // paramIndex는 실제로는 사용되지 않음 (직접 값 삽입)
|
||||
);
|
||||
|
||||
if (searchCondition) {
|
||||
// SQL 인젝션 방지를 위해 값을 직접 삽입하는 대신 안전한 방식 사용
|
||||
let condition = searchCondition.whereClause;
|
||||
|
||||
// 파라미터를 실제 값으로 치환 (안전한 방식)
|
||||
searchCondition.values.forEach((val, index) => {
|
||||
const paramPlaceholder = `$${index + 1}`;
|
||||
if (typeof val === "string") {
|
||||
condition = condition.replace(
|
||||
paramPlaceholder,
|
||||
`'${val.replace(/'/g, "''")}'`
|
||||
);
|
||||
} else if (typeof val === "number") {
|
||||
condition = condition.replace(paramPlaceholder, val.toString());
|
||||
} else {
|
||||
condition = condition.replace(
|
||||
paramPlaceholder,
|
||||
`'${String(val).replace(/'/g, "''")}'`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// main. 접두사 추가 (조인 쿼리용)
|
||||
condition = condition.replace(
|
||||
new RegExp(`\\b${columnName}\\b`, "g"),
|
||||
`main.${columnName}`
|
||||
);
|
||||
conditions.push(condition);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`검색 조건 구성 실패: ${columnName}`, error);
|
||||
// 폴백: 기본 문자열 검색
|
||||
if (typeof value === "string") {
|
||||
conditions.push(`main.${key} ILIKE '%${value}%'`);
|
||||
conditions.push(
|
||||
`main.${columnName}::text ILIKE '%${value.replace(/'/g, "''")}%'`
|
||||
);
|
||||
} else {
|
||||
conditions.push(`main.${key} = '${value}'`);
|
||||
conditions.push(
|
||||
`main.${columnName} = '${String(value).replace(/'/g, "''")}'`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,11 +41,12 @@ import {
|
|||
import { tableTypeApi } from "@/lib/api/screen";
|
||||
import { commonCodeApi } from "@/lib/api/commonCode";
|
||||
import { getCurrentUser, UserInfo } from "@/lib/api/client";
|
||||
import { DataTableComponent, DataTableColumn, DataTableFilter, AttachedFileInfo } from "@/types/screen";
|
||||
import { DataTableComponent, DataTableColumn, DataTableFilter } from "@/types/screen-legacy-backup";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { downloadFile, getLinkedFiles, getFilePreviewUrl, getDirectFileUrl } from "@/lib/api/file";
|
||||
import { toast } from "sonner";
|
||||
import { FileUpload } from "@/components/screen/widgets/FileUpload";
|
||||
import { AdvancedSearchFilters } from "./filters/AdvancedSearchFilters";
|
||||
|
||||
// 파일 데이터 타입 정의 (AttachedFileInfo와 호환)
|
||||
interface FileInfo {
|
||||
|
|
@ -332,7 +333,6 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
|
||||
// 검색 가능한 컬럼만 필터링
|
||||
const visibleColumns = component.columns?.filter((col: DataTableColumn) => col.visible) || [];
|
||||
const searchFilters = component.filters || [];
|
||||
|
||||
// 컬럼의 실제 웹 타입 정보 찾기
|
||||
const getColumnWebType = useCallback(
|
||||
|
|
@ -525,6 +525,11 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
}
|
||||
}, [component.tableName]);
|
||||
|
||||
// 실제 사용할 필터 (설정된 필터만 사용, 자동 생성 안함)
|
||||
const searchFilters = useMemo(() => {
|
||||
return component.filters || [];
|
||||
}, [component.filters]);
|
||||
|
||||
// 초기 데이터 로드
|
||||
useEffect(() => {
|
||||
loadData(1, searchValues);
|
||||
|
|
@ -1480,115 +1485,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
}
|
||||
};
|
||||
|
||||
// 검색 필터 렌더링
|
||||
const renderSearchFilter = (filter: DataTableFilter) => {
|
||||
const value = searchValues[filter.columnName] || "";
|
||||
|
||||
switch (filter.widgetType) {
|
||||
case "text":
|
||||
case "email":
|
||||
case "tel":
|
||||
return (
|
||||
<Input
|
||||
key={filter.columnName}
|
||||
placeholder={`${filter.label} 검색...`}
|
||||
value={value}
|
||||
onChange={(e) => handleSearchValueChange(filter.columnName, e.target.value)}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
handleSearch();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
case "number":
|
||||
case "decimal":
|
||||
return (
|
||||
<Input
|
||||
key={filter.columnName}
|
||||
type="number"
|
||||
placeholder={`${filter.label} 입력...`}
|
||||
value={value}
|
||||
onChange={(e) => handleSearchValueChange(filter.columnName, e.target.value)}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
handleSearch();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
case "date":
|
||||
return (
|
||||
<Input
|
||||
key={filter.columnName}
|
||||
type="date"
|
||||
value={value}
|
||||
onChange={(e) => handleSearchValueChange(filter.columnName, e.target.value)}
|
||||
/>
|
||||
);
|
||||
|
||||
case "datetime":
|
||||
return (
|
||||
<Input
|
||||
key={filter.columnName}
|
||||
type="datetime-local"
|
||||
value={value}
|
||||
onChange={(e) => handleSearchValueChange(filter.columnName, e.target.value)}
|
||||
/>
|
||||
);
|
||||
|
||||
case "select":
|
||||
// TODO: 선택 옵션은 추후 구현
|
||||
return (
|
||||
<Select
|
||||
key={filter.columnName}
|
||||
value={value}
|
||||
onValueChange={(newValue) => handleSearchValueChange(filter.columnName, newValue)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={`${filter.label} 선택...`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">전체</SelectItem>
|
||||
{/* TODO: 동적 옵션 로드 */}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
|
||||
case "code":
|
||||
// 코드 타입은 텍스트 검색으로 처리 (코드명으로 검색 가능)
|
||||
return (
|
||||
<Input
|
||||
key={filter.columnName}
|
||||
placeholder={`${filter.label} 검색... (코드명 또는 코드값)`}
|
||||
value={value}
|
||||
onChange={(e) => handleSearchValueChange(filter.columnName, e.target.value)}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
handleSearch();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<Input
|
||||
key={filter.columnName}
|
||||
placeholder={`${filter.label} 검색...`}
|
||||
value={value}
|
||||
onChange={(e) => handleSearchValueChange(filter.columnName, e.target.value)}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
handleSearch();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
// 기존 renderSearchFilter 함수는 AdvancedSearchFilters 컴포넌트로 대체됨
|
||||
|
||||
// 파일 다운로드
|
||||
const handleDownloadFile = useCallback(async (fileInfo: FileInfo) => {
|
||||
|
|
@ -1847,31 +1744,22 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* 검색 필터 */}
|
||||
{searchFilters.length > 0 && (
|
||||
{/* 검색 필터 - 항상 표시 (컬럼 정보 기반 자동 생성) */}
|
||||
{tableColumns && tableColumns.length > 0 && (
|
||||
<>
|
||||
<Separator className="my-2" />
|
||||
<div className="space-y-3">
|
||||
<div className="text-muted-foreground flex items-center gap-2 text-sm">
|
||||
<Search className="h-3 w-3" />
|
||||
검색 필터
|
||||
</div>
|
||||
<div
|
||||
className="grid gap-3"
|
||||
style={{
|
||||
gridTemplateColumns: searchFilters
|
||||
.map((filter: DataTableFilter) => `${filter.gridColumns || 3}fr`)
|
||||
.join(" "),
|
||||
}}
|
||||
>
|
||||
{searchFilters.map((filter: DataTableFilter) => (
|
||||
<div key={filter.columnName} className="space-y-1">
|
||||
<label className="text-muted-foreground text-xs font-medium">{filter.label}</label>
|
||||
{renderSearchFilter(filter)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<AdvancedSearchFilters
|
||||
filters={searchFilters.length > 0 ? searchFilters : []}
|
||||
searchValues={searchValues}
|
||||
onSearchValueChange={handleSearchValueChange}
|
||||
onSearch={handleSearch}
|
||||
onClearFilters={() => {
|
||||
setSearchValues({});
|
||||
loadData(1, {});
|
||||
}}
|
||||
tableColumns={tableColumns}
|
||||
tableName={component.tableName}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,427 @@
|
|||
import React, { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Search, X } from "lucide-react";
|
||||
import { ModernDatePicker } from "./ModernDatePicker";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { commonCodeApi } from "@/lib/api/commonCode";
|
||||
import { EntityReferenceAPI } from "@/lib/api/entityReference";
|
||||
import type { DataTableFilter } from "@/types/screen-legacy-backup";
|
||||
import type { CodeInfo } from "@/types/commonCode";
|
||||
|
||||
interface AdvancedSearchFiltersProps {
|
||||
filters: DataTableFilter[];
|
||||
searchValues: Record<string, any>;
|
||||
onSearchValueChange: (columnName: string, value: any) => void;
|
||||
onSearch: () => void;
|
||||
onClearFilters: () => void;
|
||||
className?: string;
|
||||
tableColumns?: any[]; // 테이블 컬럼 정보
|
||||
tableName?: string; // 테이블명
|
||||
}
|
||||
|
||||
interface DateRangeValue {
|
||||
from?: Date;
|
||||
to?: Date;
|
||||
}
|
||||
|
||||
interface CodeOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface EntityOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const AdvancedSearchFilters: React.FC<AdvancedSearchFiltersProps> = ({
|
||||
filters,
|
||||
searchValues,
|
||||
onSearchValueChange,
|
||||
onSearch,
|
||||
onClearFilters,
|
||||
className = "",
|
||||
tableColumns = [],
|
||||
tableName = "",
|
||||
}) => {
|
||||
// 코드 옵션 캐시
|
||||
const [codeOptions, setCodeOptions] = useState<Record<string, CodeOption[]>>({});
|
||||
// 엔티티 옵션 캐시
|
||||
const [entityOptions, setEntityOptions] = useState<Record<string, EntityOption[]>>({});
|
||||
// 로딩 상태
|
||||
const [loadingStates, setLoadingStates] = useState<Record<string, boolean>>({});
|
||||
|
||||
// 자동 필터 생성 (설정된 필터가 없을 때 테이블 컬럼 기반으로 생성)
|
||||
const autoGeneratedFilters = useMemo(() => {
|
||||
if (filters.length > 0 || !tableColumns || tableColumns.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 필터 가능한 웹타입들
|
||||
const filterableWebTypes = ["text", "email", "tel", "number", "decimal", "date", "datetime", "code", "entity"];
|
||||
|
||||
return tableColumns
|
||||
.filter((col) => {
|
||||
const webType = col.webType || col.web_type;
|
||||
return filterableWebTypes.includes(webType) && col.isVisible !== false;
|
||||
})
|
||||
.slice(0, 6) // 최대 6개까지만 자동 생성
|
||||
.map((col) => ({
|
||||
columnName: col.columnName || col.column_name,
|
||||
widgetType: col.webType || col.web_type,
|
||||
label: col.displayName || col.column_label || col.columnName || col.column_name,
|
||||
gridColumns: 3,
|
||||
codeCategory: col.codeCategory || col.code_category,
|
||||
referenceTable: col.referenceTable || col.reference_table,
|
||||
referenceColumn: col.referenceColumn || col.reference_column,
|
||||
displayColumn: col.displayColumn || col.display_column,
|
||||
}));
|
||||
}, [filters, tableColumns]);
|
||||
|
||||
// 실제 사용할 필터 (설정된 필터가 있으면 우선, 없으면 자동 생성)
|
||||
const effectiveFilters = useMemo(() => {
|
||||
return filters.length > 0 ? filters : autoGeneratedFilters;
|
||||
}, [filters, autoGeneratedFilters]);
|
||||
|
||||
// 코드 데이터 로드
|
||||
const loadCodeOptions = useCallback(
|
||||
async (codeCategory: string) => {
|
||||
if (codeOptions[codeCategory] || loadingStates[codeCategory]) return;
|
||||
|
||||
setLoadingStates((prev) => ({ ...prev, [codeCategory]: true }));
|
||||
|
||||
try {
|
||||
const response = await EntityReferenceAPI.getCodeReferenceData(codeCategory, { limit: 1000 });
|
||||
const options = response.options.map((option) => ({
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
}));
|
||||
|
||||
setCodeOptions((prev) => ({ ...prev, [codeCategory]: options }));
|
||||
} catch (error) {
|
||||
console.error(`코드 카테고리 ${codeCategory} 로드 실패:`, error);
|
||||
setCodeOptions((prev) => ({ ...prev, [codeCategory]: [] }));
|
||||
} finally {
|
||||
setLoadingStates((prev) => ({ ...prev, [codeCategory]: false }));
|
||||
}
|
||||
},
|
||||
[codeOptions, loadingStates],
|
||||
);
|
||||
|
||||
// 엔티티 데이터 로드
|
||||
const loadEntityOptions = useCallback(
|
||||
async (tableName: string, columnName: string) => {
|
||||
const key = `${tableName}.${columnName}`;
|
||||
if (entityOptions[key] || loadingStates[key]) return;
|
||||
|
||||
setLoadingStates((prev) => ({ ...prev, [key]: true }));
|
||||
|
||||
try {
|
||||
const response = await EntityReferenceAPI.getEntityReferenceData(tableName, columnName, { limit: 1000 });
|
||||
const options = response.options.map((option) => ({
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
}));
|
||||
|
||||
setEntityOptions((prev) => ({ ...prev, [key]: options }));
|
||||
} catch (error) {
|
||||
console.error(`엔티티 ${tableName}.${columnName} 로드 실패:`, error);
|
||||
setEntityOptions((prev) => ({ ...prev, [key]: [] }));
|
||||
} finally {
|
||||
setLoadingStates((prev) => ({ ...prev, [key]: false }));
|
||||
}
|
||||
},
|
||||
[entityOptions, loadingStates],
|
||||
);
|
||||
|
||||
// 즉시 검색을 위한 onChange 핸들러
|
||||
const handleChange = useCallback(
|
||||
(columnName: string, newValue: any) => {
|
||||
onSearchValueChange(columnName, newValue);
|
||||
// 즉시 검색 실행 (디바운싱 제거)
|
||||
onSearch();
|
||||
},
|
||||
[onSearchValueChange, onSearch],
|
||||
);
|
||||
|
||||
// 텍스트 입력용 핸들러 (Enter 키 또는 blur 시에만 검색)
|
||||
const handleTextChange = useCallback(
|
||||
(columnName: string, newValue: string) => {
|
||||
onSearchValueChange(columnName, newValue);
|
||||
// 텍스트는 즉시 검색하지 않고 상태만 업데이트
|
||||
},
|
||||
[onSearchValueChange],
|
||||
);
|
||||
|
||||
// Enter 키 또는 blur 시 검색 실행
|
||||
const handleTextSearch = useCallback(() => {
|
||||
onSearch();
|
||||
}, [onSearch]);
|
||||
|
||||
// 필터별 렌더링 함수
|
||||
const renderFilter = (filter: DataTableFilter) => {
|
||||
const value = searchValues[filter.columnName] || "";
|
||||
|
||||
switch (filter.widgetType) {
|
||||
case "text":
|
||||
case "email":
|
||||
case "tel":
|
||||
return (
|
||||
<Input
|
||||
key={filter.columnName}
|
||||
placeholder={`${filter.label} 검색...`}
|
||||
value={value}
|
||||
onChange={(e) => handleTextChange(filter.columnName, e.target.value)}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
handleTextSearch();
|
||||
}
|
||||
}}
|
||||
onBlur={handleTextSearch}
|
||||
/>
|
||||
);
|
||||
|
||||
case "number":
|
||||
case "decimal":
|
||||
// 숫자 필터 모드에 따라 다른 UI 렌더링
|
||||
if (filter.numberFilterMode === "exact") {
|
||||
// 정확한 값 검색
|
||||
return (
|
||||
<Input
|
||||
key={filter.columnName}
|
||||
type="number"
|
||||
placeholder={`${filter.label} 입력...`}
|
||||
value={value || ""}
|
||||
onChange={(e) => handleChange(filter.columnName, e.target.value)}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
// 범위 검색 (기본값)
|
||||
return (
|
||||
<div key={filter.columnName} className="flex space-x-2">
|
||||
<Input
|
||||
type="number"
|
||||
placeholder={`최소값`}
|
||||
value={value?.min || ""}
|
||||
onChange={(e) =>
|
||||
handleChange(filter.columnName, {
|
||||
...value,
|
||||
min: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder={`최대값`}
|
||||
value={value?.max || ""}
|
||||
onChange={(e) =>
|
||||
handleChange(filter.columnName, {
|
||||
...value,
|
||||
max: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case "date":
|
||||
case "datetime":
|
||||
return (
|
||||
<ModernDatePicker
|
||||
key={filter.columnName}
|
||||
label={filter.label}
|
||||
value={value || {}}
|
||||
onChange={(newValue) => handleChange(filter.columnName, newValue)}
|
||||
includeTime={filter.widgetType === "datetime"}
|
||||
/>
|
||||
);
|
||||
|
||||
case "code":
|
||||
console.log("🔍 코드 필터 렌더링:", {
|
||||
columnName: filter.columnName,
|
||||
codeCategory: filter.codeCategory,
|
||||
options: codeOptions[filter.codeCategory || ""],
|
||||
loading: loadingStates[filter.codeCategory || ""],
|
||||
});
|
||||
return (
|
||||
<CodeFilter
|
||||
key={filter.columnName}
|
||||
filter={filter}
|
||||
value={value}
|
||||
onChange={(newValue) => handleChange(filter.columnName, newValue)}
|
||||
options={codeOptions[filter.codeCategory || ""] || []}
|
||||
loading={loadingStates[filter.codeCategory || ""]}
|
||||
onLoadOptions={() => filter.codeCategory && loadCodeOptions(filter.codeCategory)}
|
||||
/>
|
||||
);
|
||||
|
||||
case "entity":
|
||||
return (
|
||||
<EntityFilter
|
||||
key={filter.columnName}
|
||||
filter={filter}
|
||||
value={value}
|
||||
onChange={(newValue) => handleChange(filter.columnName, newValue)}
|
||||
options={entityOptions[`${filter.referenceTable}.${filter.columnName}`] || []}
|
||||
loading={loadingStates[`${filter.referenceTable}.${filter.columnName}`]}
|
||||
onLoadOptions={() => filter.referenceTable && loadEntityOptions(filter.referenceTable, filter.columnName)}
|
||||
/>
|
||||
);
|
||||
|
||||
case "select":
|
||||
case "dropdown":
|
||||
return (
|
||||
<Select
|
||||
key={filter.columnName}
|
||||
value={value}
|
||||
onValueChange={(newValue) => onSearchValueChange(filter.columnName, newValue)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={`${filter.label} 선택...`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__ALL__">전체</SelectItem>
|
||||
{/* TODO: 동적 옵션 로드 */}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<Input
|
||||
key={filter.columnName}
|
||||
placeholder={`${filter.label} 검색...`}
|
||||
value={value}
|
||||
onChange={(e) => handleTextChange(filter.columnName, e.target.value)}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
handleTextSearch();
|
||||
}
|
||||
}}
|
||||
onBlur={handleTextSearch}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// 활성 필터 개수 계산
|
||||
const activeFiltersCount = Object.values(searchValues).filter((value) => {
|
||||
if (typeof value === "string") return value.trim() !== "";
|
||||
if (typeof value === "object" && value !== null) {
|
||||
return Object.values(value).some((v) => v !== "" && v !== null && v !== undefined);
|
||||
}
|
||||
return value !== null && value !== undefined && value !== "";
|
||||
}).length;
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-4", className)}>
|
||||
{/* 필터 헤더 */}
|
||||
<div className="text-muted-foreground flex items-center gap-2 text-sm">
|
||||
<Search className="h-3 w-3" />
|
||||
검색 필터
|
||||
{autoGeneratedFilters.length > 0 && <span className="text-xs text-blue-600">(자동 생성)</span>}
|
||||
</div>
|
||||
|
||||
{/* 필터 그리드 - 적절한 너비로 조정 */}
|
||||
{effectiveFilters.length > 0 && (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{effectiveFilters.map((filter: DataTableFilter) => {
|
||||
// 필터 개수에 따라 적절한 너비 계산
|
||||
const getFilterWidth = () => {
|
||||
const filterCount = effectiveFilters.length;
|
||||
if (filterCount === 1) return "w-80"; // 1개일 때는 적당한 크기
|
||||
if (filterCount === 2) return "w-64"; // 2개일 때는 조금 작게
|
||||
if (filterCount === 3) return "w-52"; // 3개일 때는 더 작게
|
||||
return "w-48"; // 4개 이상일 때는 가장 작게
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={filter.columnName} className={`space-y-1 ${getFilterWidth()}`}>
|
||||
<label className="text-muted-foreground text-xs font-medium">{filter.label}</label>
|
||||
{renderFilter(filter)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 필터 상태 및 초기화 버튼 */}
|
||||
{activeFiltersCount > 0 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-muted-foreground text-sm">{activeFiltersCount}개 필터 적용 중</div>
|
||||
<Button variant="outline" size="sm" onClick={onClearFilters} className="gap-2">
|
||||
<X className="h-3 w-3" />
|
||||
필터 초기화
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 코드 필터 컴포넌트
|
||||
const CodeFilter: React.FC<{
|
||||
filter: DataTableFilter;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
options: CodeOption[];
|
||||
loading: boolean;
|
||||
onLoadOptions: () => void;
|
||||
}> = ({ filter, value, onChange, options, loading, onLoadOptions }) => {
|
||||
useEffect(() => {
|
||||
if (filter.codeCategory && options.length === 0 && !loading) {
|
||||
onLoadOptions();
|
||||
}
|
||||
}, [filter.codeCategory, options.length, loading, onLoadOptions]);
|
||||
|
||||
return (
|
||||
<Select value={value} onValueChange={onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={loading ? "로딩 중..." : `${filter.label} 선택...`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__ALL__">전체</SelectItem>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
|
||||
// 엔티티 필터 컴포넌트
|
||||
const EntityFilter: React.FC<{
|
||||
filter: DataTableFilter;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
options: EntityOption[];
|
||||
loading: boolean;
|
||||
onLoadOptions: () => void;
|
||||
}> = ({ filter, value, onChange, options, loading, onLoadOptions }) => {
|
||||
useEffect(() => {
|
||||
if (filter.referenceTable && options.length === 0 && !loading) {
|
||||
onLoadOptions();
|
||||
}
|
||||
}, [filter.referenceTable, options.length, loading, onLoadOptions]);
|
||||
|
||||
return (
|
||||
<Select value={value} onValueChange={onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={loading ? "로딩 중..." : `${filter.label} 선택...`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__ALL__">전체</SelectItem>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,215 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { CalendarIcon, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import {
|
||||
format,
|
||||
addMonths,
|
||||
subMonths,
|
||||
startOfMonth,
|
||||
endOfMonth,
|
||||
eachDayOfInterval,
|
||||
isSameMonth,
|
||||
isSameDay,
|
||||
isToday,
|
||||
} from "date-fns";
|
||||
import { ko } from "date-fns/locale";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface DateRangeValue {
|
||||
from?: Date;
|
||||
to?: Date;
|
||||
}
|
||||
|
||||
interface ModernDatePickerProps {
|
||||
label: string;
|
||||
value: DateRangeValue;
|
||||
onChange: (value: DateRangeValue) => void;
|
||||
includeTime?: boolean;
|
||||
}
|
||||
|
||||
export const ModernDatePicker: React.FC<ModernDatePickerProps> = ({ label, value, onChange, includeTime = false }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [currentMonth, setCurrentMonth] = useState(new Date());
|
||||
const [selectingType, setSelectingType] = useState<"from" | "to">("from");
|
||||
|
||||
const formatDate = (date: Date | undefined) => {
|
||||
if (!date) return "";
|
||||
if (includeTime) {
|
||||
return format(date, "yyyy-MM-dd HH:mm", { locale: ko });
|
||||
}
|
||||
return format(date, "yyyy-MM-dd", { locale: ko });
|
||||
};
|
||||
|
||||
const displayValue = () => {
|
||||
if (value?.from && value?.to) {
|
||||
return `${formatDate(value.from)} ~ ${formatDate(value.to)}`;
|
||||
}
|
||||
if (value?.from) {
|
||||
return `${formatDate(value.from)} ~`;
|
||||
}
|
||||
if (value?.to) {
|
||||
return `~ ${formatDate(value.to)}`;
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
const handleDateClick = (date: Date) => {
|
||||
if (selectingType === "from") {
|
||||
const newValue = { ...value, from: date };
|
||||
onChange(newValue);
|
||||
setSelectingType("to");
|
||||
} else {
|
||||
const newValue = { ...value, to: date };
|
||||
onChange(newValue);
|
||||
setSelectingType("from");
|
||||
}
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
onChange({});
|
||||
setSelectingType("from");
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
setIsOpen(false);
|
||||
setSelectingType("from");
|
||||
// 날짜는 이미 선택 시점에 onChange가 호출되므로 중복 호출 제거
|
||||
};
|
||||
|
||||
const monthStart = startOfMonth(currentMonth);
|
||||
const monthEnd = endOfMonth(currentMonth);
|
||||
const days = eachDayOfInterval({ start: monthStart, end: monthEnd });
|
||||
|
||||
// 달력 시작을 월요일로 맞추기 위해 앞의 빈 칸들 계산
|
||||
const startDate = new Date(monthStart);
|
||||
const dayOfWeek = startDate.getDay();
|
||||
const paddingDays = dayOfWeek === 0 ? 6 : dayOfWeek - 1; // 일요일(0)이면 6개, 나머지는 -1
|
||||
|
||||
const allDays = [...Array(paddingDays).fill(null), ...days];
|
||||
|
||||
const isInRange = (date: Date) => {
|
||||
if (!value.from || !value.to) return false;
|
||||
return date >= value.from && date <= value.to;
|
||||
};
|
||||
|
||||
const isRangeStart = (date: Date) => {
|
||||
return value.from && isSameDay(date, value.from);
|
||||
};
|
||||
|
||||
const isRangeEnd = (date: Date) => {
|
||||
return value.to && isSameDay(date, value.to);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-start text-left font-normal",
|
||||
!value?.from && !value?.to && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{displayValue() || `${label} 기간 선택...`}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<div className="p-4">
|
||||
{/* 헤더 */}
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium">기간 선택</h3>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{selectingType === "from" ? "시작일을 선택하세요" : "종료일을 선택하세요"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 월 네비게이션 */}
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<Button variant="ghost" size="sm" onClick={() => setCurrentMonth(subMonths(currentMonth, 1))}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="text-sm font-medium">{format(currentMonth, "yyyy년 MM월", { locale: ko })}</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => setCurrentMonth(addMonths(currentMonth, 1))}>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 요일 헤더 */}
|
||||
<div className="mb-2 grid grid-cols-7 gap-1">
|
||||
{["월", "화", "수", "목", "금", "토", "일"].map((day) => (
|
||||
<div key={day} className="text-muted-foreground p-2 text-center text-xs font-medium">
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 날짜 그리드 */}
|
||||
<div className="mb-4 grid grid-cols-7 gap-1">
|
||||
{allDays.map((date, index) => {
|
||||
if (!date) {
|
||||
return <div key={index} className="p-2" />;
|
||||
}
|
||||
|
||||
const isCurrentMonth = isSameMonth(date, currentMonth);
|
||||
const isSelected = isRangeStart(date) || isRangeEnd(date);
|
||||
const isInRangeDate = isInRange(date);
|
||||
const isTodayDate = isToday(date);
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={date.toISOString()}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-8 w-8 p-0 text-xs",
|
||||
!isCurrentMonth && "text-muted-foreground opacity-50",
|
||||
isSelected && "bg-primary text-primary-foreground hover:bg-primary",
|
||||
isInRangeDate && !isSelected && "bg-muted",
|
||||
isTodayDate && !isSelected && "border-primary border",
|
||||
selectingType === "from" && "hover:bg-primary/20",
|
||||
selectingType === "to" && "hover:bg-secondary/20",
|
||||
)}
|
||||
onClick={() => handleDateClick(date)}
|
||||
disabled={!isCurrentMonth}
|
||||
>
|
||||
{format(date, "d")}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 선택된 범위 표시 */}
|
||||
{(value.from || value.to) && (
|
||||
<div className="bg-muted mb-4 rounded-md p-2">
|
||||
<div className="text-muted-foreground mb-1 text-xs">선택된 기간</div>
|
||||
<div className="text-sm">
|
||||
{value.from && <span className="font-medium">시작: {formatDate(value.from)}</span>}
|
||||
{value.from && value.to && <span className="mx-2">~</span>}
|
||||
{value.to && <span className="font-medium">종료: {formatDate(value.to)}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 액션 버튼 */}
|
||||
<div className="flex justify-between">
|
||||
<Button variant="outline" size="sm" onClick={handleClear}>
|
||||
초기화
|
||||
</Button>
|
||||
<div className="space-x-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setIsOpen(false)}>
|
||||
취소
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleConfirm}>
|
||||
확인
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
|
@ -320,18 +320,8 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
columnsCount: table.columns.length,
|
||||
});
|
||||
|
||||
// 테이블의 모든 컬럼을 기본 설정으로 추가
|
||||
const defaultColumns: DataTableColumn[] = table.columns.map((col, index) => ({
|
||||
id: generateComponentId(),
|
||||
columnName: col.columnName,
|
||||
label: col.columnLabel || col.columnName,
|
||||
widgetType: getWidgetTypeFromColumn(col),
|
||||
gridColumns: 2, // 기본 2칸
|
||||
visible: index < 6, // 처음 6개만 기본으로 표시
|
||||
filterable: isFilterableWebType(getWidgetTypeFromColumn(col)),
|
||||
sortable: true,
|
||||
searchable: ["text", "email", "tel"].includes(getWidgetTypeFromColumn(col)),
|
||||
}));
|
||||
// 테이블 변경 시 컬럼을 자동으로 추가하지 않음 (사용자가 수동으로 추가해야 함)
|
||||
const defaultColumns: DataTableColumn[] = [];
|
||||
|
||||
console.log("✅ 생성된 컬럼 설정:", {
|
||||
defaultColumnsCount: defaultColumns.length,
|
||||
|
|
@ -785,6 +775,11 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
|||
widgetType,
|
||||
label: targetColumn.columnLabel || targetColumn.columnName,
|
||||
gridColumns: 3,
|
||||
// 웹타입별 추가 정보 설정
|
||||
codeCategory: targetColumn.codeCategory,
|
||||
referenceTable: targetColumn.referenceTable,
|
||||
referenceColumn: targetColumn.referenceColumn,
|
||||
displayColumn: targetColumn.displayColumn,
|
||||
};
|
||||
|
||||
console.log("➕ 필터 추가 시작:", {
|
||||
|
|
|
|||
|
|
@ -121,25 +121,9 @@ export const DataTableTemplate: React.FC<DataTableTemplateProps> = ({
|
|||
className = "",
|
||||
isPreview = true,
|
||||
}) => {
|
||||
// 미리보기용 기본 컬럼 데이터
|
||||
// 설정된 컬럼만 사용 (자동 생성 안함)
|
||||
const defaultColumns = React.useMemo(() => {
|
||||
if (columns.length > 0) return columns;
|
||||
|
||||
return [
|
||||
{ id: "id", label: "ID", type: "number", visible: true, sortable: true, filterable: false, width: 80 },
|
||||
{ id: "name", label: "이름", type: "text", visible: true, sortable: true, filterable: true, width: 150 },
|
||||
{ id: "email", label: "이메일", type: "email", visible: true, sortable: true, filterable: true, width: 200 },
|
||||
{ id: "status", label: "상태", type: "select", visible: true, sortable: true, filterable: true, width: 100 },
|
||||
{
|
||||
id: "created_date",
|
||||
label: "생성일",
|
||||
type: "date",
|
||||
visible: true,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
width: 120,
|
||||
},
|
||||
];
|
||||
return columns || [];
|
||||
}, [columns]);
|
||||
|
||||
// 미리보기용 샘플 데이터
|
||||
|
|
|
|||
|
|
@ -7,14 +7,12 @@ import { entityJoinApi } from "@/lib/api/entityJoin";
|
|||
import { codeCache } from "@/lib/caching/codeCache";
|
||||
import { useEntityJoinOptimization } from "@/lib/hooks/useEntityJoinOptimization";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronsLeft,
|
||||
ChevronsRight,
|
||||
Search,
|
||||
RefreshCw,
|
||||
ArrowUpDown,
|
||||
ArrowUp,
|
||||
|
|
@ -23,6 +21,8 @@ import {
|
|||
} from "lucide-react";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AdvancedSearchFilters } from "@/components/screen/filters/AdvancedSearchFilters";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
export interface TableListComponentProps {
|
||||
component: any;
|
||||
|
|
@ -96,10 +96,12 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
const [columnLabels, setColumnLabels] = useState<Record<string, string>>({});
|
||||
const [tableLabel, setTableLabel] = useState<string>("");
|
||||
const [localPageSize, setLocalPageSize] = useState<number>(tableConfig.pagination?.pageSize || 20); // 로컬 페이지 크기 상태
|
||||
const [selectedSearchColumn, setSelectedSearchColumn] = useState<string>(""); // 선택된 검색 컬럼
|
||||
const [displayColumns, setDisplayColumns] = useState<ColumnConfig[]>([]); // 🎯 표시할 컬럼 (Entity 조인 적용됨)
|
||||
const [columnMeta, setColumnMeta] = useState<Record<string, { webType?: string; codeCategory?: string }>>({}); // 🎯 컬럼 메타정보 (웹타입, 코드카테고리)
|
||||
|
||||
// 고급 필터 관련 state
|
||||
const [searchValues, setSearchValues] = useState<Record<string, any>>({});
|
||||
|
||||
// 체크박스 상태 관리
|
||||
const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set()); // 선택된 행들의 키 집합
|
||||
const [isAllSelected, setIsAllSelected] = useState(false); // 전체 선택 상태
|
||||
|
|
@ -234,56 +236,66 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
const result = await entityJoinApi.getTableDataWithJoins(tableConfig.selectedTable, {
|
||||
page: currentPage,
|
||||
size: localPageSize,
|
||||
search: searchTerm?.trim()
|
||||
? (() => {
|
||||
// 스마트한 단일 컬럼 선택 로직 (서버가 OR 검색을 지원하지 않음)
|
||||
let searchColumn = selectedSearchColumn || sortColumn; // 사용자 선택 컬럼 최우선, 그 다음 정렬된 컬럼
|
||||
search: (() => {
|
||||
// 고급 필터 값이 있으면 우선 사용
|
||||
const hasAdvancedFilters = Object.values(searchValues).some((value) => {
|
||||
if (typeof value === "string") return value.trim() !== "";
|
||||
if (typeof value === "object" && value !== null) {
|
||||
return Object.values(value).some((v) => v !== "" && v !== null && v !== undefined);
|
||||
}
|
||||
return value !== null && value !== undefined && value !== "";
|
||||
});
|
||||
|
||||
if (!searchColumn) {
|
||||
// 1순위: name 관련 컬럼 (가장 검색에 적합)
|
||||
const nameColumns = visibleColumns.filter(
|
||||
(col) =>
|
||||
col.columnName.toLowerCase().includes("name") ||
|
||||
col.columnName.toLowerCase().includes("title") ||
|
||||
col.columnName.toLowerCase().includes("subject"),
|
||||
);
|
||||
if (hasAdvancedFilters) {
|
||||
console.log("🔍 고급 검색 필터 사용:", searchValues);
|
||||
console.log("🔍 고급 검색 필터 상세:", JSON.stringify(searchValues, null, 2));
|
||||
return searchValues;
|
||||
}
|
||||
|
||||
// 2순위: text/varchar 타입 컬럼
|
||||
const textColumns = visibleColumns.filter(
|
||||
(col) => col.dataType === "text" || col.dataType === "varchar",
|
||||
);
|
||||
// 고급 필터가 없으면 기존 단순 검색 사용
|
||||
if (searchTerm?.trim()) {
|
||||
// 스마트한 단일 컬럼 선택 로직 (서버가 OR 검색을 지원하지 않음)
|
||||
let searchColumn = sortColumn; // 정렬된 컬럼 우선
|
||||
|
||||
// 3순위: description 관련 컬럼
|
||||
const descColumns = visibleColumns.filter(
|
||||
(col) =>
|
||||
col.columnName.toLowerCase().includes("desc") ||
|
||||
col.columnName.toLowerCase().includes("comment") ||
|
||||
col.columnName.toLowerCase().includes("memo"),
|
||||
);
|
||||
|
||||
// 우선순위에 따라 선택
|
||||
if (nameColumns.length > 0) {
|
||||
searchColumn = nameColumns[0].columnName;
|
||||
} else if (textColumns.length > 0) {
|
||||
searchColumn = textColumns[0].columnName;
|
||||
} else if (descColumns.length > 0) {
|
||||
searchColumn = descColumns[0].columnName;
|
||||
} else {
|
||||
// 마지막 대안: 첫 번째 컬럼
|
||||
searchColumn = visibleColumns[0]?.columnName || "id";
|
||||
}
|
||||
}
|
||||
|
||||
console.log("🔍 선택된 검색 컬럼:", searchColumn);
|
||||
console.log("🔍 검색어:", searchTerm);
|
||||
console.log(
|
||||
"🔍 사용 가능한 컬럼들:",
|
||||
visibleColumns.map((col) => `${col.columnName}(${col.dataType || "unknown"})`),
|
||||
if (!searchColumn) {
|
||||
// 1순위: name 관련 컬럼 (가장 검색에 적합)
|
||||
const nameColumns = visibleColumns.filter(
|
||||
(col) =>
|
||||
col.columnName.toLowerCase().includes("name") ||
|
||||
col.columnName.toLowerCase().includes("title") ||
|
||||
col.columnName.toLowerCase().includes("subject"),
|
||||
);
|
||||
|
||||
return { [searchColumn]: searchTerm };
|
||||
})()
|
||||
: undefined,
|
||||
// 2순위: text/varchar 타입 컬럼
|
||||
const textColumns = visibleColumns.filter((col) => col.dataType === "text" || col.dataType === "varchar");
|
||||
|
||||
// 3순위: description 관련 컬럼
|
||||
const descColumns = visibleColumns.filter(
|
||||
(col) =>
|
||||
col.columnName.toLowerCase().includes("desc") ||
|
||||
col.columnName.toLowerCase().includes("comment") ||
|
||||
col.columnName.toLowerCase().includes("memo"),
|
||||
);
|
||||
|
||||
// 우선순위에 따라 선택
|
||||
if (nameColumns.length > 0) {
|
||||
searchColumn = nameColumns[0].columnName;
|
||||
} else if (textColumns.length > 0) {
|
||||
searchColumn = textColumns[0].columnName;
|
||||
} else if (descColumns.length > 0) {
|
||||
searchColumn = descColumns[0].columnName;
|
||||
} else {
|
||||
// 마지막 대안: 첫 번째 컬럼
|
||||
searchColumn = visibleColumns[0]?.columnName || "id";
|
||||
}
|
||||
}
|
||||
|
||||
console.log("🔍 기존 검색 방식 사용:", { [searchColumn]: searchTerm });
|
||||
return { [searchColumn]: searchTerm };
|
||||
}
|
||||
|
||||
return undefined;
|
||||
})(),
|
||||
sortBy: sortColumn || undefined,
|
||||
sortOrder: sortDirection,
|
||||
enableEntityJoin: true, // 🎯 Entity 조인 활성화
|
||||
|
|
@ -410,10 +422,23 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
}
|
||||
};
|
||||
|
||||
// 검색
|
||||
const handleSearch = (term: string) => {
|
||||
setSearchTerm(term);
|
||||
setCurrentPage(1); // 검색 시 첫 페이지로 이동
|
||||
// 고급 필터 핸들러
|
||||
const handleSearchValueChange = (columnName: string, value: any) => {
|
||||
setSearchValues((prev) => ({
|
||||
...prev,
|
||||
[columnName]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleAdvancedSearch = () => {
|
||||
setCurrentPage(1);
|
||||
fetchTableData();
|
||||
};
|
||||
|
||||
const handleClearAdvancedFilters = () => {
|
||||
setSearchValues({});
|
||||
setCurrentPage(1);
|
||||
fetchTableData();
|
||||
};
|
||||
|
||||
// 새로고침
|
||||
|
|
@ -522,7 +547,16 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
if (tableConfig.autoLoad && !isDesignMode) {
|
||||
fetchTableData();
|
||||
}
|
||||
}, [tableConfig.selectedTable, localPageSize, currentPage, searchTerm, sortColumn, sortDirection, columnLabels]);
|
||||
}, [
|
||||
tableConfig.selectedTable,
|
||||
localPageSize,
|
||||
currentPage,
|
||||
searchTerm,
|
||||
sortColumn,
|
||||
sortDirection,
|
||||
columnLabels,
|
||||
searchValues,
|
||||
]);
|
||||
|
||||
// refreshKey 변경 시 테이블 데이터 새로고침
|
||||
useEffect(() => {
|
||||
|
|
@ -577,8 +611,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
.sort((a, b) => a.order - b.order);
|
||||
}
|
||||
|
||||
// 체크박스가 활성화된 경우 체크박스 컬럼을 추가
|
||||
if (checkboxConfig.enabled) {
|
||||
// 체크박스가 활성화되고 실제 데이터 컬럼이 있는 경우에만 체크박스 컬럼을 추가
|
||||
if (checkboxConfig.enabled && columns.length > 0) {
|
||||
const checkboxColumn: ColumnConfig = {
|
||||
columnName: "__checkbox__",
|
||||
displayName: "",
|
||||
|
|
@ -819,8 +853,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* 검색 */}
|
||||
{tableConfig.filter?.enabled && tableConfig.filter?.quickSearch && (
|
||||
{/* 검색 - 기존 방식은 주석처리 */}
|
||||
{/* {tableConfig.filter?.enabled && tableConfig.filter?.quickSearch && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute top-1/2 left-2 h-4 w-4 -translate-y-1/2 transform text-gray-400" />
|
||||
|
|
@ -831,7 +865,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
className="w-64 pl-8"
|
||||
/>
|
||||
</div>
|
||||
{/* 검색 컬럼 선택 드롭다운 */}
|
||||
{tableConfig.filter?.showColumnSelector && (
|
||||
<select
|
||||
value={selectedSearchColumn}
|
||||
|
|
@ -847,7 +880,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
</select>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
)} */}
|
||||
|
||||
{/* 새로고침 */}
|
||||
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={loading}>
|
||||
|
|
@ -857,6 +890,33 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* 고급 검색 필터 - 항상 표시 (컬럼 정보 기반 자동 생성) */}
|
||||
{tableConfig.filter?.enabled && visibleColumns && visibleColumns.length > 0 && (
|
||||
<>
|
||||
<Separator className="my-2" />
|
||||
<AdvancedSearchFilters
|
||||
filters={tableConfig.filter?.filters || []} // 설정된 필터 사용, 없으면 자동 생성
|
||||
searchValues={searchValues}
|
||||
onSearchValueChange={handleSearchValueChange}
|
||||
onSearch={handleAdvancedSearch}
|
||||
onClearFilters={handleClearAdvancedFilters}
|
||||
tableColumns={visibleColumns.map((col) => ({
|
||||
columnName: col.columnName,
|
||||
webType: columnMeta[col.columnName]?.webType || "text",
|
||||
displayName: columnLabels[col.columnName] || col.displayName || col.columnName,
|
||||
codeCategory: columnMeta[col.columnName]?.codeCategory,
|
||||
isVisible: col.visible,
|
||||
// 추가 메타데이터 전달 (필터 자동 생성용)
|
||||
web_type: columnMeta[col.columnName]?.webType || "text",
|
||||
column_name: col.columnName,
|
||||
column_label: columnLabels[col.columnName] || col.displayName || col.columnName,
|
||||
code_category: columnMeta[col.columnName]?.codeCategory,
|
||||
}))}
|
||||
tableName={tableConfig.selectedTable}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 테이블 컨텐츠 */}
|
||||
<div className={`flex-1 ${localPageSize >= 50 ? "overflow-auto" : "overflow-hidden"}`}>
|
||||
{loading ? (
|
||||
|
|
|
|||
|
|
@ -95,20 +95,33 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
|||
fetchTables();
|
||||
}, []);
|
||||
|
||||
// 선택된 테이블의 컬럼 목록 설정 (tableColumns prop 우선 사용)
|
||||
// 선택된 테이블의 컬럼 목록 설정
|
||||
useEffect(() => {
|
||||
console.log("🔍 useEffect 실행됨 - tableColumns:", tableColumns, "length:", tableColumns?.length);
|
||||
if (tableColumns && tableColumns.length > 0) {
|
||||
// tableColumns prop이 있으면 사용
|
||||
console.log(
|
||||
"🔍 useEffect 실행됨 - config.selectedTable:",
|
||||
config.selectedTable,
|
||||
"screenTableName:",
|
||||
screenTableName,
|
||||
);
|
||||
|
||||
// 컴포넌트에 명시적으로 테이블이 선택되었거나, 화면에 연결된 테이블이 있는 경우에만 컬럼 목록 표시
|
||||
const shouldShowColumns = config.selectedTable || (screenTableName && config.columns && config.columns.length > 0);
|
||||
|
||||
if (!shouldShowColumns) {
|
||||
console.log("🔧 컬럼 목록 숨김 - 명시적 테이블 선택 또는 설정된 컬럼이 없음");
|
||||
setAvailableColumns([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// tableColumns prop을 우선 사용하되, 컴포넌트가 명시적으로 설정되었을 때만
|
||||
if (tableColumns && tableColumns.length > 0 && (config.selectedTable || config.columns?.length > 0)) {
|
||||
console.log("🔧 tableColumns prop 사용:", tableColumns);
|
||||
console.log("🔧 첫 번째 컬럼 상세:", tableColumns[0]);
|
||||
const mappedColumns = tableColumns.map((column: any) => ({
|
||||
columnName: column.columnName || column.name,
|
||||
dataType: column.dataType || column.type || "text",
|
||||
label: column.label || column.displayName || column.columnLabel || column.columnName || column.name,
|
||||
}));
|
||||
console.log("🏷️ availableColumns 설정됨:", mappedColumns);
|
||||
console.log("🏷️ 첫 번째 mappedColumn:", mappedColumns[0]);
|
||||
setAvailableColumns(mappedColumns);
|
||||
} else if (config.selectedTable || screenTableName) {
|
||||
// API에서 컬럼 정보 가져오기
|
||||
|
|
@ -144,7 +157,7 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
|||
} else {
|
||||
setAvailableColumns([]);
|
||||
}
|
||||
}, [config.selectedTable, screenTableName, tableColumns]);
|
||||
}, [config.selectedTable, screenTableName, tableColumns, config.columns]);
|
||||
|
||||
// Entity 조인 컬럼 정보 가져오기
|
||||
useEffect(() => {
|
||||
|
|
@ -274,6 +287,72 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
|||
handleChange("columns", columns);
|
||||
};
|
||||
|
||||
// 필터 추가
|
||||
const addFilter = (columnName: string) => {
|
||||
const existingFilter = config.filter?.filters?.find((f) => f.columnName === columnName);
|
||||
if (existingFilter) return;
|
||||
|
||||
const column = availableColumns.find((col) => col.columnName === columnName);
|
||||
if (!column) return;
|
||||
|
||||
// tableColumns에서 해당 컬럼의 메타정보 찾기
|
||||
const tableColumn = tableColumns?.find((tc) => tc.columnName === columnName || tc.column_name === columnName);
|
||||
|
||||
// 컬럼의 데이터 타입과 웹타입에 따라 위젯 타입 결정
|
||||
const inferWidgetType = (dataType: string, webType?: string): string => {
|
||||
// 웹타입이 있으면 우선 사용
|
||||
if (webType) {
|
||||
return webType;
|
||||
}
|
||||
|
||||
// 데이터 타입으로 추론
|
||||
const type = dataType.toLowerCase();
|
||||
if (type.includes("int") || type.includes("numeric") || type.includes("decimal")) return "number";
|
||||
if (type.includes("date") || type.includes("timestamp")) return "date";
|
||||
if (type.includes("bool")) return "boolean";
|
||||
return "text";
|
||||
};
|
||||
|
||||
const widgetType = inferWidgetType(column.dataType, tableColumn?.webType || tableColumn?.web_type);
|
||||
|
||||
const newFilter = {
|
||||
columnName,
|
||||
widgetType,
|
||||
label: column.label || column.columnName,
|
||||
gridColumns: 3,
|
||||
numberFilterMode: "range" as const,
|
||||
// 코드 타입인 경우 코드 카테고리 추가
|
||||
...(widgetType === "code" && {
|
||||
codeCategory: tableColumn?.codeCategory || tableColumn?.code_category,
|
||||
}),
|
||||
// 엔티티 타입인 경우 참조 정보 추가
|
||||
...(widgetType === "entity" && {
|
||||
referenceTable: tableColumn?.referenceTable || tableColumn?.reference_table,
|
||||
referenceColumn: tableColumn?.referenceColumn || tableColumn?.reference_column,
|
||||
displayColumn: tableColumn?.displayColumn || tableColumn?.display_column,
|
||||
}),
|
||||
};
|
||||
|
||||
console.log("🔍 필터 추가:", newFilter);
|
||||
|
||||
const currentFilters = config.filter?.filters || [];
|
||||
handleNestedChange("filter", "filters", [...currentFilters, newFilter]);
|
||||
};
|
||||
|
||||
// 필터 제거
|
||||
const removeFilter = (index: number) => {
|
||||
const currentFilters = config.filter?.filters || [];
|
||||
const updatedFilters = currentFilters.filter((_, i) => i !== index);
|
||||
handleNestedChange("filter", "filters", updatedFilters);
|
||||
};
|
||||
|
||||
// 필터 업데이트
|
||||
const updateFilter = (index: number, key: string, value: any) => {
|
||||
const currentFilters = config.filter?.filters || [];
|
||||
const updatedFilters = currentFilters.map((filter, i) => (i === index ? { ...filter, [key]: value } : filter));
|
||||
handleNestedChange("filter", "filters", updatedFilters);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm font-medium">테이블 리스트 설정</div>
|
||||
|
|
@ -620,6 +699,16 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
|||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : availableColumns.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center text-gray-500">
|
||||
<p>컬럼을 추가하려면 먼저 컴포넌트에 테이블을 명시적으로 선택하거나</p>
|
||||
<p className="text-sm">기본 설정 탭에서 테이블을 설정해주세요.</p>
|
||||
<p className="mt-2 text-xs text-blue-600">현재 화면 테이블: {screenTableName}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
<Card>
|
||||
|
|
@ -990,54 +1079,141 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
|||
{/* 필터 설정 탭 */}
|
||||
<TabsContent value="filter" className="space-y-4">
|
||||
<ScrollArea className="h-[600px] pr-4">
|
||||
{/* 필터 기능 활성화 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">검색 및 필터</CardTitle>
|
||||
<CardTitle className="text-base">필터 설정</CardTitle>
|
||||
<CardDescription>테이블에서 사용할 검색 필터를 설정하세요</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="filterEnabled"
|
||||
checked={config.filter?.enabled}
|
||||
checked={config.filter?.enabled || false}
|
||||
onCheckedChange={(checked) => handleNestedChange("filter", "enabled", checked)}
|
||||
/>
|
||||
<Label htmlFor="filterEnabled">필터 기능 사용</Label>
|
||||
</div>
|
||||
|
||||
{config.filter?.enabled && (
|
||||
<>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="quickSearch"
|
||||
checked={config.filter?.quickSearch}
|
||||
onCheckedChange={(checked) => handleNestedChange("filter", "quickSearch", checked)}
|
||||
/>
|
||||
<Label htmlFor="quickSearch">빠른 검색</Label>
|
||||
</div>
|
||||
|
||||
{config.filter?.quickSearch && (
|
||||
<div className="ml-6 flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="showColumnSelector"
|
||||
checked={config.filter?.showColumnSelector}
|
||||
onCheckedChange={(checked) => handleNestedChange("filter", "showColumnSelector", checked)}
|
||||
/>
|
||||
<Label htmlFor="showColumnSelector">검색 컬럼 선택기 표시</Label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="advancedFilter"
|
||||
checked={config.filter?.advancedFilter}
|
||||
onCheckedChange={(checked) => handleNestedChange("filter", "advancedFilter", checked)}
|
||||
/>
|
||||
<Label htmlFor="advancedFilter">고급 필터</Label>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 필터 목록 */}
|
||||
{config.filter?.enabled && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">사용할 필터</CardTitle>
|
||||
<CardDescription>검색에 사용할 컬럼 필터를 추가하고 설정하세요</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* 필터 추가 버튼 */}
|
||||
{availableColumns.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{availableColumns
|
||||
.filter((col) => !config.filter?.filters?.find((f) => f.columnName === col.columnName))
|
||||
.map((column) => (
|
||||
<Button
|
||||
key={column.columnName}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => addFilter(column.columnName)}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
{column.label || column.columnName}
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{column.dataType}
|
||||
</Badge>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 설정된 필터 목록 */}
|
||||
{config.filter?.filters && config.filter.filters.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">설정된 필터</h4>
|
||||
{config.filter.filters.map((filter, index) => (
|
||||
<div key={filter.columnName} className="space-y-3 rounded-lg border p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline">{filter.widgetType}</Badge>
|
||||
<span className="font-medium">{filter.label}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => removeFilter(index)}
|
||||
className="text-red-600 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-xs">표시명</Label>
|
||||
<Input
|
||||
value={filter.label}
|
||||
onChange={(e) => updateFilter(index, "label", e.target.value)}
|
||||
placeholder="필터 라벨"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">그리드 컬럼</Label>
|
||||
<Select
|
||||
value={filter.gridColumns.toString()}
|
||||
onValueChange={(value) => updateFilter(index, "gridColumns", parseInt(value))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="2">2칸</SelectItem>
|
||||
<SelectItem value="3">3칸</SelectItem>
|
||||
<SelectItem value="4">4칸</SelectItem>
|
||||
<SelectItem value="6">6칸</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 숫자 타입인 경우 검색 모드 선택 */}
|
||||
{(filter.widgetType === "number" || filter.widgetType === "decimal") && (
|
||||
<div>
|
||||
<Label className="text-xs">검색 모드</Label>
|
||||
<Select
|
||||
value={filter.numberFilterMode || "range"}
|
||||
onValueChange={(value) => updateFilter(index, "numberFilterMode", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="exact">정확한 값</SelectItem>
|
||||
<SelectItem value="range">범위 검색</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 코드 타입인 경우 코드 카테고리 */}
|
||||
{filter.widgetType === "code" && (
|
||||
<div>
|
||||
<Label className="text-xs">코드 카테고리</Label>
|
||||
<Input
|
||||
value={filter.codeCategory || ""}
|
||||
onChange={(e) => updateFilter(index, "codeCategory", e.target.value)}
|
||||
placeholder="코드 카테고리"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
|
|
|
|||
|
|
@ -59,10 +59,7 @@ export const TableListDefinition = createComponentDefinition({
|
|||
// 필터 설정
|
||||
filter: {
|
||||
enabled: true,
|
||||
quickSearch: true,
|
||||
showColumnSelector: true, // 검색컬럼 선택기 표시 기본값
|
||||
advancedFilter: false,
|
||||
filterableColumns: [],
|
||||
filters: [], // 사용자가 설정할 필터 목록
|
||||
},
|
||||
|
||||
// 액션 설정
|
||||
|
|
|
|||
|
|
@ -71,14 +71,17 @@ export interface ColumnConfig {
|
|||
*/
|
||||
export interface FilterConfig {
|
||||
enabled: boolean;
|
||||
quickSearch: boolean;
|
||||
showColumnSelector?: boolean; // 검색 컬럼 선택기 표시 여부
|
||||
advancedFilter: boolean;
|
||||
filterableColumns: string[];
|
||||
defaultFilters?: Array<{
|
||||
column: string;
|
||||
operator: "=" | "!=" | ">" | "<" | ">=" | "<=" | "LIKE" | "IN";
|
||||
value: any;
|
||||
// 사용할 필터 목록 (DataTableFilter 타입 사용)
|
||||
filters: Array<{
|
||||
columnName: string;
|
||||
widgetType: string;
|
||||
label: string;
|
||||
gridColumns: number;
|
||||
numberFilterMode?: "exact" | "range"; // 숫자 필터 모드
|
||||
codeCategory?: string;
|
||||
referenceTable?: string;
|
||||
referenceColumn?: string;
|
||||
displayColumn?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -432,6 +432,9 @@ export interface DataTableColumn {
|
|||
};
|
||||
}
|
||||
|
||||
// 숫자 필터 검색 모드
|
||||
export type NumberFilterMode = "exact" | "range";
|
||||
|
||||
// 데이터 테이블 필터 설정
|
||||
export interface DataTableFilter {
|
||||
columnName: string;
|
||||
|
|
@ -439,6 +442,13 @@ export interface DataTableFilter {
|
|||
label: string;
|
||||
gridColumns: number; // 필터에서 차지할 컬럼 수
|
||||
webTypeConfig?: WebTypeConfig;
|
||||
|
||||
// 고급 필터를 위한 추가 속성들
|
||||
codeCategory?: string; // 코드 타입용 카테고리
|
||||
referenceTable?: string; // 엔티티 타입용 참조 테이블
|
||||
referenceColumn?: string; // 엔티티 타입용 참조 컬럼
|
||||
displayColumn?: string; // 엔티티 타입용 표시 컬럼
|
||||
numberFilterMode?: NumberFilterMode; // 숫자 필터 모드 (exact: 정확한 값, range: 범위)
|
||||
}
|
||||
|
||||
// 데이터 테이블 페이지네이션 설정
|
||||
|
|
|
|||
Loading…
Reference in New Issue