카테고리값 자동감지

This commit is contained in:
kjs
2026-01-12 16:08:02 +09:00
parent 9cc5bbbf05
commit 87189c792e
7 changed files with 365 additions and 135 deletions

View File

@@ -43,7 +43,7 @@ import {
} from "lucide-react";
import { tableTypeApi } from "@/lib/api/screen";
import { commonCodeApi } from "@/lib/api/commonCode";
import { getCurrentUser, UserInfo } from "@/lib/api/client";
import { apiClient, getCurrentUser, UserInfo } from "@/lib/api/client";
import { useAuth } from "@/hooks/useAuth";
import { DataTableComponent, DataTableColumn, DataTableFilter } from "@/types/screen-legacy-backup";
import { cn } from "@/lib/utils";
@@ -101,11 +101,7 @@ const CascadingDropdownInForm: React.FC<CascadingDropdownInFormProps> = ({
const isDisabled = !parentValue || loading;
return (
<Select
value={value || ""}
onValueChange={(newValue) => onChange?.(newValue)}
disabled={isDisabled}
>
<Select value={value || ""} onValueChange={(newValue) => onChange?.(newValue)} disabled={isDisabled}>
<SelectTrigger className={className}>
{loading ? (
<div className="flex items-center gap-2">
@@ -188,7 +184,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
const splitPanelContext = useSplitPanelContext(); // 분할 패널 컨텍스트
const screenContext = useScreenContextOptional(); // 화면 컨텍스트 (좌측/우측 위치 확인용)
const splitPanelPosition = screenContext?.splitPanelPosition; // 분할 패널 내 위치
// URL에서 menuObjid 가져오기 (카테고리 값 조회 시 필요)
const searchParams = useSearchParams();
const menuObjid = useMemo(() => {
@@ -198,7 +194,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
const urlMenuObjid = searchParams.get("menuObjid");
return urlMenuObjid ? parseInt(urlMenuObjid) : undefined;
}, [screenContext?.menuObjid, searchParams]);
const [data, setData] = useState<Record<string, any>[]>([]);
const [loading, setLoading] = useState(false);
const [searchValues, setSearchValues] = useState<Record<string, any>>({});
@@ -210,7 +206,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
const hasInitializedWidthsRef = useRef(false);
const columnRefs = useRef<Record<string, HTMLTableCellElement | null>>({});
const isResizingRef = useRef(false);
// TableOptions 상태
const [filters, setFilters] = useState<TableFilter[]>([]);
const [grouping, setGrouping] = useState<string[]>([]);
@@ -247,14 +243,19 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
const [columnLabels, setColumnLabels] = useState<Record<string, string>>({}); // 컬럼명 -> 라벨 매핑
// 카테고리 값 매핑 캐시 (컬럼명 -> {코드 -> {라벨, 색상}})
const [categoryMappings, setCategoryMappings] = useState<Record<string, Record<string, { label: string; color?: string }>>>({});
const [categoryMappings, setCategoryMappings] = useState<
Record<string, Record<string, { label: string; color?: string }>>
>({});
// 카테고리 코드 라벨 캐시 (CATEGORY_* 코드 -> 라벨)
const [categoryCodeLabels, setCategoryCodeLabels] = useState<Record<string, string>>({});
// 테이블 등록 (Context에 등록)
const tableId = `datatable-${component.id}`;
useEffect(() => {
if (!component.tableName || !component.columns) return;
registerTable({
tableId,
label: component.title || "데이터 테이블",
@@ -331,7 +332,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
useEffect(() => {
const handleRelatedButtonSelect = (event: CustomEvent) => {
const { targetTable, filterColumn, filterValue } = event.detail || {};
// 이 테이블이 대상 테이블인지 확인
if (targetTable === component.tableName) {
console.log("📌 [InteractiveDataTable] RelatedDataButtons 필터 적용:", {
@@ -379,7 +380,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
// menuObjid가 있으면 쿼리 파라미터로 전달 (메뉴별 카테고리 색상 적용)
const queryParams = menuObjid ? `?menuObjid=${menuObjid}` : "";
const response = await apiClient.get(
`/table-categories/${component.tableName}/${col.columnName}/values${queryParams}`
`/table-categories/${component.tableName}/${col.columnName}/values${queryParams}`,
);
if (response.data.success && response.data.data) {
@@ -596,13 +597,13 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
// 없으면 테이블 타입 관리에서 설정된 값 찾기
const tableColumn = tableColumns.find((col) => col.columnName === columnName);
// input_type 우선 사용 (category 등)
const inputType = (tableColumn as any)?.input_type || (tableColumn as any)?.inputType;
if (inputType) {
return inputType;
}
// 없으면 webType 사용
return tableColumn?.webType || "text";
},
@@ -709,19 +710,19 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
let linkedFilterValues: Record<string, any> = {};
let hasLinkedFiltersConfigured = false; // 연결 필터가 설정되어 있는지 여부
let hasSelectedLeftData = false; // 좌측에서 데이터가 선택되었는지 여부
if (splitPanelContext) {
// 연결 필터 설정 여부 확인 (현재 테이블에 해당하는 필터가 있는지)
const linkedFiltersConfig = splitPanelContext.linkedFilters || [];
hasLinkedFiltersConfigured = linkedFiltersConfig.some(
(filter) => filter.targetColumn?.startsWith(component.tableName + ".") ||
filter.targetColumn === component.tableName
(filter) =>
filter.targetColumn?.startsWith(component.tableName + ".") || filter.targetColumn === component.tableName,
);
// 좌측 데이터 선택 여부 확인
hasSelectedLeftData = splitPanelContext.selectedLeftData &&
Object.keys(splitPanelContext.selectedLeftData).length > 0;
hasSelectedLeftData =
splitPanelContext.selectedLeftData && Object.keys(splitPanelContext.selectedLeftData).length > 0;
linkedFilterValues = splitPanelContext.getLinkedFilterValues();
// 현재 테이블에 해당하는 필터만 추출 (테이블명.컬럼명 형식에서)
const tableSpecificFilters: Record<string, any> = {};
@@ -740,7 +741,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
}
linkedFilterValues = tableSpecificFilters;
}
// 🆕 연결 필터가 설정되어 있지만 좌측에서 데이터가 선택되지 않은 경우
// → 빈 데이터 표시 (모든 데이터를 보여주지 않음)
if (hasLinkedFiltersConfigured && !hasSelectedLeftData) {
@@ -752,9 +753,9 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
setLoading(false);
return;
}
// 🆕 RelatedDataButtons 필터 적용
let relatedButtonFilterValues: Record<string, any> = {};
const relatedButtonFilterValues: Record<string, any> = {};
if (relatedButtonFilter) {
relatedButtonFilterValues[relatedButtonFilter.filterColumn] = relatedButtonFilter.filterValue;
}
@@ -765,16 +766,16 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
...linkedFilterValues,
...relatedButtonFilterValues, // 🆕 RelatedDataButtons 필터 추가
};
console.log("🔍 데이터 조회 시작:", {
tableName: component.tableName,
page,
console.log("🔍 데이터 조회 시작:", {
tableName: component.tableName,
page,
pageSize,
linkedFilterValues,
relatedButtonFilterValues,
mergedSearchParams,
});
const result = await tableTypeApi.getTableData(component.tableName, {
page,
size: pageSize,
@@ -782,11 +783,11 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
autoFilter: component.autoFilter, // 🆕 자동 필터 설정 전달
});
console.log("✅ 데이터 조회 완료:", {
console.log("✅ 데이터 조회 완료:", {
tableName: component.tableName,
dataLength: result.data.length,
dataLength: result.data.length,
total: result.total,
page: result.page
page: result.page,
});
setData(result.data);
@@ -794,6 +795,45 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
setTotalPages(result.totalPages);
setCurrentPage(result.page);
// 카테고리 코드 패턴(CATEGORY_*) 검출 및 라벨 조회
const detectAndLoadCategoryLabels = async () => {
const categoryCodes = new Set<string>();
result.data.forEach((row: Record<string, any>) => {
Object.values(row).forEach((value) => {
if (typeof value === "string" && value.startsWith("CATEGORY_")) {
categoryCodes.add(value);
}
});
});
console.log("🏷️ [InteractiveDataTable] 감지된 카테고리 코드:", Array.from(categoryCodes));
// 새로운 카테고리 코드만 필터링 (이미 캐시된 것 제외)
const newCodes = Array.from(categoryCodes);
if (newCodes.length > 0) {
try {
console.log("🏷️ [InteractiveDataTable] 카테고리 라벨 API 호출:", newCodes);
const response = await apiClient.post("/table-categories/labels-by-codes", { valueCodes: newCodes });
console.log("🏷️ [InteractiveDataTable] 카테고리 라벨 API 응답:", response.data);
if (response.data.success && response.data.data) {
setCategoryCodeLabels((prev) => {
const newLabels = {
...prev,
...response.data.data,
};
console.log("🏷️ [InteractiveDataTable] 카테고리 라벨 캐시 업데이트:", newLabels);
return newLabels;
});
}
} catch (error) {
console.error("카테고리 라벨 조회 실패:", error);
}
}
};
detectAndLoadCategoryLabels();
// 각 행의 파일 상태 확인 (전체 행 + 가상 파일 컬럼별)
const fileStatusPromises = result.data.map(async (rowData: Record<string, any>) => {
const primaryKeyField = Object.keys(rowData)[0];
@@ -929,18 +969,18 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
try {
const columns = await tableTypeApi.getColumns(component.tableName);
setTableColumns(columns);
// 🆕 전체 컬럼 목록 설정
const columnNames = columns.map(col => col.columnName);
const columnNames = columns.map((col) => col.columnName);
setAllAvailableColumns(columnNames);
// 🆕 컬럼명 -> 라벨 매핑 생성
const labels: Record<string, string> = {};
columns.forEach(col => {
columns.forEach((col) => {
labels[col.columnName] = col.displayName || col.columnName;
});
setColumnLabels(labels);
// 🆕 localStorage에서 필터 설정 복원
if (user?.userId && component.componentId) {
const storageKey = `table-search-filter-${user.userId}-${component.componentId}`;
@@ -996,28 +1036,31 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
);
// 행 선택 핸들러
const handleRowSelect = useCallback((rowIndex: number, isSelected: boolean) => {
setSelectedRows((prev) => {
const newSet = new Set(prev);
if (isSelected) {
newSet.add(rowIndex);
} else {
newSet.delete(rowIndex);
const handleRowSelect = useCallback(
(rowIndex: number, isSelected: boolean) => {
setSelectedRows((prev) => {
const newSet = new Set(prev);
if (isSelected) {
newSet.add(rowIndex);
} else {
newSet.delete(rowIndex);
}
return newSet;
});
// 분할 패널 좌측에서 선택 시 컨텍스트에 데이터 저장 (연결 필터용)
if (splitPanelContext && splitPanelPosition === "left" && !splitPanelContext.disableAutoDataTransfer) {
if (isSelected && data[rowIndex]) {
splitPanelContext.setSelectedLeftData(data[rowIndex]);
console.log("🔗 [InteractiveDataTable] 좌측 선택 데이터 저장:", data[rowIndex]);
} else if (!isSelected) {
splitPanelContext.setSelectedLeftData(null);
console.log("🔗 [InteractiveDataTable] 좌측 선택 데이터 초기화");
}
}
return newSet;
});
// 분할 패널 좌측에서 선택 시 컨텍스트에 데이터 저장 (연결 필터용)
if (splitPanelContext && splitPanelPosition === "left" && !splitPanelContext.disableAutoDataTransfer) {
if (isSelected && data[rowIndex]) {
splitPanelContext.setSelectedLeftData(data[rowIndex]);
console.log("🔗 [InteractiveDataTable] 좌측 선택 데이터 저장:", data[rowIndex]);
} else if (!isSelected) {
splitPanelContext.setSelectedLeftData(null);
console.log("🔗 [InteractiveDataTable] 좌측 선택 데이터 초기화");
}
}
}, [data, splitPanelContext, splitPanelPosition]);
},
[data, splitPanelContext, splitPanelPosition],
);
// 전체 선택/해제 핸들러
const handleSelectAll = useCallback(
@@ -1599,7 +1642,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
</div>
);
}
// 상세 설정에서 옵션 목록 가져오기
const options = detailSettings?.options || [];
if (options.length > 0) {
@@ -1726,7 +1769,9 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
case "category": {
// 카테고리 셀렉트 (동적 import)
const { CategorySelectComponent } = require("@/lib/registry/components/category-select/CategorySelectComponent");
const {
CategorySelectComponent,
} = require("@/lib/registry/components/category-select/CategorySelectComponent");
return (
<div>
<CategorySelectComponent
@@ -1854,7 +1899,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
</div>
);
}
// 상세 설정에서 옵션 목록 가져오기
const optionsAdd = detailSettings?.options || [];
if (optionsAdd.length > 0) {
@@ -2026,7 +2071,9 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
case "category": {
// 카테고리 셀렉트 (동적 import)
const { CategorySelectComponent } = require("@/lib/registry/components/category-select/CategorySelectComponent");
const {
CategorySelectComponent,
} = require("@/lib/registry/components/category-select/CategorySelectComponent");
return (
<div>
<CategorySelectComponent
@@ -2164,8 +2211,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
const actualWebType = getColumnWebType(column.columnName);
// 파일 타입 컬럼 처리 (가상 파일 컬럼 포함)
const isFileColumn =
actualWebType === "file" || column.columnName === "file_path" || column.isVirtualFileColumn;
const isFileColumn = actualWebType === "file" || column.columnName === "file_path" || column.isVirtualFileColumn;
// 파일 타입 컬럼은 파일 아이콘으로 표시 (컬럼별 파일 관리)
if (isFileColumn && rowData) {
@@ -2210,25 +2256,25 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
case "category": {
// 카테고리 타입: 배지로 표시 (배지 없음 옵션 지원)
if (!value) return "";
const mapping = categoryMappings[column.columnName];
const categoryData = mapping?.[String(value)];
// 매핑 데이터가 있으면 라벨과 색상 사용, 없으면 코드값만 텍스트로 표시
const displayLabel = categoryData?.label || String(value);
const displayColor = categoryData?.color;
// 배지 없음 옵션: color가 없거나, "none"이거나, 매핑 데이터가 없으면 텍스트만 표시
if (!displayColor || displayColor === "none" || !categoryData) {
return <span className="text-sm">{displayLabel}</span>;
}
return (
<Badge
style={{
<Badge
style={{
backgroundColor: displayColor,
borderColor: displayColor
}}
borderColor: displayColor,
}}
className="text-white"
>
{displayLabel}
@@ -2268,8 +2314,41 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
}
break;
default:
return String(value);
default: {
// 카테고리 코드 패턴 감지 (CATEGORY_로 시작하는 값)
const strValue = String(value);
if (strValue.startsWith("CATEGORY_")) {
// 1. categoryMappings에서 해당 코드 검색 (색상 정보 포함)
for (const columnName of Object.keys(categoryMappings)) {
const mapping = categoryMappings[columnName];
const categoryData = mapping?.[strValue];
if (categoryData) {
// 색상이 있으면 배지로, 없으면 텍스트로 표시
if (categoryData.color && categoryData.color !== "none") {
return (
<Badge
style={{
backgroundColor: categoryData.color,
borderColor: categoryData.color,
}}
className="text-white"
>
{categoryData.label}
</Badge>
);
}
return <span className="text-sm">{categoryData.label}</span>;
}
}
// 2. categoryCodeLabels에서 검색 (API로 조회한 라벨)
const cachedLabel = categoryCodeLabels[strValue];
if (cachedLabel) {
return <span className="text-sm">{cachedLabel}</span>;
}
}
return strValue;
}
}
return String(value);
@@ -2405,15 +2484,12 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
{visibleColumns.length > 0 ? (
<>
<div className="overflow-hidden rounded-lg border border-gray-200/60 bg-white shadow-sm">
<Table style={{ tableLayout: 'fixed' }}>
<TableHeader className="bg-gradient-to-b from-muted/50 to-muted border-b-2 border-primary/20">
<Table style={{ tableLayout: "fixed" }}>
<TableHeader className="from-muted/50 to-muted border-primary/20 border-b-2 bg-gradient-to-b">
<TableRow>
{/* 체크박스 컬럼 (삭제 기능이 활성화된 경우) */}
{component.enableDelete && (
<TableHead
className="px-4"
style={{ width: '48px', minWidth: '48px', maxWidth: '48px' }}
>
<TableHead className="px-4" style={{ width: "48px", minWidth: "48px", maxWidth: "48px" }}>
<Checkbox
checked={selectedRows.size === data.length && data.length > 0}
onCheckedChange={handleSelectAll}
@@ -2422,74 +2498,74 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
)}
{visibleColumns.map((column: DataTableColumn, columnIndex) => {
const columnWidth = columnWidths[column.id];
return (
<TableHead
key={column.id}
ref={(el) => (columnRefs.current[column.id] = el)}
className="relative px-4 font-bold text-foreground/90 select-none text-center hover:bg-muted/70 transition-colors"
style={{
className="text-foreground/90 hover:bg-muted/70 relative px-4 text-center font-bold transition-colors select-none"
style={{
width: columnWidth ? `${columnWidth}px` : undefined,
userSelect: 'none'
userSelect: "none",
}}
>
{column.label}
{/* 리사이즈 핸들 */}
{columnIndex < visibleColumns.length - 1 && (
<div
className="absolute right-0 top-0 h-full w-2 cursor-col-resize hover:bg-blue-500 z-20"
style={{ marginRight: '-4px', paddingLeft: '4px', paddingRight: '4px' }}
className="absolute top-0 right-0 z-20 h-full w-2 cursor-col-resize hover:bg-blue-500"
style={{ marginRight: "-4px", paddingLeft: "4px", paddingRight: "4px" }}
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
const thElement = columnRefs.current[column.id];
if (!thElement) return;
isResizingRef.current = true;
const startX = e.clientX;
const startWidth = columnWidth || thElement.offsetWidth;
// 드래그 중 텍스트 선택 방지
document.body.style.userSelect = 'none';
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = "none";
document.body.style.cursor = "col-resize";
const handleMouseMove = (moveEvent: MouseEvent) => {
moveEvent.preventDefault();
const diff = moveEvent.clientX - startX;
const newWidth = Math.max(80, startWidth + diff);
// 직접 DOM 스타일 변경 (리렌더링 없음)
if (thElement) {
thElement.style.width = `${newWidth}px`;
}
};
const handleMouseUp = () => {
// 최종 너비를 state에 저장
if (thElement) {
const finalWidth = Math.max(80, thElement.offsetWidth);
setColumnWidths(prev => ({ ...prev, [column.id]: finalWidth }));
setColumnWidths((prev) => ({ ...prev, [column.id]: finalWidth }));
}
// 텍스트 선택 복원
document.body.style.userSelect = '';
document.body.style.cursor = '';
document.body.style.userSelect = "";
document.body.style.cursor = "";
// 약간의 지연 후 리사이즈 플래그 해제
setTimeout(() => {
isResizingRef.current = false;
}, 100);
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
}}
/>
)}
@@ -2517,10 +2593,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
<TableRow key={rowIndex} className="transition-all duration-200 hover:bg-orange-100">
{/* 체크박스 셀 (삭제 기능이 활성화된 경우) */}
{component.enableDelete && (
<TableCell
className="px-4"
style={{ width: '48px', minWidth: '48px', maxWidth: '48px' }}
>
<TableCell className="px-4" style={{ width: "48px", minWidth: "48px", maxWidth: "48px" }}>
<Checkbox
checked={selectedRows.has(rowIndex)}
onCheckedChange={(checked) => handleRowSelect(rowIndex, checked as boolean)}
@@ -2530,10 +2603,10 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
{visibleColumns.map((column: DataTableColumn) => {
const isNumeric = column.widgetType === "number" || column.widgetType === "decimal";
return (
<TableCell
key={column.id}
className="px-4 text-sm font-medium text-gray-900 whitespace-nowrap overflow-hidden text-ellipsis"
style={{ textAlign: isNumeric ? 'right' : 'left' }}
<TableCell
key={column.id}
className="overflow-hidden px-4 text-sm font-medium text-ellipsis whitespace-nowrap text-gray-900"
style={{ textAlign: isNumeric ? "right" : "left" }}
>
{formatCellValue(row[column.columnName], column, row)}
</TableCell>