diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index 8136426b..5b598422 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -927,11 +927,12 @@ export class TableManagementService { ...layout.properties, widgetType: inputType, inputType: inputType, - // componentConfig 내부의 type도 업데이트 + // componentConfig 내부의 type, inputType, webType 모두 업데이트 componentConfig: { ...layout.properties?.componentConfig, type: newComponentType, inputType: inputType, + webType: inputType, // 프론트엔드 SelectBasicComponent에서 카테고리 로딩 여부 판단에 사용 }, }; @@ -947,7 +948,7 @@ export class TableManagementService { ); logger.info( - `화면 레이아웃 업데이트: screen_id=${layout.screen_id}, component_id=${layout.component_id}, widgetType=${inputType}, componentType=${newComponentType}` + `화면 레이아웃 업데이트: screen_id=${layout.screen_id}, component_id=${layout.component_id}, widgetType=${inputType}, webType=${inputType}, componentType=${newComponentType}` ); } diff --git a/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx b/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx index ec78a180..e10bb7f6 100644 --- a/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx +++ b/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx @@ -8,7 +8,18 @@ import { Checkbox } from "@/components/ui/checkbox"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; -import { Search, Database, RefreshCw, Settings, Plus, Activity, Trash2, Copy, Check, ChevronsUpDown } from "lucide-react"; +import { + Search, + Database, + RefreshCw, + Settings, + Plus, + Activity, + Trash2, + Copy, + Check, + ChevronsUpDown, +} from "lucide-react"; import { cn } from "@/lib/utils"; import { LoadingSpinner } from "@/components/common/LoadingSpinner"; import { toast } from "sonner"; @@ -97,11 +108,16 @@ export default function TableManagementPage() { const [referenceTableColumns, setReferenceTableColumns] = useState>({}); // 🆕 Entity 타입 Combobox 열림/닫힘 상태 (컬럼별 관리) - const [entityComboboxOpen, setEntityComboboxOpen] = useState>({}); + const [entityComboboxOpen, setEntityComboboxOpen] = useState< + Record< + string, + { + table: boolean; + joinColumn: boolean; + displayColumn: boolean; + } + > + >({}); // DDL 기능 관련 상태 const [createTableModalOpen, setCreateTableModalOpen] = useState(false); @@ -337,7 +353,11 @@ export default function TableManagementPage() { if (col.detailSettings && typeof col.detailSettings === "string") { try { const parsed = JSON.parse(col.detailSettings); - if (parsed.hierarchyRole === "large" || parsed.hierarchyRole === "medium" || parsed.hierarchyRole === "small") { + if ( + parsed.hierarchyRole === "large" || + parsed.hierarchyRole === "medium" || + parsed.hierarchyRole === "small" + ) { hierarchyRole = parsed.hierarchyRole; } if (parsed.numberingRuleId) { @@ -437,9 +457,9 @@ export default function TableManagementPage() { } else { // 기존 hierarchyRole 유지하면서 JSON 형식으로 저장 const existingHierarchyRole = hierarchyRole; - newDetailSettings = JSON.stringify({ + newDetailSettings = JSON.stringify({ codeCategory: value, - hierarchyRole: existingHierarchyRole + hierarchyRole: existingHierarchyRole, }); codeCategory = value; codeValue = value; @@ -595,7 +615,7 @@ export default function TableManagementPage() { numberingRuleId: column.numberingRuleId, hasNumberingRuleId: !!column.numberingRuleId, }); - + if (column.inputType === "numbering") { let existingSettings: Record = {}; if (typeof finalDetailSettings === "string" && finalDetailSettings.trim().startsWith("{")) { @@ -1403,63 +1423,7 @@ export default function TableManagementPage() { )} )} - {/* 입력 타입이 'category'인 경우 2레벨 메뉴 다중 선택 */} - {column.inputType === "category" && ( -
- -
- {secondLevelMenus.length === 0 ? ( -

- 2레벨 메뉴가 없습니다. 메뉴를 선택하지 않으면 모든 메뉴에서 사용 가능합니다. -

- ) : ( - secondLevelMenus.map((menu) => { - // menuObjid를 숫자로 변환하여 비교 - const menuObjidNum = Number(menu.menuObjid); - const isChecked = (column.categoryMenus || []).includes(menuObjidNum); - - return ( -
- { - const currentMenus = column.categoryMenus || []; - const newMenus = e.target.checked - ? [...currentMenus, menuObjidNum] - : currentMenus.filter((id) => id !== menuObjidNum); - - setColumns((prev) => - prev.map((col) => - col.columnName === column.columnName - ? { ...col, categoryMenus: newMenus } - : col, - ), - ); - }} - className="text-primary focus:ring-ring h-4 w-4 rounded border-gray-300 focus:ring-2" - /> - -
- ); - }) - )} -
- {column.categoryMenus && column.categoryMenus.length > 0 && ( -

- {column.categoryMenus.length}개 메뉴 선택됨 -

- )} -
- )} + {/* 카테고리 타입: 메뉴 종속성 제거됨 - 테이블/컬럼 단위로 관리 */} {/* 입력 타입이 'entity'인 경우 참조 테이블 선택 */} {column.inputType === "entity" && ( <> @@ -1483,8 +1447,8 @@ export default function TableManagementPage() { className="bg-background h-8 w-full justify-between text-xs" > {column.referenceTable && column.referenceTable !== "none" - ? referenceTableOptions.find((opt) => opt.value === column.referenceTable)?.label || - column.referenceTable + ? referenceTableOptions.find((opt) => opt.value === column.referenceTable) + ?.label || column.referenceTable : "테이블 선택..."} @@ -1502,10 +1466,17 @@ export default function TableManagementPage() { key={option.value} value={`${option.label} ${option.value}`} onSelect={() => { - handleDetailSettingsChange(column.columnName, "entity", option.value); + handleDetailSettingsChange( + column.columnName, + "entity", + option.value, + ); setEntityComboboxOpen((prev) => ({ ...prev, - [column.columnName]: { ...prev[column.columnName], table: false }, + [column.columnName]: { + ...prev[column.columnName], + table: false, + }, })); }} className="text-xs" @@ -1513,13 +1484,17 @@ export default function TableManagementPage() {
{option.label} {option.value !== "none" && ( - {option.value} + + {option.value} + )}
@@ -1550,9 +1525,13 @@ export default function TableManagementPage() { role="combobox" aria-expanded={entityComboboxOpen[column.columnName]?.joinColumn || false} className="bg-background h-8 w-full justify-between text-xs" - disabled={!referenceTableColumns[column.referenceTable] || referenceTableColumns[column.referenceTable].length === 0} + disabled={ + !referenceTableColumns[column.referenceTable] || + referenceTableColumns[column.referenceTable].length === 0 + } > - {!referenceTableColumns[column.referenceTable] || referenceTableColumns[column.referenceTable].length === 0 ? ( + {!referenceTableColumns[column.referenceTable] || + referenceTableColumns[column.referenceTable].length === 0 ? (
로딩중... @@ -1576,10 +1555,17 @@ export default function TableManagementPage() { { - handleDetailSettingsChange(column.columnName, "entity_reference_column", "none"); + handleDetailSettingsChange( + column.columnName, + "entity_reference_column", + "none", + ); setEntityComboboxOpen((prev) => ({ ...prev, - [column.columnName]: { ...prev[column.columnName], joinColumn: false }, + [column.columnName]: { + ...prev[column.columnName], + joinColumn: false, + }, })); }} className="text-xs" @@ -1587,7 +1573,9 @@ export default function TableManagementPage() { -- 선택 안함 -- @@ -1597,10 +1585,17 @@ export default function TableManagementPage() { key={refCol.columnName} value={`${refCol.columnLabel || ""} ${refCol.columnName}`} onSelect={() => { - handleDetailSettingsChange(column.columnName, "entity_reference_column", refCol.columnName); + handleDetailSettingsChange( + column.columnName, + "entity_reference_column", + refCol.columnName, + ); setEntityComboboxOpen((prev) => ({ ...prev, - [column.columnName]: { ...prev[column.columnName], joinColumn: false }, + [column.columnName]: { + ...prev[column.columnName], + joinColumn: false, + }, })); }} className="text-xs" @@ -1608,13 +1603,17 @@ export default function TableManagementPage() {
{refCol.columnName} {refCol.columnLabel && ( - {refCol.columnLabel} + + {refCol.columnLabel} + )}
@@ -1639,7 +1638,10 @@ export default function TableManagementPage() { onOpenChange={(open) => setEntityComboboxOpen((prev) => ({ ...prev, - [column.columnName]: { ...prev[column.columnName], displayColumn: open }, + [column.columnName]: { + ...prev[column.columnName], + displayColumn: open, + }, })) } > @@ -1647,11 +1649,17 @@ export default function TableManagementPage() { )} + + {/* 상태 유지 체크박스 */} +
+ setPersistState(checked === true)} + className="h-3.5 w-3.5" + /> + +
{/* 차트 토글 */} {chartConfig && ( @@ -1685,137 +1782,224 @@ export const PivotGridComponent: React.FC = ({ > - {/* 열 헤더 */} - - {/* 좌상단 코너 (행 필드 라벨 + 필터) */} - + {/* 좌상단 코너 (첫 번째 레벨에만 표시) */} + {levelIdx === 0 && ( + + )} + + {/* 열 헤더 셀 - 해당 레벨 */} + {levelCells.map((cell, cellIdx) => ( + ))} - {rowFields.length === 0 && 항목} - - - {/* 열 헤더 셀 */} - {flatColumns.map((col, idx) => ( - )} - colSpan={dataFields.length || 1} - style={{ width: columnWidths[idx] || "auto", minWidth: 50 }} - onClick={dataFields.length === 1 ? () => handleSort(dataFields[0].field) : undefined} - > -
- {col.caption || "(전체)"} - {dataFields.length === 1 && } -
- {/* 열 리사이즈 핸들 */} -
handleResizeStart(idx, e)} - /> - - ))} - - {/* 행 총계 헤더 */} - {totals?.showRowGrandTotals && ( -
)} - colSpan={dataFields.length || 1} - rowSpan={dataFields.length > 1 ? 2 : 1} - > - 총계 - - )} - - {/* 열 필드 필터 (헤더 오른쪽 끝에 표시) */} - {columnFields.length > 0 && ( + + )) + ) : ( + // 열 필드가 없는 경우: 단일 행 + - )} - + + {/* 열 헤더 셀 (열 필드 없을 때) */} + {flatColumns.map((col, idx) => ( + + ))} + + {/* 행 총계 헤더 */} + {totals?.showRowGrandTotals && ( + + )} + + )} {/* 데이터 필드 라벨 (다중 데이터 필드인 경우) */} {dataFields.length > 1 && ( diff --git a/frontend/lib/registry/components/pivot-grid/PivotGridConfigPanel.tsx b/frontend/lib/registry/components/pivot-grid/PivotGridConfigPanel.tsx index 37f0862b..448c92a5 100644 --- a/frontend/lib/registry/components/pivot-grid/PivotGridConfigPanel.tsx +++ b/frontend/lib/registry/components/pivot-grid/PivotGridConfigPanel.tsx @@ -16,6 +16,7 @@ import { PivotAreaType, AggregationType, FieldDataType, + DateGroupInterval, } from "./types"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; @@ -202,6 +203,28 @@ const AreaDropZone: React.FC = ({ )} + {/* 행/열 영역에서 날짜 타입일 때 그룹화 옵션 */} + {(area === "row" || area === "column") && field.dataType === "date" && ( + + )} + + {/* 필터 아이콘 (필터 적용 시) */} + {hasFilter && ( + + )} + {/* 필드 라벨 */} + + + + + 필터만 초기화 + {filteredFieldCount > 0 && ( + + ({filteredFieldCount}개) + + )} + + + + 필드 배치 초기화 + + + + + 전체 초기화 + + + + + {/* 접기 버튼 */} + {onToggleCollapse && ( - - )} + )} + {/* 드래그 오버레이 */} diff --git a/frontend/lib/registry/components/pivot-grid/types.ts b/frontend/lib/registry/components/pivot-grid/types.ts index 87ba2414..d4d8b1e5 100644 --- a/frontend/lib/registry/components/pivot-grid/types.ts +++ b/frontend/lib/registry/components/pivot-grid/types.ts @@ -304,6 +304,7 @@ export interface PivotHeaderNode { level: number; // 깊이 children?: PivotHeaderNode[]; // 자식 노드 isExpanded: boolean; // 확장 상태 + hasChildren: boolean; // 자식 존재 가능 여부 (다음 레벨 필드 있음) path: string[]; // 경로 (드릴다운용) subtotal?: PivotCellValue[]; // 소계 span?: number; // colspan/rowspan @@ -330,8 +331,11 @@ export interface PivotResult { // 플랫 행 목록 (렌더링용) flatRows: PivotFlatRow[]; - // 플랫 열 목록 (렌더링용) + // 플랫 열 목록 (렌더링용) - 리프 노드만 flatColumns: PivotFlatColumn[]; + + // 열 헤더 레벨별 (다중 행 헤더용) + columnHeaderLevels: PivotColumnHeaderCell[][]; // 총합계 grandTotals: { @@ -360,6 +364,14 @@ export interface PivotFlatColumn { isTotal?: boolean; } +// 열 헤더 셀 (다중 행 헤더용) +export interface PivotColumnHeaderCell { + caption: string; // 표시 텍스트 + colSpan: number; // 병합할 열 수 + path: string[]; // 전체 경로 + level: number; // 레벨 (0부터 시작) +} + // ==================== 상태 관리 ==================== export interface PivotGridState { diff --git a/frontend/lib/registry/components/pivot-grid/utils/pivotEngine.ts b/frontend/lib/registry/components/pivot-grid/utils/pivotEngine.ts index 02dd4608..35893dea 100644 --- a/frontend/lib/registry/components/pivot-grid/utils/pivotEngine.ts +++ b/frontend/lib/registry/components/pivot-grid/utils/pivotEngine.ts @@ -10,6 +10,7 @@ import { PivotFlatRow, PivotFlatColumn, PivotCellValue, + PivotColumnHeaderCell, DateGroupInterval, AggregationType, SummaryDisplayMode, @@ -76,6 +77,31 @@ export function pathToKey(path: string[]): string { return path.join("||"); } +/** + * 모든 가능한 경로 생성 (열 전체 확장용) + */ +function generateAllPaths( + data: Record[], + fields: PivotFieldConfig[] +): string[] { + const allPaths: string[] = []; + + // 각 레벨까지의 고유 경로 수집 + for (let depth = 1; depth <= fields.length; depth++) { + const fieldsAtDepth = fields.slice(0, depth); + const pathSet = new Set(); + + data.forEach((row) => { + const path = fieldsAtDepth.map((f) => getFieldValue(row, f)); + pathSet.add(pathToKey(path)); + }); + + pathSet.forEach((pathKey) => allPaths.push(pathKey)); + } + + return allPaths; +} + /** * 키를 경로로 변환 */ @@ -129,6 +155,7 @@ function buildHeaderTree( caption: key, level: 0, isExpanded: expandedPaths.has(pathKey), + hasChildren: remainingFields.length > 0, // 다음 레벨 필드가 있으면 자식 있음 path: path, span: 1, }; @@ -195,6 +222,7 @@ function buildChildNodes( caption: key, level: level, isExpanded: expandedPaths.has(pathKey), + hasChildren: remainingFields.length > 0, // 다음 레벨 필드가 있으면 자식 있음 path: path, span: 1, }; @@ -238,7 +266,7 @@ function flattenRows(nodes: PivotHeaderNode[]): PivotFlatRow[] { level: node.level, caption: node.caption, isExpanded: node.isExpanded, - hasChildren: !!(node.children && node.children.length > 0), + hasChildren: node.hasChildren, // 노드에서 직접 가져옴 (다음 레벨 필드 존재 여부 기준) }); if (node.isExpanded && node.children) { @@ -324,6 +352,66 @@ function getMaxColumnLevel( return Math.min(maxLevel, totalFields - 1); } +/** + * 다중 행 열 헤더 생성 + * 각 레벨별로 셀과 colSpan 정보를 반환 + */ +function buildColumnHeaderLevels( + nodes: PivotHeaderNode[], + totalLevels: number +): PivotColumnHeaderCell[][] { + if (totalLevels === 0 || nodes.length === 0) { + return []; + } + + const levels: PivotColumnHeaderCell[][] = Array.from( + { length: totalLevels }, + () => [] + ); + + // 리프 노드 수 계산 (colSpan 계산용) + function countLeaves(node: PivotHeaderNode): number { + if (!node.children || node.children.length === 0 || !node.isExpanded) { + return 1; + } + return node.children.reduce((sum, child) => sum + countLeaves(child), 0); + } + + // 트리 순회하며 각 레벨에 셀 추가 + function traverse(node: PivotHeaderNode, level: number) { + const colSpan = countLeaves(node); + + levels[level].push({ + caption: node.caption, + colSpan, + path: node.path, + level, + }); + + if (node.children && node.isExpanded) { + for (const child of node.children) { + traverse(child, level + 1); + } + } else if (level < totalLevels - 1) { + // 확장되지 않은 노드는 다음 레벨들에 빈 셀로 채움 + for (let i = level + 1; i < totalLevels; i++) { + levels[i].push({ + caption: "", + colSpan, + path: node.path, + level: i, + }); + } + } + } + + for (const node of nodes) { + traverse(node, 0); + } + + return levels; +} + // ==================== 데이터 매트릭스 생성 ==================== /** @@ -733,12 +821,11 @@ export function processPivotData( uniqueValues.forEach((val) => expandedRowSet.add(val)); } - if (expandedColumnPaths.length === 0 && columnFields.length > 0) { - const firstField = columnFields[0]; - const uniqueValues = new Set( - filteredData.map((row) => getFieldValue(row, firstField)) - ); - uniqueValues.forEach((val) => expandedColSet.add(val)); + // 열은 항상 전체 확장 (열 헤더는 확장/축소 UI가 없음) + // 모든 가능한 열 경로를 확장 상태로 설정 + if (columnFields.length > 0) { + const allColumnPaths = generateAllPaths(filteredData, columnFields); + allColumnPaths.forEach((pathKey) => expandedColSet.add(pathKey)); } // 헤더 트리 생성 @@ -786,6 +873,12 @@ export function processPivotData( grandTotals.grand ); + // 다중 행 열 헤더 생성 + const columnHeaderLevels = buildColumnHeaderLevels( + columnHeaders, + columnFields.length + ); + return { rowHeaders, columnHeaders, @@ -797,6 +890,7 @@ export function processPivotData( caption: path[path.length - 1] || "", span: 1, })), + columnHeaderLevels, grandTotals, }; } diff --git a/frontend/lib/registry/components/select-basic/SelectBasicComponent.tsx b/frontend/lib/registry/components/select-basic/SelectBasicComponent.tsx index b1bdea4a..53a6416b 100644 --- a/frontend/lib/registry/components/select-basic/SelectBasicComponent.tsx +++ b/frontend/lib/registry/components/select-basic/SelectBasicComponent.tsx @@ -235,7 +235,8 @@ const SelectBasicComponent: React.FC = ({ setIsLoadingCategories(true); import("@/lib/api/tableCategoryValue").then(({ getCategoryValues }) => { - getCategoryValues(component.tableName!, component.columnName!) + // 🆕 menuObjid를 4번째 파라미터로 전달 (카테고리 스코프 적용) + getCategoryValues(component.tableName!, component.columnName!, false, menuObjid) .then((response) => { if (response.success && "data" in response && response.data) { const activeValues = response.data.filter((v: any) => v.isActive !== false);
0 ? 2 : 1} - > -
- {rowFields.map((f, idx) => ( -
- {f.caption} - { - const newFields = fields.map((fld) => - fld.field === field.field && fld.area === "row" - ? { ...fld, filterValues: values, filterType: type } - : fld - ); - handleFieldsChange(newFields); - }} - trigger={ - - } - /> - {idx < rowFields.length - 1 && /} -
+ {/* 다중 행 열 헤더 */} + {columnHeaderLevels.length > 0 ? ( + // 열 필드가 있는 경우: 각 레벨별로 행 생성 + columnHeaderLevels.map((levelCells, levelIdx) => ( +
1 ? 1 : 0)} + > +
+ {rowFields.map((f, idx) => ( +
+ {f.caption} + { + const newFields = fields.map((fld) => + fld.field === field.field && fld.area === "row" + ? { ...fld, filterValues: values, filterType: type } + : fld + ); + handleFieldsChange(newFields); + }} + trigger={ + + } + /> + {idx < rowFields.length - 1 && /} +
+ ))} + {rowFields.length === 0 && 항목} +
+
+
+ {cell.caption || "(전체)"} + {levelIdx === columnHeaderLevels.length - 1 && dataFields.length === 1 && ( + + )} +
+
1 ? 1 : 0)} + > + 총계 + 0 && ( + 1 ? 1 : 0)} + > +
+ {columnFields.map((f) => ( + { + const newFields = fields.map((fld) => + fld.field === field.field && fld.area === "column" + ? { ...fld, filterValues: values, filterType: type } + : fld + ); + handleFieldsChange(newFields); + }} + trigger={ + + } + /> + ))} +
+
1 ? 2 : 1} > -
- {columnFields.map((f) => ( - { - const newFields = fields.map((fld) => - fld.field === field.field && fld.area === "column" - ? { ...fld, filterValues: values, filterType: type } - : fld - ); - handleFieldsChange(newFields); - }} - trigger={ - - } - /> +
+ {rowFields.map((f, idx) => ( +
+ {f.caption} + { + const newFields = fields.map((fld) => + fld.field === field.field && fld.area === "row" + ? { ...fld, filterValues: values, filterType: type } + : fld + ); + handleFieldsChange(newFields); + }} + trigger={ + + } + /> + {idx < rowFields.length - 1 && /} +
))} + {rowFields.length === 0 && 항목}
handleSort(dataFields[0].field) : undefined} + > +
+ {col.caption || "(전체)"} + {dataFields.length === 1 && } +
+
handleResizeStart(idx, e)} + /> +
1 ? 2 : 1} + > + 총계 +