검색 필터기능 수정사항

This commit is contained in:
kjs 2025-09-23 14:26:18 +09:00
parent e653effac0
commit da9985cd24
11 changed files with 1537 additions and 318 deletions

View File

@ -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, "''")}'`
);
}
}
}

View File

@ -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>

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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(" 필터 추가 시작:", {

View File

@ -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]);
// 미리보기용 샘플 데이터

View File

@ -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 ? (

View File

@ -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>

View File

@ -59,10 +59,7 @@ export const TableListDefinition = createComponentDefinition({
// 필터 설정
filter: {
enabled: true,
quickSearch: true,
showColumnSelector: true, // 검색컬럼 선택기 표시 기본값
advancedFilter: false,
filterableColumns: [],
filters: [], // 사용자가 설정할 필터 목록
},
// 액션 설정

View File

@ -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;
}>;
}

View File

@ -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: 범위)
}
// 데이터 테이블 페이지네이션 설정