Merge branch 'mhkim-node' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node
This commit is contained in:
commit
d5650c5797
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 조인 설정 관리
|
||||
// ========================================
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ export interface TableCategoryValue {
|
|||
// 계층 구조
|
||||
parentValueId?: number;
|
||||
depth?: number;
|
||||
path?: string;
|
||||
|
||||
// 추가 정보
|
||||
description?: string;
|
||||
|
|
|
|||
|
|
@ -137,6 +137,7 @@ export type V2SelectSource = "static" | "code" | "db" | "api" | "entity" | "cate
|
|||
export interface SelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
displayLabel?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Reference in New Issue