Merge pull request 'feature/screen-management' (#241) from feature/screen-management into main

Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/241
This commit is contained in:
kjs 2025-12-03 18:48:48 +09:00
commit 8b3017224f
5 changed files with 354 additions and 18 deletions

View File

@ -403,18 +403,25 @@ export class EntityJoinService {
const fromClause = `FROM ${tableName} main`;
// LEFT JOIN 절들 (위에서 생성한 별칭 매핑 사용, 각 sourceColumn마다 별도 JOIN)
// 멀티테넌시: 모든 조인에 company_code 조건 추가 (다른 회사 데이터 혼합 방지)
const joinClauses = uniqueReferenceTableConfigs
.map((config) => {
const aliasKey = `${config.referenceTable}:${config.sourceColumn}`;
const alias = aliasMap.get(aliasKey);
// table_column_category_values는 특별한 조인 조건 필요 (회사별 필터링)
// table_column_category_values는 특별한 조인 조건 필요 (회사별 필터링)
if (config.referenceTable === "table_column_category_values") {
// 멀티테넌시: 회사 데이터만 사용 (공통 데이터 제외)
return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn} AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}' AND ${alias}.company_code = main.company_code AND ${alias}.is_active = true`;
}
return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn}`;
// user_info는 전역 테이블이므로 company_code 조건 없이 조인
if (config.referenceTable === "user_info") {
return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn}`;
}
// 일반 테이블: company_code가 있으면 같은 회사 데이터만 조인 (멀티테넌시)
// supplier_mng, customer_mng, item_info 등 회사별 데이터 테이블
return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn} AND ${alias}.company_code = main.company_code`;
})
.join("\n");

View File

@ -149,11 +149,12 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
const existing = savedRules.find((r) => r.ruleId === currentRule.ruleId);
// 저장 전에 현재 화면의 테이블명과 menuObjid 자동 설정
// 메뉴 기반으로 채번규칙 관리 (menuObjid로 필터링)
const ruleToSave = {
...currentRule,
scopeType: "table" as const, // ⚠️ 임시: DB 제약 조건 때문에 table 유지
tableName: currentTableName || currentRule.tableName || null, // 현재 테이블명 자동 설정 (빈 값은 null)
menuObjid: menuObjid || currentRule.menuObjid || null, // 🆕 메뉴 OBJID 설정 (필터링용)
scopeType: "menu" as const, // 메뉴 기반 채번규칙
tableName: currentTableName || currentRule.tableName || null, // 현재 테이블명 (참고용)
menuObjid: menuObjid || currentRule.menuObjid || null, // 메뉴 OBJID (필터링 기준)
};
console.log("💾 채번 규칙 저장:", {

View File

@ -1,6 +1,6 @@
"use client";
import React, { useEffect, useState, useMemo, useCallback } from "react";
import React, { useEffect, useState, useMemo, useCallback, useRef } from "react";
import { ComponentRendererProps } from "@/types/component";
import { CardDisplayConfig } from "./types";
import { tableTypeApi } from "@/lib/api/screen";
@ -13,6 +13,8 @@ import { Badge } from "@/components/ui/badge";
import { useScreenContextOptional } from "@/contexts/ScreenContext";
import { useSplitPanelContext } from "@/contexts/SplitPanelContext";
import { useModalDataStore } from "@/stores/modalDataStore";
import { useTableOptions } from "@/contexts/TableOptionsContext";
import { TableFilter, ColumnVisibility, TableColumn } from "@/types/table-options";
export interface CardDisplayComponentProps extends ComponentRendererProps {
config?: CardDisplayConfig;
@ -48,11 +50,32 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
const splitPanelContext = useSplitPanelContext();
const splitPanelPosition = screenContext?.splitPanelPosition;
// TableOptions Context (검색 필터 위젯 연동용)
let tableOptionsContext: ReturnType<typeof useTableOptions> | null = null;
try {
tableOptionsContext = useTableOptions();
} catch (e) {
// Context가 없으면 (디자이너 모드) 무시
}
// 테이블 데이터 상태 관리
const [loadedTableData, setLoadedTableData] = useState<any[]>([]);
const [loadedTableColumns, setLoadedTableColumns] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
// 필터 상태 (검색 필터 위젯에서 전달받은 필터)
const [filters, setFiltersInternal] = useState<TableFilter[]>([]);
// 필터 상태 변경 래퍼 (로깅용)
const setFilters = useCallback((newFilters: TableFilter[]) => {
console.log("🎴 [CardDisplay] setFilters 호출됨:", {
componentId: component.id,
filtersCount: newFilters.length,
filters: newFilters,
});
setFiltersInternal(newFilters);
}, [component.id]);
// 카테고리 매핑 상태 (카테고리 코드 -> 라벨/색상)
const [columnMeta, setColumnMeta] = useState<
Record<string, { webType?: string; codeCategory?: string; inputType?: string }>
@ -380,6 +403,195 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
}
}, [screenContext, component.id, dataProvider]);
// TableOptionsContext에 테이블 등록 (검색 필터 위젯 연동용)
const tableId = `card-display-${component.id}`;
const tableNameToUse = tableName || component.componentConfig?.tableName || '';
const tableLabel = component.componentConfig?.title || component.label || "카드 디스플레이";
// ref로 최신 데이터 참조 (useCallback 의존성 문제 해결)
const loadedTableDataRef = useRef(loadedTableData);
const categoryMappingsRef = useRef(categoryMappings);
useEffect(() => {
loadedTableDataRef.current = loadedTableData;
}, [loadedTableData]);
useEffect(() => {
categoryMappingsRef.current = categoryMappings;
}, [categoryMappings]);
// 필터가 변경되면 데이터 다시 로드 (테이블 리스트와 동일한 패턴)
// 초기 로드 여부 추적
const isInitialLoadRef = useRef(true);
useEffect(() => {
if (!tableNameToUse || isDesignMode) return;
// 초기 로드는 별도 useEffect에서 처리하므로 스킵
if (isInitialLoadRef.current) {
isInitialLoadRef.current = false;
return;
}
const loadFilteredData = async () => {
try {
setLoading(true);
// 필터 값을 검색 파라미터로 변환
const searchParams: Record<string, any> = {};
filters.forEach(filter => {
if (filter.value !== undefined && filter.value !== null && filter.value !== '') {
searchParams[filter.columnName] = filter.value;
}
});
console.log("🔍 [CardDisplay] 필터 적용 데이터 로드:", {
tableName: tableNameToUse,
filtersCount: filters.length,
searchParams,
});
// search 파라미터로 검색 조건 전달 (API 스펙에 맞게)
const dataResponse = await tableTypeApi.getTableData(tableNameToUse, {
page: 1,
size: 50,
search: searchParams,
});
setLoadedTableData(dataResponse.data);
// 데이터 건수 업데이트
if (tableOptionsContext) {
tableOptionsContext.updateTableDataCount(tableId, dataResponse.data?.length || 0);
}
} catch (error) {
console.error("❌ [CardDisplay] 필터 적용 실패:", error);
} finally {
setLoading(false);
}
};
// 필터 변경 시 항상 데이터 다시 로드 (빈 필터 = 전체 데이터)
loadFilteredData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filters, tableNameToUse, isDesignMode, tableId]);
// 컬럼 고유 값 조회 함수 (select 타입 필터용)
const getColumnUniqueValues = useCallback(async (columnName: string): Promise<Array<{ label: string; value: string }>> => {
if (!tableNameToUse) return [];
try {
// 현재 로드된 데이터에서 고유 값 추출
const uniqueValues = new Set<string>();
loadedTableDataRef.current.forEach(row => {
const value = row[columnName];
if (value !== null && value !== undefined && value !== '') {
uniqueValues.add(String(value));
}
});
// 카테고리 매핑이 있으면 라벨 적용
const mapping = categoryMappingsRef.current[columnName];
return Array.from(uniqueValues).map(value => ({
value,
label: mapping?.[value]?.label || value,
}));
} catch (error) {
console.error(`❌ [CardDisplay] 고유 값 조회 실패: ${columnName}`, error);
return [];
}
}, [tableNameToUse]);
// TableOptionsContext에 등록
// registerTable과 unregisterTable 함수 참조 저장 (의존성 안정화)
const registerTableRef = useRef(tableOptionsContext?.registerTable);
const unregisterTableRef = useRef(tableOptionsContext?.unregisterTable);
// setFiltersInternal을 ref로 저장 (등록 시 최신 함수 사용)
const setFiltersRef = useRef(setFiltersInternal);
const getColumnUniqueValuesRef = useRef(getColumnUniqueValues);
useEffect(() => {
registerTableRef.current = tableOptionsContext?.registerTable;
unregisterTableRef.current = tableOptionsContext?.unregisterTable;
}, [tableOptionsContext]);
useEffect(() => {
setFiltersRef.current = setFiltersInternal;
}, [setFiltersInternal]);
useEffect(() => {
getColumnUniqueValuesRef.current = getColumnUniqueValues;
}, [getColumnUniqueValues]);
// 테이블 등록 (한 번만 실행, 컬럼 변경 시에만 재등록)
const columnsKey = JSON.stringify(loadedTableColumns.map((col: any) => col.columnName || col.column_name));
useEffect(() => {
if (!registerTableRef.current || !unregisterTableRef.current) return;
if (isDesignMode || !tableNameToUse || loadedTableColumns.length === 0) return;
// 컬럼 정보를 TableColumn 형식으로 변환
const columns: TableColumn[] = loadedTableColumns.map((col: any) => ({
columnName: col.columnName || col.column_name,
columnLabel: col.displayName || col.columnLabel || col.column_label || col.columnName || col.column_name,
inputType: columnMeta[col.columnName || col.column_name]?.inputType || 'text',
visible: true,
width: 200,
sortable: true,
filterable: true,
}));
// onFilterChange는 ref를 통해 최신 함수를 호출하는 래퍼 사용
const onFilterChangeWrapper = (newFilters: TableFilter[]) => {
console.log("🎴 [CardDisplay] onFilterChange 래퍼 호출:", {
tableId,
filtersCount: newFilters.length,
});
setFiltersRef.current(newFilters);
};
const getColumnUniqueValuesWrapper = async (columnName: string) => {
return getColumnUniqueValuesRef.current(columnName);
};
const registration = {
tableId,
label: tableLabel,
tableName: tableNameToUse,
columns,
dataCount: loadedTableData.length,
onFilterChange: onFilterChangeWrapper,
onGroupChange: () => {}, // 카드 디스플레이는 그룹핑 미지원
onColumnVisibilityChange: () => {}, // 카드 디스플레이는 컬럼 가시성 미지원
getColumnUniqueValues: getColumnUniqueValuesWrapper,
};
console.log("📋 [CardDisplay] TableOptionsContext에 등록:", {
tableId,
tableName: tableNameToUse,
columnsCount: columns.length,
dataCount: loadedTableData.length,
});
registerTableRef.current(registration);
const unregister = unregisterTableRef.current;
const currentTableId = tableId;
return () => {
console.log("📋 [CardDisplay] TableOptionsContext에서 해제:", currentTableId);
unregister(currentTableId);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
isDesignMode,
tableId,
tableNameToUse,
tableLabel,
columnsKey, // 컬럼 변경 시에만 재등록
]);
// 로딩 중인 경우 로딩 표시
if (loading) {
return (

View File

@ -1,6 +1,6 @@
"use client";
import React, { useState, useEffect, useRef } from "react";
import React, { useState, useEffect, useRef, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Settings, Filter, Layers, X, Check, ChevronsUpDown } from "lucide-react";
@ -41,6 +41,7 @@ interface TableSearchWidgetProps {
showTableSelector?: boolean; // 테이블 선택 드롭다운 표시 여부
filterMode?: "dynamic" | "preset"; // 필터 모드
presetFilters?: PresetFilter[]; // 고정 필터 목록
targetPanelPosition?: "left" | "right" | "auto"; // 분할 패널에서 대상 패널 위치 (기본: "left")
};
};
screenId?: number; // 화면 ID
@ -82,19 +83,90 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
const showTableSelector = component.componentConfig?.showTableSelector ?? true;
const filterMode = component.componentConfig?.filterMode ?? "dynamic";
const presetFilters = component.componentConfig?.presetFilters ?? [];
const targetPanelPosition = component.componentConfig?.targetPanelPosition ?? "left"; // 기본값: 좌측 패널
// Map을 배열로 변환
const tableList = Array.from(registeredTables.values());
const currentTable = selectedTableId ? getTable(selectedTableId) : undefined;
// 첫 번째 테이블 자동 선택
useEffect(() => {
const tables = Array.from(registeredTables.values());
if (autoSelectFirstTable && tables.length > 0 && !selectedTableId) {
setSelectedTableId(tables[0].tableId);
const allTableList = Array.from(registeredTables.values());
// 대상 패널 위치에 따라 테이블 필터링 (tableId 패턴 기반)
const tableList = useMemo(() => {
// "auto"면 모든 테이블 반환
if (targetPanelPosition === "auto") {
return allTableList;
}
}, [registeredTables, selectedTableId, autoSelectFirstTable, setSelectedTableId]);
// 테이블 ID 패턴으로 필터링
// card-display-XXX: 좌측 패널 (카드 디스플레이)
// datatable-XXX, table-list-XXX: 우측 패널 (테이블 리스트)
const filteredTables = allTableList.filter(table => {
const tableId = table.tableId.toLowerCase();
if (targetPanelPosition === "left") {
// 좌측 패널 대상: card-display만
return tableId.includes("card-display") || tableId.includes("card");
} else if (targetPanelPosition === "right") {
// 우측 패널 대상: datatable, table-list 등 (card-display 제외)
const isCardDisplay = tableId.includes("card-display") || tableId.includes("card");
return !isCardDisplay;
}
return true;
});
// 필터링된 결과가 없으면 모든 테이블 반환 (폴백)
if (filteredTables.length === 0) {
console.log("🔍 [TableSearchWidget] 대상 패널에 테이블 없음, 전체 테이블 사용:", {
targetPanelPosition,
allTablesCount: allTableList.length,
allTableIds: allTableList.map(t => t.tableId),
});
return allTableList;
}
console.log("🔍 [TableSearchWidget] 테이블 필터링:", {
targetPanelPosition,
allTablesCount: allTableList.length,
filteredCount: filteredTables.length,
filteredTableIds: filteredTables.map(t => t.tableId),
});
return filteredTables;
}, [allTableList, targetPanelPosition]);
// currentTable은 tableList(필터링된 목록)에서 가져와야 함
const currentTable = useMemo(() => {
if (!selectedTableId) return undefined;
// 먼저 tableList(필터링된 목록)에서 찾기
const tableFromList = tableList.find(t => t.tableId === selectedTableId);
if (tableFromList) {
return tableFromList;
}
// tableList에 없으면 전체에서 찾기 (폴백)
return getTable(selectedTableId);
}, [selectedTableId, tableList, getTable]);
// 대상 패널의 첫 번째 테이블 자동 선택
useEffect(() => {
if (!autoSelectFirstTable || tableList.length === 0) {
return;
}
// 현재 선택된 테이블이 대상 패널에 있는지 확인
const isCurrentTableInTarget = selectedTableId && tableList.some(t => t.tableId === selectedTableId);
// 현재 선택된 테이블이 대상 패널에 없으면 대상 패널의 첫 번째 테이블 선택
if (!selectedTableId || !isCurrentTableInTarget) {
const targetTable = tableList[0];
console.log("🔍 [TableSearchWidget] 대상 패널 테이블 자동 선택:", {
targetPanelPosition,
selectedTableId: targetTable.tableId,
tableName: targetTable.tableName,
});
setSelectedTableId(targetTable.tableId);
}
}, [tableList, selectedTableId, autoSelectFirstTable, setSelectedTableId, targetPanelPosition]);
// 현재 테이블의 저장된 필터 불러오기 (동적 모드) 또는 고정 필터 적용 (고정 모드)
useEffect(() => {
@ -302,6 +374,12 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
return true;
});
console.log("🔍 [TableSearchWidget] 필터 적용:", {
currentTableId: currentTable?.tableId,
currentTableName: currentTable?.tableName,
filtersCount: filtersWithValues.length,
filtersWithValues,
});
currentTable?.onFilterChange(filtersWithValues);
};

View File

@ -76,12 +76,16 @@ export function TableSearchWidgetConfigPanel({
const [localPresetFilters, setLocalPresetFilters] = useState<PresetFilter[]>(
currentConfig.presetFilters ?? []
);
const [localTargetPanelPosition, setLocalTargetPanelPosition] = useState<"left" | "right" | "auto">(
currentConfig.targetPanelPosition ?? "left"
);
useEffect(() => {
setLocalAutoSelect(currentConfig.autoSelectFirstTable ?? true);
setLocalShowSelector(currentConfig.showTableSelector ?? true);
setLocalFilterMode(currentConfig.filterMode ?? "dynamic");
setLocalPresetFilters(currentConfig.presetFilters ?? []);
setLocalTargetPanelPosition(currentConfig.targetPanelPosition ?? "left");
}, [currentConfig]);
// 설정 업데이트 헬퍼
@ -164,6 +168,40 @@ export function TableSearchWidgetConfigPanel({
</Label>
</div>
{/* 대상 패널 위치 (분할 패널용) */}
<div className="space-y-2 border-t pt-4">
<Label className="text-xs sm:text-sm font-medium"> ( )</Label>
<p className="text-[10px] text-muted-foreground mb-2">
.
</p>
<RadioGroup
value={localTargetPanelPosition}
onValueChange={(value: "left" | "right" | "auto") => {
setLocalTargetPanelPosition(value);
handleUpdate("targetPanelPosition", value);
}}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="left" id="target-left" />
<Label htmlFor="target-left" className="text-xs sm:text-sm cursor-pointer font-normal">
( )
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="right" id="target-right" />
<Label htmlFor="target-right" className="text-xs sm:text-sm cursor-pointer font-normal">
( )
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="auto" id="target-auto" />
<Label htmlFor="target-auto" className="text-xs sm:text-sm cursor-pointer font-normal">
( )
</Label>
</div>
</RadioGroup>
</div>
{/* 필터 모드 선택 */}
<div className="space-y-2 border-t pt-4">
<Label className="text-xs sm:text-sm font-medium"> </Label>