Merge branch 'mhkim-node' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node

This commit is contained in:
kjs 2026-03-25 11:03:41 +09:00
commit d5650c5797
19 changed files with 504 additions and 195 deletions

View File

@ -561,6 +561,34 @@ export class EntityJoinController {
});
}
}
/**
* ( )
* GET /api/table-management/tables/:tableName/column-values/:columnName
*/
async getColumnUniqueValues(req: Request, res: Response): Promise<void> {
try {
const { tableName, columnName } = req.params;
const companyCode = (req as any).user?.companyCode;
const data = await tableManagementService.getColumnDistinctValues(
tableName,
columnName,
companyCode
);
res.status(200).json({
success: true,
data,
});
} catch (error) {
logger.error(`컬럼 고유값 조회 실패: ${req.params.tableName}.${req.params.columnName}`, error);
res.status(500).json({
success: false,
message: "컬럼 고유값 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
}
export const entityJoinController = new EntityJoinController();

View File

@ -55,6 +55,15 @@ router.get(
entityJoinController.getTableDataWithJoins.bind(entityJoinController)
);
/**
* ( )
* GET /api/table-management/tables/:tableName/column-values/:columnName
*/
router.get(
"/tables/:tableName/column-values/:columnName",
entityJoinController.getColumnUniqueValues.bind(entityJoinController)
);
// ========================================
// 🎯 Entity 조인 설정 관리
// ========================================

View File

@ -211,7 +211,8 @@ class TableCategoryValueService {
created_at AS "createdAt",
updated_at AS "updatedAt",
created_by AS "createdBy",
updated_by AS "updatedBy"
updated_by AS "updatedBy",
path
FROM category_values
WHERE table_name = $1
AND column_name = $2
@ -1441,7 +1442,7 @@ class TableCategoryValueService {
// is_active 필터 제거: 비활성화된 카테고리도 라벨로 표시되어야 함
if (companyCode === "*") {
query = `
SELECT DISTINCT value_code, value_label
SELECT DISTINCT value_code, value_label, path
FROM category_values
WHERE value_code IN (${placeholders1})
`;
@ -1449,7 +1450,7 @@ class TableCategoryValueService {
} else {
const companyIdx = n + 1;
query = `
SELECT DISTINCT value_code, value_label
SELECT DISTINCT value_code, value_label, path
FROM category_values
WHERE value_code IN (${placeholders1})
AND (company_code = $${companyIdx} OR company_code = '*')
@ -1460,10 +1461,15 @@ class TableCategoryValueService {
const result = await pool.query(query, params);
// { [code]: label } 형태로 변환 (중복 시 첫 번째 결과 우선)
// path가 있고 '/'를 포함하면(depth>1) 전체 경로를 ' > ' 구분자로 표시
const labels: Record<string, string> = {};
for (const row of result.rows) {
if (!labels[row.value_code]) {
labels[row.value_code] = row.value_label;
if (row.path && row.path.includes('/')) {
labels[row.value_code] = row.path.replace(/\//g, ' > ');
} else {
labels[row.value_code] = row.value_label;
}
}
}

View File

@ -3412,6 +3412,31 @@ export class TableManagementService {
case "is_not_null":
filterConditions.push(`${safeColumn} IS NOT NULL`);
break;
case "not_contains":
filterConditions.push(
`${safeColumn}::text NOT LIKE '%${String(value).replace(/'/g, "''")}%'`
);
break;
case "greater_than":
filterConditions.push(
`(${safeColumn})::numeric > ${parseFloat(String(value))}`
);
break;
case "less_than":
filterConditions.push(
`(${safeColumn})::numeric < ${parseFloat(String(value))}`
);
break;
case "greater_or_equal":
filterConditions.push(
`(${safeColumn})::numeric >= ${parseFloat(String(value))}`
);
break;
case "less_or_equal":
filterConditions.push(
`(${safeColumn})::numeric <= ${parseFloat(String(value))}`
);
break;
}
}
@ -3428,6 +3453,89 @@ export class TableManagementService {
}
}
// 🆕 filterGroups 처리 (런타임 필터 빌더 - 그룹별 AND/OR 지원)
if (
options.dataFilter &&
options.dataFilter.filterGroups &&
options.dataFilter.filterGroups.length > 0
) {
const groupConditions: string[] = [];
for (const group of options.dataFilter.filterGroups) {
if (!group.conditions || group.conditions.length === 0) continue;
const conditions: string[] = [];
for (const condition of group.conditions) {
const { columnName, operator, value } = condition;
if (!columnName) continue;
const safeCol = `main."${columnName}"`;
switch (operator) {
case "equals":
conditions.push(`${safeCol}::text = '${String(value).replace(/'/g, "''")}'`);
break;
case "not_equals":
conditions.push(`${safeCol}::text != '${String(value).replace(/'/g, "''")}'`);
break;
case "contains":
conditions.push(`${safeCol}::text LIKE '%${String(value).replace(/'/g, "''")}%'`);
break;
case "not_contains":
conditions.push(`${safeCol}::text NOT LIKE '%${String(value).replace(/'/g, "''")}%'`);
break;
case "starts_with":
conditions.push(`${safeCol}::text LIKE '${String(value).replace(/'/g, "''")}%'`);
break;
case "ends_with":
conditions.push(`${safeCol}::text LIKE '%${String(value).replace(/'/g, "''")}'`);
break;
case "greater_than":
conditions.push(`(${safeCol})::numeric > ${parseFloat(String(value))}`);
break;
case "less_than":
conditions.push(`(${safeCol})::numeric < ${parseFloat(String(value))}`);
break;
case "greater_or_equal":
conditions.push(`(${safeCol})::numeric >= ${parseFloat(String(value))}`);
break;
case "less_or_equal":
conditions.push(`(${safeCol})::numeric <= ${parseFloat(String(value))}`);
break;
case "is_null":
conditions.push(`(${safeCol} IS NULL OR ${safeCol}::text = '')`);
break;
case "is_not_null":
conditions.push(`(${safeCol} IS NOT NULL AND ${safeCol}::text != '')`);
break;
case "in": {
const inArr = Array.isArray(value) ? value : [String(value)];
if (inArr.length > 0) {
const vals = inArr.map((v) => `'${String(v).replace(/'/g, "''")}'`).join(", ");
conditions.push(`${safeCol}::text IN (${vals})`);
}
break;
}
}
}
if (conditions.length > 0) {
const logic = group.logic === "OR" ? " OR " : " AND ";
groupConditions.push(`(${conditions.join(logic)})`);
}
}
if (groupConditions.length > 0) {
const groupWhere = groupConditions.join(" AND ");
whereClause = whereClause
? `${whereClause} AND ${groupWhere}`
: groupWhere;
logger.info(`🔍 필터 그룹 적용 (Entity 조인): ${groupWhere}`);
}
}
// 🆕 제외 필터 적용 (다른 테이블에 이미 존재하는 데이터 제외)
if (options.excludeFilter && options.excludeFilter.enabled) {
const {
@ -5391,4 +5499,40 @@ export class TableManagementService {
return [];
}
}
/**
* ( )
*/
async getColumnDistinctValues(
tableName: string,
columnName: string,
companyCode?: string
): Promise<{ value: string; label: string }[]> {
try {
// 테이블명/컬럼명 안전성 검증 (영문, 숫자, 언더스코어만 허용)
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(tableName) || !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(columnName)) {
logger.warn(`잘못된 테이블/컬럼명: ${tableName}.${columnName}`);
return [];
}
let sql = `SELECT DISTINCT "${columnName}"::text as value FROM "${tableName}" WHERE "${columnName}" IS NOT NULL AND "${columnName}"::text != ''`;
const params: any[] = [];
if (companyCode) {
params.push(companyCode);
sql += ` AND "company_code" = $${params.length}`;
}
sql += ` ORDER BY value LIMIT 500`;
const rows = await query<{ value: string }>(sql, params);
return rows.map((row) => ({
value: row.value,
label: row.value,
}));
} catch (error) {
logger.error(`컬럼 고유값 조회 실패: ${tableName}.${columnName}`, error);
return [];
}
}
}

View File

@ -648,24 +648,31 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
);
if (response.data.success && response.data.data) {
// valueCode 및 valueId -> {label, color} 매핑 생성
// valueCode 및 valueId -> {label, color} 매핑 생성 (트리 재귀 평탄화)
const mapping: Record<string, { label: string; color?: string }> = {};
response.data.data.forEach((item: any) => {
// valueCode로 매핑
if (item.valueCode) {
mapping[item.valueCode] = {
label: item.valueLabel,
color: item.color,
};
}
// valueId로도 매핑 (숫자 ID 저장 시 라벨 표시용)
if (item.valueId !== undefined && item.valueId !== null) {
mapping[String(item.valueId)] = {
label: item.valueLabel,
color: item.color,
};
}
});
const flattenCategoryTree = (items: any[], parentLabel: string = "") => {
items.forEach((item: any) => {
const displayLabel = parentLabel
? `${parentLabel} / ${item.valueLabel}`
: item.valueLabel;
if (item.valueCode) {
mapping[item.valueCode] = {
label: displayLabel,
color: item.color,
};
}
if (item.valueId !== undefined && item.valueId !== null) {
mapping[String(item.valueId)] = {
label: displayLabel,
color: item.color,
};
}
if (item.children && Array.isArray(item.children) && item.children.length > 0) {
flattenCategoryTree(item.children, item.valueLabel);
}
});
};
flattenCategoryTree(response.data.data);
mappings[col.columnName] = mapping;
console.log(`✅ 카테고리 매핑 로드 성공 [${col.columnName}]:`, mapping, { menuObjid });
}

View File

@ -92,7 +92,14 @@ const DropdownSelect = forwardRef<
className={cn("w-full", allowClear && hasValue ? "pr-8" : "", className)}
style={style}
>
<SelectValue placeholder={placeholder} />
<span data-slot="select-value">
{(() => {
const val = typeof value === "string" ? value : (value?.[0] ?? "");
const opt = options.find(o => o.value === val);
if (!opt || !val) return placeholder;
return opt.displayLabel || opt.label;
})()}
</span>
</SelectTrigger>
<SelectContent>
{options
@ -139,7 +146,7 @@ const DropdownSelect = forwardRef<
const selectedLabels = useMemo(() => {
return safeOptions
.filter((o) => selectedValues.includes(o.value))
.map((o) => o.label)
.map((o) => o.displayLabel || o.label)
.filter(Boolean) as string[];
}, [selectedValues, safeOptions]);
@ -896,18 +903,23 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>((props, ref) =
// 트리 구조를 평탄화하여 옵션으로 변환
// 🔧 value로 valueCode를 사용 (커스텀 테이블 저장/조회 호환)
const flattenTree = (
items: { valueId: number; valueCode: string; valueLabel: string; children?: any[] }[],
items: { valueId: number; valueCode: string; valueLabel: string; path?: string; children?: any[] }[],
depth: number = 0,
parentLabel: string = "",
): SelectOption[] => {
const result: SelectOption[] = [];
for (const item of items) {
const prefix = depth > 0 ? "\u00A0\u00A0\u00A0".repeat(depth) + "└ " : "";
const displayLabel = parentLabel
? `${parentLabel} / ${item.valueLabel}`
: item.valueLabel;
result.push({
value: item.valueCode, // 🔧 valueCode를 value로 사용
label: prefix + item.valueLabel,
displayLabel,
});
if (item.children && item.children.length > 0) {
result.push(...flattenTree(item.children, depth + 1));
result.push(...flattenTree(item.children, depth + 1, item.valueLabel));
}
}
return result;

View File

@ -372,22 +372,27 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
if (response.data.success && response.data.data) {
const mapping: Record<string, { label: string; color?: string }> = {};
response.data.data.forEach((item: any) => {
// API 응답 형식: valueCode, valueLabel (camelCase)
const code = item.valueCode || item.value_code || item.category_code || item.code || item.value;
const label = item.valueLabel || item.value_label || item.category_name || item.name || item.label || code;
// color가 null/undefined/"none"이면 undefined로 유지 (배지 없음)
const rawColor = item.color ?? item.badge_color;
const color = (rawColor && rawColor !== "none") ? rawColor : undefined;
mapping[code] = { label, color };
});
const flattenCategoryTree = (items: any[], parentLabel: string = "") => {
items.forEach((item: any) => {
const code = item.valueCode || item.value_code || item.category_code || item.code || item.value;
const rawLabel = item.valueLabel || item.value_label || item.category_name || item.name || item.label || code;
const displayLabel = parentLabel ? `${parentLabel} / ${rawLabel}` : rawLabel;
const rawColor = item.color ?? item.badge_color;
const color = (rawColor && rawColor !== "none") ? rawColor : undefined;
mapping[code] = { label: displayLabel, color };
if (item.children && Array.isArray(item.children) && item.children.length > 0) {
flattenCategoryTree(item.children, rawLabel);
}
});
};
flattenCategoryTree(response.data.data);
mappings[columnName] = mapping;
}
} catch (error) {
// 카테고리 매핑 로드 실패 시 무시
}
}
setCategoryMappings(mappings);
}
} catch (error) {

View File

@ -223,7 +223,9 @@ export const CategorySelectComponent: React.FC<
key={categoryValue.valueId}
value={categoryValue.valueCode}
>
{categoryValue.valueLabel}
{categoryValue.path && categoryValue.path.includes('/')
? categoryValue.path.replace(/\//g, ' / ')
: categoryValue.valueLabel}
</SelectItem>
))}
</SelectContent>

View File

@ -258,7 +258,9 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
const activeValues = response.data.filter((v: any) => v.isActive !== false);
const options = activeValues.map((v: any) => ({
value: v.valueCode,
label: v.valueLabel || v.valueCode,
label: (v.path && v.path.includes('/'))
? v.path.replace(/\//g, ' / ')
: (v.valueLabel || v.valueCode),
}));
setCategoryOptions(options);
}

View File

@ -1613,12 +1613,20 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
if (response.data.success && response.data.data) {
const valueMap: Record<string, { label: string; color?: string }> = {};
response.data.data.forEach((item: any) => {
valueMap[item.value_code || item.valueCode] = {
label: item.value_label || item.valueLabel,
color: item.color,
};
});
const flattenCategoryTree = (items: any[], parentLabel: string = "") => {
items.forEach((item: any) => {
const rawLabel = item.value_label || item.valueLabel;
const displayLabel = parentLabel ? `${parentLabel} / ${rawLabel}` : rawLabel;
valueMap[item.value_code || item.valueCode] = {
label: displayLabel,
color: item.color,
};
if (item.children && Array.isArray(item.children) && item.children.length > 0) {
flattenCategoryTree(item.children, rawLabel);
}
});
};
flattenCategoryTree(response.data.data);
mappings[columnName] = valueMap;
console.log(`✅ 좌측 카테고리 매핑 로드 [${columnName}]:`, valueMap);
}
@ -1675,12 +1683,20 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
if (response.data.success && response.data.data) {
const valueMap: Record<string, { label: string; color?: string }> = {};
response.data.data.forEach((item: any) => {
valueMap[item.value_code || item.valueCode] = {
label: item.value_label || item.valueLabel,
color: item.color,
};
});
const flattenCategoryTree = (items: any[], parentLabel: string = "") => {
items.forEach((item: any) => {
const rawLabel = item.value_label || item.valueLabel;
const displayLabel = parentLabel ? `${parentLabel} / ${rawLabel}` : rawLabel;
valueMap[item.value_code || item.valueCode] = {
label: displayLabel,
color: item.color,
};
if (item.children && Array.isArray(item.children) && item.children.length > 0) {
flattenCategoryTree(item.children, rawLabel);
}
});
};
flattenCategoryTree(response.data.data);
// 조인된 테이블의 경우 "테이블명.컬럼명" 형태로 저장
const mappingKey = tableName === rightTableName ? columnName : `${tableName}.${columnName}`;

View File

@ -1337,7 +1337,8 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
if (result.success && Array.isArray(result.data) && result.data.length > 0) {
for (const item of result.data) {
if (item.valueCode && item.valueLabel) {
labelMap[item.valueCode] = item.valueLabel;
// 계층 경로 표시: path가 있고 '/'를 포함하면 전체 경로를 ' > ' 구분자로 표시
labelMap[item.valueCode] = item.path && item.path.includes('/') ? item.path.replace(/\//g, ' > ') : item.valueLabel;
}
}
}

View File

@ -1287,22 +1287,25 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const apiClient = (await import("@/lib/api/client")).apiClient;
// 트리 구조를 평탄화하는 헬퍼 함수 (메인 테이블 + 엔티티 조인 공통 사용)
const flattenTree = (items: any[], mapping: Record<string, { label: string; color?: string }>) => {
const flattenTree = (items: any[], mapping: Record<string, { label: string; color?: string }>, parentLabel: string = "") => {
items.forEach((item: any) => {
const displayLabel = parentLabel
? `${parentLabel} / ${item.valueLabel}`
: item.valueLabel;
if (item.valueCode) {
mapping[String(item.valueCode)] = {
label: item.valueLabel,
label: displayLabel,
color: item.color,
};
}
if (item.valueId !== undefined && item.valueId !== null) {
mapping[String(item.valueId)] = {
label: item.valueLabel,
label: displayLabel,
color: item.color,
};
}
if (item.children && Array.isArray(item.children) && item.children.length > 0) {
flattenTree(item.children, mapping);
flattenTree(item.children, mapping, item.valueLabel);
}
});
};

View File

@ -372,22 +372,27 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
if (response.data.success && response.data.data) {
const mapping: Record<string, { label: string; color?: string }> = {};
response.data.data.forEach((item: any) => {
// API 응답 형식: valueCode, valueLabel (camelCase)
const code = item.valueCode || item.value_code || item.category_code || item.code || item.value;
const label = item.valueLabel || item.value_label || item.category_name || item.name || item.label || code;
// color가 null/undefined/"none"이면 undefined로 유지 (배지 없음)
const rawColor = item.color ?? item.badge_color;
const color = (rawColor && rawColor !== "none") ? rawColor : undefined;
mapping[code] = { label, color };
});
const flattenCategoryTree = (items: any[], parentLabel: string = "") => {
items.forEach((item: any) => {
const code = item.valueCode || item.value_code || item.category_code || item.code || item.value;
const rawLabel = item.valueLabel || item.value_label || item.category_name || item.name || item.label || code;
const displayLabel = parentLabel ? `${parentLabel} / ${rawLabel}` : rawLabel;
const rawColor = item.color ?? item.badge_color;
const color = (rawColor && rawColor !== "none") ? rawColor : undefined;
mapping[code] = { label: displayLabel, color };
if (item.children && Array.isArray(item.children) && item.children.length > 0) {
flattenCategoryTree(item.children, rawLabel);
}
});
};
flattenCategoryTree(response.data.data);
mappings[columnName] = mapping;
}
} catch (error) {
// 카테고리 매핑 로드 실패 시 무시
}
}
setCategoryMappings(mappings);
}
} catch (error) {

View File

@ -458,7 +458,7 @@ export function ItemRoutingComponent({
{/* ════ 품목 추가 다이얼로그 (테이블 형태 + 검색) ════ */}
<Dialog open={addDialogOpen} onOpenChange={setAddDialogOpen}>
<DialogContent className="max-w-[95vw]" style={{ maxWidth: `min(95vw, ${config.addModalMaxWidth || "600px"})` }}>
<DialogContent className="!max-w-none" style={{ width: `min(100%, 95vw, ${config.addModalMaxWidth || "600px"})` }}>
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm">

View File

@ -3896,13 +3896,13 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
const canDragLeftGroupedColumns = !isDesignMode && columnsToShow.length > 1;
if (groupedLeftData.length > 0) {
return (
<div className="overflow-auto">
<>
{groupedLeftData.map((group, groupIdx) => (
<div key={groupIdx} className="mb-4">
<div className="bg-muted px-3 py-2 text-sm font-semibold">
{group.groupKey} ({group.count})
</div>
<table className="divide-border min-w-full divide-y">
<table className="divide-border min-w-full divide-y" style={{ minWidth: `${Math.max(columnsToShow.length * 120, 400)}px` }}>
<thead className="bg-muted">
<tr>
{columnsToShow.map((col, idx) => {
@ -4016,7 +4016,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
</table>
</div>
))}
</div>
</>
);
}
@ -4027,8 +4027,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
componentConfig.leftPanel?.showDelete !== false);
const canDragLeftColumns = !isDesignMode && columnsToShow.length > 1;
return (
<div className="overflow-auto">
<table className="divide-border min-w-full divide-y">
<table className="divide-border min-w-full divide-y" style={{ minWidth: `${Math.max(columnsToShow.length * 120, 400)}px` }}>
<thead className="bg-muted sticky top-0 z-10">
<tr>
{columnsToShow.map((col, idx) => {
@ -4135,7 +4134,6 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
})}
</tbody>
</table>
</div>
);
})()
)}
@ -5189,19 +5187,13 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
columnsToShow = [...keyCols, ...applyRuntimeOrder(dataCols, "main")];
}
// 컬럼 너비 합계 계산 (작업 컬럼 제외, 100% 초과 시 스크롤)
const rightTotalColWidth = columnsToShow.reduce((sum, col) => {
const w = col.width && col.width <= 100 ? col.width : 0;
return sum + w;
}, 0);
const rightConfigColumnStart = columnsToShow.filter((c: any) => c._isKeyColumn).length;
const canDragRightColumns = displayColumns.length > 0;
return (
<div className="flex h-full w-full flex-col">
<div className="min-h-0 flex-1 overflow-auto">
<table className="min-w-full">
<table className="min-w-full" style={{ minWidth: `${Math.max(columnsToShow.length * 120, 400)}px` }}>
<thead className="sticky top-0 z-10">
<tr className="border-border/60 border-b-2">
{columnsToShow.map((col, idx) => {
@ -5221,7 +5213,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
isDragging && "opacity-50",
)}
style={{
width: col.width && col.width <= 100 ? `${col.width}%` : "auto",
minWidth: col.width ? `${col.width}px` : "80px",
textAlign: col.align || "left",
}}
draggable={isDraggable}
@ -5387,14 +5379,14 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
return filteredData.length > 0 ? (
<div className="flex h-full w-full flex-col">
<div className="min-h-0 flex-1 overflow-auto">
<table className="min-w-full text-sm">
<table className="min-w-full text-sm" style={{ minWidth: `${Math.max(columnsToDisplay.length * 120, 400)}px` }}>
<thead className="bg-background sticky top-0 z-10">
<tr className="border-border/60 border-b-2">
{columnsToDisplay.map((col) => (
<th
key={col.name}
className="text-muted-foreground px-3 py-[7px] text-left text-[9px] font-bold tracking-[0.04em] whitespace-nowrap uppercase"
style={{ width: col.width && col.width <= 100 ? `${col.width}%` : "auto" }}
style={{ minWidth: col.width ? `${col.width}px` : "80px" }}
>
{col.label}
</th>

View File

@ -369,6 +369,8 @@ import {
Trash2,
Lock,
GripVertical,
Loader2,
Search,
} from "lucide-react";
import * as XLSX from "xlsx";
import { FileText, ChevronRightIcon } from "lucide-react";
@ -810,17 +812,25 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const [headerFilters, setHeaderFilters] = useState<Record<string, Set<string>>>({});
const [openFilterColumn, setOpenFilterColumn] = useState<string | null>(null);
// 🆕 서버에서 가져온 컬럼별 고유값 캐시 (헤더 필터 드롭다운용)
const [asyncColumnUniqueValues, setAsyncColumnUniqueValues] = useState<
Record<string, { value: string; label: string }[]>
>({});
const [loadingFilterColumn, setLoadingFilterColumn] = useState<string | null>(null);
const [filterSearchTerm, setFilterSearchTerm] = useState("");
// 🆕 Filter Builder (고급 필터) 관련 상태 - filteredData보다 먼저 정의해야 함
const [filterGroups, setFilterGroups] = useState<FilterGroup[]>([]);
// 🆕 joinColumnMapping - filteredData에서 사용하므로 먼저 정의해야 함
const [joinColumnMapping, setJoinColumnMapping] = useState<Record<string, string>>({});
// 🆕 분할 패널에서 우측에 이미 추가된 항목 필터링 (좌측 테이블에만 적용) + 헤더 필터
// 분할 패널에서 우측에 이미 추가된 항목 필터링 (좌측 테이블에만 적용)
// 헤더 필터와 필터 빌더는 서버사이드에서 처리됨 (fetchTableDataInternal에서 API 파라미터로 전달)
const filteredData = useMemo(() => {
let result = data;
// 1. 분할 패널 좌측에 있고, 우측에 추가된 항목이 있는 경우 필터링
// 분할 패널 좌측에 있고, 우측에 추가된 항목이 있는 경우 필터링
if (splitPanelPosition === "left" && splitPanelContext?.addedItemIds && splitPanelContext.addedItemIds.size > 0) {
const addedIds = splitPanelContext.addedItemIds;
result = result.filter((row) => {
@ -829,78 +839,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
});
}
// 2. 헤더 필터 적용 (joinColumnMapping 사용 - 조인된 컬럼과 일치해야 함)
if (Object.keys(headerFilters).length > 0) {
result = result.filter((row) => {
return Object.entries(headerFilters).every(([columnName, values]) => {
if (values.size === 0) return true;
// joinColumnMapping을 사용하여 조인된 컬럼명 확인
const mappedColumnName = joinColumnMapping[columnName] || columnName;
// 여러 가능한 컬럼명 시도 (mappedColumnName 우선)
const cellValue = row[mappedColumnName] ?? row[columnName] ?? row[columnName.toLowerCase()] ?? row[columnName.toUpperCase()];
const cellStr = cellValue !== null && cellValue !== undefined ? String(cellValue) : "";
return values.has(cellStr);
});
});
}
// 3. 🆕 Filter Builder 적용
if (filterGroups.length > 0) {
result = result.filter((row) => {
return filterGroups.every((group) => {
const validConditions = group.conditions.filter(
(c) => c.column && (c.operator === "isEmpty" || c.operator === "isNotEmpty" || c.value),
);
if (validConditions.length === 0) return true;
const evaluateCondition = (value: any, condition: (typeof group.conditions)[0]): boolean => {
const strValue = value !== null && value !== undefined ? String(value).toLowerCase() : "";
const condValue = condition.value.toLowerCase();
switch (condition.operator) {
case "equals":
return strValue === condValue;
case "notEquals":
return strValue !== condValue;
case "contains":
return strValue.includes(condValue);
case "notContains":
return !strValue.includes(condValue);
case "startsWith":
return strValue.startsWith(condValue);
case "endsWith":
return strValue.endsWith(condValue);
case "greaterThan":
return parseFloat(strValue) > parseFloat(condValue);
case "lessThan":
return parseFloat(strValue) < parseFloat(condValue);
case "greaterOrEqual":
return parseFloat(strValue) >= parseFloat(condValue);
case "lessOrEqual":
return parseFloat(strValue) <= parseFloat(condValue);
case "isEmpty":
return strValue === "" || value === null || value === undefined;
case "isNotEmpty":
return strValue !== "" && value !== null && value !== undefined;
default:
return true;
}
};
if (group.logic === "AND") {
return validConditions.every((cond) => evaluateCondition(row[cond.column], cond));
} else {
return validConditions.some((cond) => evaluateCondition(row[cond.column], cond));
}
});
});
}
return result;
}, [data, splitPanelPosition, splitPanelContext?.addedItemIds, headerFilters, filterGroups, joinColumnMapping]);
}, [data, splitPanelPosition, splitPanelContext?.addedItemIds]);
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(0);
@ -1650,16 +1590,19 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// 트리 구조를 평탄화하는 헬퍼 함수 (메인 테이블 + 엔티티 조인 공통 사용)
// valueCode만 키로 사용 (valueId까지 넣으면 같은 라벨이 2번 나옴)
const flattenTree = (items: any[], mapping: Record<string, { label: string; color?: string }>) => {
const flattenTree = (items: any[], mapping: Record<string, { label: string; color?: string }>, parentLabel: string = "") => {
items.forEach((item: any) => {
if (item.valueCode) {
const displayLabel = parentLabel
? `${parentLabel} / ${item.valueLabel}`
: item.valueLabel;
mapping[String(item.valueCode)] = {
label: item.valueLabel,
label: displayLabel,
color: item.color,
};
}
if (item.children && Array.isArray(item.children) && item.children.length > 0) {
flattenTree(item.children, mapping);
flattenTree(item.children, mapping, item.valueLabel);
}
});
};
@ -1956,11 +1899,32 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
};
}
// 검색 필터, 연결 필터, RelatedDataButtons 필터 병합
// 🆕 헤더 필터를 서버 필터 형식으로 변환
const headerFilterValues: Record<string, any> = {};
Object.entries(headerFilters).forEach(([columnName, values]) => {
if (values.size > 0) {
const mappedCol = joinColumnMapping[columnName] || columnName;
headerFilterValues[mappedCol] = { value: Array.from(values), operator: "in" };
}
});
// 🆕 필터 빌더를 서버 필터 형식으로 변환
const filterBuilderValues: Record<string, any> = {};
filterGroups.forEach((group) => {
group.conditions.forEach((cond) => {
if (cond.column && (cond.operator === "isEmpty" || cond.operator === "isNotEmpty" || cond.value)) {
filterBuilderValues[cond.column] = { value: cond.value, operator: cond.operator };
}
});
});
// 검색 필터, 연결 필터, RelatedDataButtons 필터, 헤더 필터, 필터 빌더 병합
const filters = {
...(Object.keys(searchValues).length > 0 ? searchValues : {}),
...linkedFilterValues,
...relatedButtonFilterValues, // 🆕 RelatedDataButtons 필터 추가
...headerFilterValues, // 🆕 헤더 필터 추가
...filterBuilderValues, // 🆕 필터 빌더 추가
};
const hasFilters = Object.keys(filters).length > 0;
@ -2137,6 +2101,10 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
isRelatedButtonTarget,
// 🆕 프리뷰용 회사 코드 오버라이드
companyCode,
// 🆕 서버사이드 헤더 필터 / 필터 빌더
headerFilters,
filterGroups,
joinColumnMapping,
]);
const fetchTableDataDebounced = useCallback(
@ -2594,6 +2562,11 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
return result;
}, [data, tableConfig.columns, joinColumnMapping]);
// 데이터 변경 시 헤더 필터 드롭다운 캐시 초기화
useEffect(() => {
setAsyncColumnUniqueValues({});
}, [data]);
// 🆕 헤더 필터 토글
const toggleHeaderFilter = useCallback((columnName: string, value: string) => {
setHeaderFilters((prev) => {
@ -6122,11 +6095,40 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
<span className="text-primary">{sortDirection === "asc" ? "↑" : "↓"}</span>
)}
{/* 🆕 헤더 필터 버튼 */}
{tableConfig.headerFilter !== false &&
columnUniqueValues[column.columnName]?.length > 0 && (
{tableConfig.headerFilter !== false && (
<Popover
open={openFilterColumn === column.columnName}
onOpenChange={(open) => setOpenFilterColumn(open ? column.columnName : null)}
onOpenChange={(open) => {
if (open) {
setOpenFilterColumn(column.columnName);
setFilterSearchTerm("");
// 서버에서 고유값 가져오기
if (!asyncColumnUniqueValues[column.columnName]) {
setLoadingFilterColumn(column.columnName);
const mappedCol = joinColumnMapping[column.columnName] || column.columnName;
const tableName = tableConfig.selectedTable;
if (tableName) {
import("@/lib/api/client").then(({ apiClient }) => {
apiClient
.get(`/table-management/tables/${tableName}/column-values/${mappedCol}`)
.then((res) => {
const values = (res.data?.data || []).map((v: any) => ({
value: String(v.value ?? ""),
label: String(v.label ?? v.value ?? ""),
}));
setAsyncColumnUniqueValues((prev) => ({ ...prev, [column.columnName]: values }));
})
.catch(() => {
setAsyncColumnUniqueValues((prev) => ({ ...prev, [column.columnName]: [] }));
})
.finally(() => setLoadingFilterColumn(null));
});
}
}
} else {
setOpenFilterColumn(null);
}
}}
>
<PopoverTrigger asChild>
<button
@ -6146,7 +6148,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
</button>
</PopoverTrigger>
<PopoverContent
className="w-48 p-2"
className="w-56 p-2"
align="start"
onClick={(e) => e.stopPropagation()}
>
@ -6164,35 +6166,66 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
</button>
)}
</div>
{/* 검색 입력 */}
<div className="relative">
<Search className="text-muted-foreground absolute top-1/2 left-2 h-3 w-3 -translate-y-1/2" />
<input
type="text"
value={filterSearchTerm}
onChange={(e) => setFilterSearchTerm(e.target.value)}
placeholder="검색..."
className="border-input bg-background w-full rounded border py-1 pr-2 pl-7 text-xs"
onClick={(e) => e.stopPropagation()}
/>
</div>
<div className="max-h-48 space-y-1 overflow-y-auto">
{columnUniqueValues[column.columnName]?.slice(0, 50).map((val) => {
const isSelected = headerFilters[column.columnName]?.has(val);
return (
<div
key={val}
className={cn(
"hover:bg-muted flex cursor-pointer items-center gap-2 rounded px-2 py-1 text-xs",
isSelected && "bg-primary/10",
)}
onClick={() => toggleHeaderFilter(column.columnName, val)}
>
<div
className={cn(
"flex h-4 w-4 items-center justify-center rounded border",
isSelected ? "bg-primary border-primary" : "border-input",
)}
>
{isSelected && <Check className="text-primary-foreground h-3 w-3" />}
</div>
<span className="truncate">{val || "(빈 값)"}</span>
</div>
);
})}
{(columnUniqueValues[column.columnName]?.length || 0) > 50 && (
<div className="text-muted-foreground px-2 py-1 text-xs">
... {(columnUniqueValues[column.columnName]?.length || 0) - 50}
{loadingFilterColumn === column.columnName ? (
<div className="flex items-center justify-center py-4">
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
<span className="text-muted-foreground ml-2 text-xs">...</span>
</div>
)}
) : (asyncColumnUniqueValues[column.columnName] || []).length === 0 ? (
<div className="text-muted-foreground px-2 py-2 text-center text-xs">
</div>
) : (() => {
const filteredItems = (asyncColumnUniqueValues[column.columnName] || []).filter((item) => {
if (!filterSearchTerm) return true;
const term = filterSearchTerm.toLowerCase();
return item.value.toLowerCase().includes(term) || item.label.toLowerCase().includes(term);
});
return filteredItems.length === 0 ? (
<div className="text-muted-foreground px-2 py-2 text-center text-xs">
</div>
) : (
<>
{filteredItems.map((item) => {
const isSelected = headerFilters[column.columnName]?.has(item.value);
return (
<div
key={item.value}
className={cn(
"hover:bg-muted flex cursor-pointer items-center gap-2 rounded px-2 py-1 text-xs",
isSelected && "bg-primary/10",
)}
onClick={() => toggleHeaderFilter(column.columnName, item.value)}
>
<div
className={cn(
"flex h-4 w-4 items-center justify-center rounded border",
isSelected ? "bg-primary border-primary" : "border-input",
)}
>
{isSelected && <Check className="text-primary-foreground h-3 w-3" />}
</div>
<span className="truncate">{item.label || item.value || "(빈 값)"}</span>
</div>
);
})}
</>
);
})()}
</div>
</div>
</PopoverContent>

View File

@ -3,7 +3,7 @@
import React, { useState, useEffect, useRef, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Settings, X, ChevronsUpDown } from "lucide-react";
import { Settings, X, ChevronsUpDown, Search } from "lucide-react";
import { useTableOptions } from "@/contexts/TableOptionsContext";
import { useTableSearchWidgetHeight } from "@/contexts/TableSearchWidgetHeightContext";
import { useActiveTab } from "@/contexts/ActiveTabContext";
@ -77,6 +77,10 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
const [selectOptions, setSelectOptions] = useState<Record<string, Array<{ label: string; value: string }>>>({});
// 선택된 값의 라벨 저장 (데이터 없을 때도 라벨 유지)
const [selectedLabels, setSelectedLabels] = useState<Record<string, string>>({});
// select 필터 드롭다운 내 검색 텍스트
const [selectSearchTexts, setSelectSearchTexts] = useState<Record<string, string>>({});
// select 필터 Popover 열림 상태
const [selectPopoverOpen, setSelectPopoverOpen] = useState<Record<string, boolean>>({});
// 높이 감지를 위한 ref
const containerRef = useRef<HTMLDivElement>(null);
@ -695,6 +699,14 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
[] as Array<{ value: string; label: string }>,
);
// 검색 텍스트로 필터링
const searchText = selectSearchTexts[filter.columnName] || "";
const filteredOptions = searchText
? uniqueOptions.filter((option) =>
option.label.toLowerCase().includes(searchText.toLowerCase())
)
: uniqueOptions;
// 항상 다중선택 모드
const selectedValues: string[] = Array.isArray(value) ? value : value ? [value] : [];
@ -719,7 +731,15 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
};
return (
<Popover>
<Popover
open={selectPopoverOpen[filter.columnName] || false}
onOpenChange={(open) => {
setSelectPopoverOpen((prev) => ({ ...prev, [filter.columnName]: open }));
if (!open) {
setSelectSearchTexts((prev) => ({ ...prev, [filter.columnName]: "" }));
}
}}
>
<PopoverTrigger asChild>
<Button
variant="outline"
@ -735,12 +755,34 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<div className="border-b p-2">
<div className="relative">
<Search className="text-muted-foreground absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2" />
<Input
type="text"
placeholder="검색..."
value={selectSearchTexts[filter.columnName] || ""}
onChange={(e) =>
setSelectSearchTexts((prev) => ({
...prev,
[filter.columnName]: e.target.value,
}))
}
onKeyDown={(e) => { if (e.key === "Enter") e.preventDefault(); }}
className="h-8 pl-8 text-xs focus-visible:ring-0 focus-visible:ring-offset-0 sm:text-sm"
style={{ outline: "none", boxShadow: "none" }}
autoFocus
/>
</div>
</div>
<div className="max-h-60 overflow-auto">
{uniqueOptions.length === 0 ? (
<div className="text-muted-foreground px-3 py-2 text-xs"> </div>
{filteredOptions.length === 0 ? (
<div className="text-muted-foreground px-3 py-2 text-xs">
{searchText ? "검색 결과 없음" : "옵션 없음"}
</div>
) : (
<div className="p-1">
{uniqueOptions.map((option, index) => (
{filteredOptions.map((option, index) => (
<div
key={`${filter.columnName}-multi-${option.value}-${index}`}
className="hover:bg-accent flex cursor-pointer items-center space-x-2 rounded-sm px-2 py-1.5"

View File

@ -15,6 +15,7 @@ export interface TableCategoryValue {
// 계층 구조
parentValueId?: number;
depth?: number;
path?: string;
// 추가 정보
description?: string;

View File

@ -137,6 +137,7 @@ export type V2SelectSource = "static" | "code" | "db" | "api" | "entity" | "cate
export interface SelectOption {
value: string;
label: string;
displayLabel?: string;
}
/**