Compare commits

...

3 Commits

8 changed files with 843 additions and 1258 deletions

View File

@ -67,7 +67,90 @@ interface UnifiedRepeaterConfig {
}
```
### 저장 테이블 설정 UI 표준
### 조회 테이블 설정 UI 표준 (테이블 리스트)
테이블 리스트 등 조회용 컴포넌트의 ConfigPanel에서:
```tsx
// 현재 선택된 테이블 카드 형태로 표시
<div className="flex items-center gap-2 rounded-md border bg-slate-50 p-2">
<Database className="h-4 w-4 text-blue-500" />
<div className="flex-1">
<div className="text-xs font-medium">
{config.customTableName || screenTableName || "테이블 미선택"}
</div>
<div className="text-[10px] text-muted-foreground">
{config.useCustomTable ? "커스텀 테이블" : "화면 기본 테이블"}
</div>
</div>
</div>
// 테이블 선택 Combobox (기본/전체 그룹)
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="w-full justify-between">
테이블 변경...
<ChevronsUpDown className="ml-2 h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0">
<Command>
<CommandInput placeholder="테이블 검색..." />
<CommandList>
{/* 그룹 1: 화면 기본 테이블 */}
{screenTableName && (
<CommandGroup heading="기본 (화면 테이블)">
<CommandItem
value={screenTableName}
onSelect={() => {
handleChange("useCustomTable", false);
handleChange("customTableName", undefined);
handleChange("selectedTable", screenTableName);
handleChange("columns", []); // 테이블 변경 시 컬럼 초기화
}}
>
<Database className="mr-2 h-3 w-3 text-blue-500" />
{screenTableName}
</CommandItem>
</CommandGroup>
)}
{/* 그룹 2: 전체 테이블 */}
<CommandGroup heading="전체 테이블">
{availableTables
.filter((table) => table.tableName !== screenTableName)
.map((table) => (
<CommandItem
key={table.tableName}
value={table.tableName}
onSelect={() => {
handleChange("useCustomTable", true);
handleChange("customTableName", table.tableName);
handleChange("selectedTable", table.tableName);
handleChange("columns", []); // 테이블 변경 시 컬럼 초기화
}}
>
<Table2 className="mr-2 h-3 w-3 text-slate-400" />
{table.displayName || table.tableName}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
// 읽기전용 설정
<div className="flex items-center space-x-2">
<Checkbox
checked={config.isReadOnly || false}
onCheckedChange={(checked) => handleChange("isReadOnly", checked)}
/>
<Label className="text-xs">읽기전용 (조회만 가능)</Label>
</div>
```
### 저장 테이블 설정 UI 표준 (리피터)
리피터 등 저장 기능이 있는 컴포넌트의 ConfigPanel에서:

View File

@ -4076,12 +4076,17 @@ export class TableManagementService {
// table_type_columns에서 입력타입 정보 조회
// 회사별 설정 우선, 없으면 기본 설정(*) fallback
// detail_settings 컬럼에 유효하지 않은 JSON이 있을 수 있으므로 안전하게 처리
const rawInputTypes = await query<any>(
`SELECT DISTINCT ON (ttc.column_name)
ttc.column_name as "columnName",
COALESCE(cl.column_label, ttc.column_name) as "displayName",
ttc.input_type as "inputType",
COALESCE(ttc.detail_settings::jsonb, '{}'::jsonb) as "detailSettings",
CASE
WHEN ttc.detail_settings IS NULL OR ttc.detail_settings = '' THEN '{}'::jsonb
WHEN ttc.detail_settings ~ '^\\s*\\{.*\\}\\s*$' THEN ttc.detail_settings::jsonb
ELSE '{}'::jsonb
END as "detailSettings",
ttc.is_nullable as "isNullable",
ic.data_type as "dataType",
ttc.company_code as "companyCode"

View File

@ -0,0 +1,150 @@
# 컴포넌트 기능 현황
> 작성일: 2026-01-15
> 현재 사용 가능한 16개 컴포넌트의 다국어 지원 및 테이블 설정 기능 현황
---
## 요약
| 기능 | 적용 완료 | 미적용 | 해당없음 |
| -------------------------- | --------- | ------ | -------- |
| **다국어 지원** | 3개 | 10개 | 3개 |
| **컴포넌트별 테이블 설정** | 5개 | 5개 | 6개 |
---
## 컴포넌트별 상세 현황
### 데이터 표시 (Display) - 4개
| 컴포넌트 | 다국어 지원 | 테이블 설정 | 비고 |
| ------------------- | :---------: | :---------: | ------------------------------------------------------------------- |
| **테이블 리스트** | ✅ 적용 | ✅ 적용 | `customTableName`, `useCustomTable` 지원 |
| **카드 디스플레이** | ❌ 미적용 | ✅ 적용 | Combobox UI로 테이블 선택, `customTableName`, `useCustomTable` 지원 |
| **텍스트 표시** | ❌ 미적용 | 해당없음 | 정적 텍스트 표시용 |
| **피벗 그리드** | ❌ 미적용 | ⚠️ 부분 | `tableName` 설정 가능하나 Combobox UI 없음 |
---
### 데이터 입력 (Data) - 2개
| 컴포넌트 | 다국어 지원 | 테이블 설정 | 비고 |
| -------------------- | :---------: | :---------: | ---------------------------------------------------------- |
| **통합 반복 데이터** | ❌ 미적용 | ✅ 적용 | `mainTableName`, `foreignKeyColumn` 지원, Combobox UI 적용 |
| **반복 화면 모달** | ❌ 미적용 | ⚠️ 부분 | `tableName` 설정 가능하나 Combobox UI 없음 |
---
### 액션 (Action) - 1개
| 컴포넌트 | 다국어 지원 | 테이블 설정 | 비고 |
| ------------- | :---------: | :---------: | --------------------------- |
| **기본 버튼** | ✅ 적용 | 해당없음 | `langKeyId`, `langKey` 지원 |
---
### 레이아웃 (Layout) - 5개
| 컴포넌트 | 다국어 지원 | 테이블 설정 | 비고 |
| ----------------- | :---------: | :---------: | --------------------------------------------- |
| **분할 패널** | ✅ 적용 | ⚠️ 부분 | 다국어 지원, 테이블 설정은 하위 패널에서 처리 |
| **탭 컴포넌트** | ❌ 미적용 | 해당없음 | 화면 전환용 컨테이너 |
| **Section Card** | ❌ 미적용 | 해당없음 | 그룹화 컨테이너 |
| **Section Paper** | ❌ 미적용 | 해당없음 | 그룹화 컨테이너 |
| **구분선** | ❌ 미적용 | 해당없음 | 시각적 구분용 |
---
### 유틸리티 (Utility) - 4개
| 컴포넌트 | 다국어 지원 | 테이블 설정 | 비고 |
| ---------------------- | :---------: | :---------: | ------------------------------------------- |
| **코드 채번 규칙** | ❌ 미적용 | 해당없음 | 채번 규칙 관리 전용 |
| **렉 구조 설정** | ❌ 미적용 | 해당없음 | 창고 렉 설정 전용 |
| **출발지/도착지 선택** | ❌ 미적용 | ⚠️ 부분 | `customTableName` 지원하나 Combobox UI 없음 |
| **검색 필터** | ❌ 미적용 | ⚠️ 부분 | `screenTableName` 자동 감지 |
---
## 상세 설명
### 다국어 지원 (`langKeyId`, `langKey`)
다국어 지원이란 컴포넌트의 라벨, 플레이스홀더 등 텍스트 속성에 다국어 키를 연결하여 언어별로 다른 텍스트를 표시하는 기능입니다.
**적용 완료 (3개)**
- `table-list`: 컬럼 라벨 다국어 지원
- `button-primary`: 버튼 텍스트 다국어 지원
- `split-panel-layout`: 패널 제목 다국어 지원
**미적용 (10개)**
- `card-display`, `text-display`, `pivot-grid`
- `unified-repeater`, `repeat-screen-modal`
- `tabs`, `section-card`, `section-paper`, `divider-line`
- `numbering-rule`, `rack-structure`, `location-swap-selector`, `table-search-widget`
---
### 컴포넌트별 테이블 설정 (`customTableName`, `useCustomTable`)
컴포넌트별 테이블 설정이란 화면의 메인 테이블과 별개로 컴포넌트가 자체적으로 사용할 테이블을 지정할 수 있는 기능입니다.
**완전 적용 (5개)**
| 컴포넌트 | 적용 방식 |
| ------------------ | --------------------------------------------------------------------------------- |
| `table-list` | Combobox UI로 테이블 선택, `customTableName`, `useCustomTable`, `isReadOnly` 지원 |
| `unified-repeater` | Combobox UI로 테이블 선택, `mainTableName`, `foreignKeyColumn` 지원, FK 자동 연결 |
| `unified-list` | `TableListConfigPanel` 래핑하여 동일 기능 제공 |
| `card-display` | Combobox UI로 테이블 선택, `customTableName`, `useCustomTable` 지원 |
**부분 적용 (5개)**
| 컴포넌트 | 현재 상태 | 필요 작업 |
| ------------------------ | --------------------------- | --------------------- |
| `pivot-grid` | `tableName` 설정 가능 | Combobox UI 추가 필요 |
| `repeat-screen-modal` | `tableName` 설정 가능 | Combobox UI 추가 필요 |
| `split-panel-layout` | 하위 패널에서 처리 | 하위 컴포넌트에 위임 |
| `location-swap-selector` | `customTableName` 지원 | Combobox UI 추가 필요 |
| `table-search-widget` | `screenTableName` 자동 감지 | 현재 방식 유지 가능 |
**해당없음 (6개)**
- `text-display`, `divider-line`: 정적 컴포넌트
- `tabs`, `section-card`, `section-paper`: 레이아웃 컨테이너
- `numbering-rule`, `rack-structure`: 특수 목적 컴포넌트
---
## 우선순위 작업 목록
### 1순위: 데이터 컴포넌트 테이블 설정 UI 통일
| 컴포넌트 | 작업 내용 | 상태 |
| --------------------- | ---------------------------------------- | ------- |
| `card-display` | Combobox UI 추가, `customTableName` 지원 | ✅ 완료 |
| `pivot-grid` | Combobox UI 추가 | 대기 |
| `repeat-screen-modal` | Combobox UI 추가 | 대기 |
### 2순위: 다국어 지원 확대
| 컴포넌트 | 작업 내용 |
| ------------------ | -------------------------- |
| `unified-repeater` | 컬럼 라벨 `langKeyId` 지원 |
| `card-display` | 필드 라벨 `langKeyId` 지원 |
| `tabs` | 탭 이름 `langKeyId` 지원 |
| `section-card` | 제목 `langKeyId` 지원 |
---
## 범례
| 기호 | 의미 |
| ---- | --------------------------------- |
| ✅ | 완전 적용 |
| ⚠️ | 부분 적용 (기능은 있으나 UI 미비) |
| ❌ | 미적용 |
| | 해당없음 (기능 불필요) |

View File

@ -66,14 +66,7 @@ export function ComponentsPanel({
// unified-date: 테이블 컬럼 드래그 시 자동 생성되므로 숨김 처리
// unified-layout: 중첩 드래그앤드롭 기능 미구현으로 숨김 처리
// unified-group: 중첩 드래그앤드롭 기능 미구현으로 숨김 처리
{
id: "unified-list",
name: "통합 목록",
description: "테이블, 카드 등 다양한 데이터 표시 방식 지원",
category: "display" as ComponentCategory,
tags: ["table", "list", "card", "unified"],
defaultSize: { width: 600, height: 400 },
},
// unified-list: table-list, card-display로 분리하여 숨김 처리
// unified-media 제거 - 테이블 컬럼의 image/file 입력 타입으로 사용
// unified-biz 제거 - 개별 컴포넌트(flow-widget, rack-structure, numbering-rule)로 직접 표시
// unified-hierarchy 제거 - 현재 미사용
@ -113,8 +106,7 @@ export function ComponentsPanel({
// 특수 업무용 컴포넌트 (일반 화면에서 불필요)
"tax-invoice-list", // 세금계산서 전용
"customer-item-mapping", // 고객-품목 매핑 전용
// unified-list로 통합됨
"card-display", // → unified-list (card 모드)
// card-display는 별도 컴포넌트로 유지
// unified-media로 통합됨
"image-display", // → unified-media (image)
// 공통코드관리로 통합 예정
@ -128,6 +120,12 @@ export function ComponentsPanel({
"universal-form-modal", // 범용 폼 모달
// 통합 미디어 (테이블 컬럼 입력 타입으로 사용)
"unified-media", // → 테이블 컬럼의 image/file 입력 타입으로 사용
// 플로우 위젯 숨김 처리
"flow-widget",
// 선택 항목 상세입력 - 기존 컴포넌트 조합으로 대체 가능
"selected-items-detail-input",
// 연관 데이터 버튼 - unified-repeater로 대체 가능
"related-data-buttons",
];
return {

View File

@ -2,21 +2,13 @@
/**
* UnifiedList
* .
* -
* - +
* TableListConfigPanel을 .
* card-display .
*/
import React, { useState, useEffect, useMemo } from "react";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Checkbox } from "@/components/ui/checkbox";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Database, Link2, GripVertical, ChevronDown, ChevronRight } from "lucide-react";
import { tableTypeApi } from "@/lib/api/screen";
import { cn } from "@/lib/utils";
import React, { useMemo } from "react";
import { TableListConfigPanel } from "@/lib/registry/components/table-list/TableListConfigPanel";
import { TableListConfig } from "@/lib/registry/components/table-list/types";
interface UnifiedListConfigPanelProps {
config: Record<string, any>;
@ -25,476 +17,148 @@ interface UnifiedListConfigPanelProps {
currentTableName?: string;
}
interface ColumnOption {
columnName: string;
displayName: string;
isJoinColumn?: boolean;
sourceTable?: string;
inputType?: string;
}
/**
* UnifiedList
* TableListConfigPanel과
*/
export const UnifiedListConfigPanel: React.FC<UnifiedListConfigPanelProps> = ({
config,
onChange,
currentTableName,
}) => {
// 컬럼 목록 (테이블 컬럼 + 엔티티 조인 컬럼)
const [columns, setColumns] = useState<ColumnOption[]>([]);
const [loadingColumns, setLoadingColumns] = useState(false);
const [expandedJoinSections, setExpandedJoinSections] = useState<Set<string>>(new Set());
// UnifiedList config를 TableListConfig 형식으로 변환
const tableListConfig: TableListConfig = useMemo(() => {
// 컬럼 형식 변환: UnifiedList columns -> TableList columns
const columns = (config.columns || []).map((col: any, index: number) => ({
columnName: col.key || col.columnName || col.field || "",
displayName: col.title || col.header || col.displayName || col.key || col.columnName || col.field || "",
width: col.width ? parseInt(col.width, 10) : undefined,
visible: col.visible !== false,
sortable: col.sortable !== false,
searchable: col.searchable !== false,
align: col.align || "left",
order: index,
isEntityJoin: col.isJoinColumn || col.isEntityJoin || false,
thousandSeparator: col.thousandSeparator,
editable: col.editable,
entityDisplayConfig: col.entityDisplayConfig,
}));
// 설정 업데이트 핸들러
const updateConfig = (field: string, value: any) => {
const newConfig = { ...config, [field]: value };
console.log("⚙️ UnifiedListConfigPanel updateConfig:", { field, value, newConfig });
return {
selectedTable: config.tableName || config.dataSource?.table || currentTableName,
tableName: config.tableName || config.dataSource?.table || currentTableName,
columns,
useCustomTable: config.useCustomTable,
customTableName: config.customTableName,
isReadOnly: config.isReadOnly !== false, // UnifiedList는 기본적으로 읽기 전용
displayMode: "table", // 테이블 모드 고정 (카드는 card-display 컴포넌트 사용)
pagination: config.pagination !== false ? {
enabled: true,
pageSize: config.pageSize || 10,
position: "bottom",
showPageSize: true,
pageSizeOptions: [5, 10, 20, 50, 100],
} : {
enabled: false,
pageSize: 10,
position: "bottom",
showPageSize: false,
pageSizeOptions: [10],
},
filter: config.filter,
dataFilter: config.dataFilter,
checkbox: {
enabled: true,
position: "left",
showHeader: true,
},
height: "auto",
autoWidth: true,
stickyHeader: true,
autoLoad: true,
horizontalScroll: {
enabled: true,
minColumnWidth: 100,
maxColumnWidth: 300,
},
};
}, [config, currentTableName]);
// TableListConfig 변경을 UnifiedList config 형식으로 변환
const handleConfigChange = (partialConfig: Partial<TableListConfig>) => {
const newConfig: Record<string, any> = { ...config };
// 테이블 설정 변환
if (partialConfig.selectedTable !== undefined) {
newConfig.tableName = partialConfig.selectedTable;
if (!newConfig.dataSource) {
newConfig.dataSource = {};
}
newConfig.dataSource.table = partialConfig.selectedTable;
}
if (partialConfig.tableName !== undefined) {
newConfig.tableName = partialConfig.tableName;
if (!newConfig.dataSource) {
newConfig.dataSource = {};
}
newConfig.dataSource.table = partialConfig.tableName;
}
if (partialConfig.useCustomTable !== undefined) {
newConfig.useCustomTable = partialConfig.useCustomTable;
}
if (partialConfig.customTableName !== undefined) {
newConfig.customTableName = partialConfig.customTableName;
}
if (partialConfig.isReadOnly !== undefined) {
newConfig.isReadOnly = partialConfig.isReadOnly;
}
// 컬럼 형식 변환: TableList columns -> UnifiedList columns
if (partialConfig.columns !== undefined) {
newConfig.columns = partialConfig.columns.map((col: any) => ({
key: col.columnName,
field: col.columnName,
title: col.displayName,
header: col.displayName,
width: col.width ? String(col.width) : undefined,
visible: col.visible,
sortable: col.sortable,
searchable: col.searchable,
align: col.align,
isJoinColumn: col.isEntityJoin,
isEntityJoin: col.isEntityJoin,
thousandSeparator: col.thousandSeparator,
editable: col.editable,
entityDisplayConfig: col.entityDisplayConfig,
}));
}
// 페이지네이션 변환
if (partialConfig.pagination !== undefined) {
newConfig.pagination = partialConfig.pagination?.enabled;
newConfig.pageSize = partialConfig.pagination?.pageSize || 10;
}
// 필터 변환
if (partialConfig.filter !== undefined) {
newConfig.filter = partialConfig.filter;
}
// 데이터 필터 변환
if (partialConfig.dataFilter !== undefined) {
newConfig.dataFilter = partialConfig.dataFilter;
}
console.log("⚙️ UnifiedListConfigPanel handleConfigChange:", { partialConfig, newConfig });
onChange(newConfig);
};
// 테이블명 (현재 화면의 테이블 사용)
const tableName = currentTableName || config.tableName;
// 화면의 테이블명을 config에 자동 저장
useEffect(() => {
if (currentTableName && config.tableName !== currentTableName) {
onChange({ ...config, tableName: currentTableName });
}
}, [currentTableName]);
// 테이블 컬럼 및 엔티티 조인 컬럼 로드
useEffect(() => {
const loadColumns = async () => {
if (!tableName) {
setColumns([]);
return;
}
setLoadingColumns(true);
try {
// 1. 테이블 컬럼 로드
const columnData = await tableTypeApi.getColumns(tableName);
const baseColumns: ColumnOption[] = columnData.map((c: any) => ({
columnName: c.columnName || c.column_name,
displayName: c.displayName || c.columnLabel || c.columnName || c.column_name,
isJoinColumn: false,
inputType: c.inputType || c.input_type || c.webType || c.web_type,
}));
// 2. 엔티티 타입 컬럼 찾기 및 조인 컬럼 정보 로드
const entityColumns = columnData.filter((c: any) => (c.inputType || c.input_type) === "entity");
const joinColumnOptions: ColumnOption[] = [];
for (const entityCol of entityColumns) {
const colName = entityCol.columnName || entityCol.column_name;
// referenceTable 우선순위:
// 1. 컬럼의 reference_table 필드
// 2. detailSettings.referenceTable
let referenceTable = entityCol.referenceTable || entityCol.reference_table;
if (!referenceTable) {
let detailSettings = entityCol.detailSettings || entityCol.detail_settings;
if (typeof detailSettings === "string") {
try {
detailSettings = JSON.parse(detailSettings);
} catch {
continue;
}
}
referenceTable = detailSettings?.referenceTable;
}
if (referenceTable) {
try {
const refColumnData = await tableTypeApi.getColumns(referenceTable);
refColumnData.forEach((refCol: any) => {
const refColName = refCol.columnName || refCol.column_name;
const refDisplayName = refCol.displayName || refCol.columnLabel || refColName;
joinColumnOptions.push({
columnName: `${colName}.${refColName}`,
displayName: refDisplayName,
isJoinColumn: true,
sourceTable: referenceTable,
});
});
} catch (error) {
console.error(`참조 테이블 ${referenceTable} 컬럼 로드 실패:`, error);
}
}
}
setColumns([...baseColumns, ...joinColumnOptions]);
} catch (error) {
console.error("컬럼 목록 로드 실패:", error);
setColumns([]);
} finally {
setLoadingColumns(false);
}
};
loadColumns();
}, [tableName]);
// 컬럼 설정
const configColumns: Array<{
key: string;
title: string;
width?: string;
isJoinColumn?: boolean;
inputType?: string;
thousandSeparator?: boolean;
}> = config.columns || [];
// 컬럼이 추가되었는지 확인
const isColumnAdded = (columnName: string) => {
return configColumns.some((col) => col.key === columnName);
};
// 컬럼 토글 (추가/제거)
const toggleColumn = (column: ColumnOption) => {
if (isColumnAdded(column.columnName)) {
// 제거
const newColumns = configColumns.filter((col) => col.key !== column.columnName);
updateConfig("columns", newColumns);
} else {
// 추가
const isNumberType = ["number", "decimal", "integer", "float", "double", "numeric", "currency"].includes(
column.inputType || "",
);
const newColumn = {
key: column.columnName,
title: column.displayName,
width: "",
isJoinColumn: column.isJoinColumn || false,
inputType: column.inputType,
thousandSeparator: isNumberType ? true : undefined, // 숫자 타입은 기본적으로 천단위 구분자 사용
};
updateConfig("columns", [...configColumns, newColumn]);
}
};
// 컬럼 천단위 구분자 토글
const toggleThousandSeparator = (columnKey: string, checked: boolean) => {
const newColumns = configColumns.map((col) => (col.key === columnKey ? { ...col, thousandSeparator: checked } : col));
updateConfig("columns", newColumns);
};
// 숫자 타입 컬럼인지 확인
const isNumberColumn = (columnKey: string) => {
const colInfo = columns.find((c) => c.columnName === columnKey);
const configCol = configColumns.find((c) => c.key === columnKey);
const inputType = configCol?.inputType || colInfo?.inputType || "";
return ["number", "decimal", "integer", "float", "double", "numeric", "currency"].includes(inputType);
};
// 컬럼 제목 수정
const updateColumnTitle = (columnKey: string, title: string) => {
const newColumns = configColumns.map((col) => (col.key === columnKey ? { ...col, title } : col));
updateConfig("columns", newColumns);
};
// 그룹별 컬럼 분리
const baseColumns = useMemo(() => columns.filter((col) => !col.isJoinColumn), [columns]);
// 조인 컬럼을 소스 테이블별로 그룹화
const joinColumnsByTable = useMemo(() => {
const grouped: Record<string, ColumnOption[]> = {};
columns
.filter((col) => col.isJoinColumn)
.forEach((col) => {
const table = col.sourceTable || "unknown";
if (!grouped[table]) {
grouped[table] = [];
}
grouped[table].push(col);
});
return grouped;
}, [columns]);
// 조인 섹션 토글
const toggleJoinSection = (tableName: string) => {
setExpandedJoinSections((prev) => {
const newSet = new Set(prev);
if (newSet.has(tableName)) {
newSet.delete(tableName);
} else {
newSet.add(tableName);
}
return newSet;
});
};
return (
<div className="space-y-4">
{/* 뷰 모드 */}
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
<Select value={config.viewMode || "table"} onValueChange={(value) => updateConfig("viewMode", value)}>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="방식 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="table"></SelectItem>
<SelectItem value="card"></SelectItem>
</SelectContent>
</Select>
</div>
{/* 카드 모드 설정 */}
{config.viewMode === "card" && (
<>
<Separator />
<div className="space-y-3">
<Label className="text-xs font-medium"> </Label>
{/* 제목 컬럼 */}
<div className="space-y-1">
<Label className="text-muted-foreground text-[10px]"> </Label>
<Select
value={config.cardConfig?.titleColumn || ""}
onValueChange={(value) => updateConfig("cardConfig", { ...config.cardConfig, titleColumn: value })}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{configColumns.map((col: any) => (
<SelectItem key={col.key} value={col.key}>
{col.title}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 부제목 컬럼 */}
<div className="space-y-1">
<Label className="text-muted-foreground text-[10px]"> </Label>
<Select
value={config.cardConfig?.subtitleColumn || "_none_"}
onValueChange={(value) =>
updateConfig("cardConfig", { ...config.cardConfig, subtitleColumn: value === "_none_" ? "" : value })
}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="선택 (선택사항)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="_none_"></SelectItem>
{configColumns.map((col: any) => (
<SelectItem key={col.key} value={col.key}>
{col.title}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 행당 카드 수 */}
<div className="space-y-1">
<Label className="text-muted-foreground text-[10px]"> </Label>
<Select
value={String(config.cardConfig?.cardsPerRow || 3)}
onValueChange={(value) =>
updateConfig("cardConfig", { ...config.cardConfig, cardsPerRow: parseInt(value) })
}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">1</SelectItem>
<SelectItem value="2">2</SelectItem>
<SelectItem value="3">3</SelectItem>
<SelectItem value="4">4</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</>
)}
<Separator />
{/* 컬럼 선택 */}
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
{loadingColumns ? (
<p className="text-muted-foreground py-2 text-xs"> ...</p>
) : !tableName ? (
<p className="text-muted-foreground py-2 text-xs"> </p>
) : (
<div className="max-h-60 space-y-2 overflow-y-auto rounded-md border p-2">
{/* 테이블 컬럼 */}
<div className="space-y-0.5">
{baseColumns.map((column) => (
<div
key={column.columnName}
className={cn(
"hover:bg-muted/50 flex cursor-pointer items-center gap-2 rounded px-2 py-1",
isColumnAdded(column.columnName) && "bg-primary/10",
)}
onClick={() => toggleColumn(column)}
>
<Checkbox
checked={isColumnAdded(column.columnName)}
onCheckedChange={() => toggleColumn(column)}
className="pointer-events-none h-3.5 w-3.5 flex-shrink-0"
/>
<Database className="text-muted-foreground h-3 w-3 flex-shrink-0" />
<span className="truncate text-xs">{column.displayName}</span>
</div>
))}
</div>
{/* 조인 컬럼 (테이블별 그룹) */}
{Object.keys(joinColumnsByTable).length > 0 && (
<div className="mt-2 border-t pt-2">
<div className="text-muted-foreground mb-1 flex items-center gap-1 text-[10px] font-medium">
<Link2 className="h-3 w-3 text-blue-500" />
</div>
{Object.entries(joinColumnsByTable).map(([refTable, refColumns]) => (
<div key={refTable} className="mb-1">
<div
className="hover:bg-muted/30 flex cursor-pointer items-center gap-1 rounded px-1 py-0.5"
onClick={() => toggleJoinSection(refTable)}
>
{expandedJoinSections.has(refTable) ? (
<ChevronDown className="text-muted-foreground h-3 w-3 flex-shrink-0" />
) : (
<ChevronRight className="text-muted-foreground h-3 w-3 flex-shrink-0" />
)}
<span className="truncate text-[10px] font-medium text-blue-600">{refTable}</span>
<span className="text-muted-foreground text-[10px]">({refColumns.length})</span>
</div>
{expandedJoinSections.has(refTable) && (
<div className="ml-3 space-y-0.5">
{refColumns.map((column) => (
<div
key={column.columnName}
className={cn(
"hover:bg-muted/50 flex cursor-pointer items-center gap-2 rounded px-2 py-1",
isColumnAdded(column.columnName) && "bg-blue-50",
)}
onClick={() => toggleColumn(column)}
>
<Checkbox
checked={isColumnAdded(column.columnName)}
onCheckedChange={() => toggleColumn(column)}
className="pointer-events-none h-3.5 w-3.5 flex-shrink-0"
/>
<span className="min-w-0 flex-1 truncate text-xs">{column.displayName}</span>
</div>
))}
</div>
)}
</div>
))}
</div>
)}
</div>
)}
</div>
{/* 선택된 컬럼 상세 설정 */}
{configColumns.length > 0 && (
<>
<Separator />
<div className="space-y-2">
<Label className="text-xs font-medium"> ({configColumns.length})</Label>
<div className="max-h-40 space-y-1 overflow-y-auto">
{configColumns.map((column, index) => {
const colInfo = columns.find((c) => c.columnName === column.key);
const showThousandSeparator = isNumberColumn(column.key);
return (
<div key={column.key} className="bg-muted/30 space-y-1.5 rounded-md border p-2">
<div className="flex items-center gap-2">
<GripVertical className="text-muted-foreground h-3 w-3 cursor-grab" />
{column.isJoinColumn ? (
<Link2 className="h-3 w-3 flex-shrink-0 text-blue-500" />
) : (
<Database className="text-muted-foreground h-3 w-3 flex-shrink-0" />
)}
<Input
value={column.title}
onChange={(e) => updateColumnTitle(column.key, e.target.value)}
placeholder="제목"
className="h-6 flex-1 text-xs"
/>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => toggleColumn(colInfo || { columnName: column.key, displayName: column.title })}
className="text-destructive h-6 w-6 p-0"
>
×
</Button>
</div>
{/* 숫자 컬럼인 경우 천단위 구분자 옵션 표시 */}
{showThousandSeparator && (
<div className="ml-5 flex items-center gap-2">
<Checkbox
id={`thousand-${column.key}`}
checked={column.thousandSeparator !== false}
onCheckedChange={(checked) => toggleThousandSeparator(column.key, !!checked)}
className="h-3 w-3"
/>
<label htmlFor={`thousand-${column.key}`} className="text-muted-foreground text-[10px]">
</label>
</div>
)}
</div>
);
})}
</div>
</div>
</>
)}
<Separator />
{/* 페이지네이션 설정 */}
<div className="space-y-3">
<div className="flex items-center space-x-2">
<Checkbox
id="pagination"
checked={config.pagination !== false}
onCheckedChange={(checked) => updateConfig("pagination", checked)}
/>
<label htmlFor="pagination" className="text-xs font-medium">
</label>
</div>
{config.pagination !== false && (
<div className="space-y-2">
<Label className="text-xs"> </Label>
<Select
value={String(config.pageSize || 10)}
onValueChange={(value) => updateConfig("pageSize", Number(value))}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="5">5</SelectItem>
<SelectItem value="10">10</SelectItem>
<SelectItem value="20">20</SelectItem>
<SelectItem value="50">50</SelectItem>
<SelectItem value="100">100</SelectItem>
</SelectContent>
</Select>
</div>
)}
</div>
</div>
<TableListConfigPanel
config={tableListConfig}
onChange={handleConfigChange}
screenTableName={currentTableName}
/>
);
};

View File

@ -1,7 +1,8 @@
"use client";
import React, { useState, useEffect } from "react";
import React, { useState, useEffect, useMemo } from "react";
import { entityJoinApi } from "@/lib/api/entityJoin";
import { tableManagementApi } from "@/lib/api/tableManagement";
import {
Select,
SelectContent,
@ -11,11 +12,14 @@ import {
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 { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Trash2 } from "lucide-react";
import { Trash2, Database, ChevronsUpDown, Check } from "lucide-react";
import { cn } from "@/lib/utils";
interface CardDisplayConfigPanelProps {
config: any;
@ -57,6 +61,13 @@ export const CardDisplayConfigPanel: React.FC<CardDisplayConfigPanelProps> = ({
screenTableName,
tableColumns = [],
}) => {
// 테이블 선택 상태
const [tableComboboxOpen, setTableComboboxOpen] = useState(false);
const [allTables, setAllTables] = useState<Array<{ tableName: string; displayName: string }>>([]);
const [loadingTables, setLoadingTables] = useState(false);
const [availableColumns, setAvailableColumns] = useState<any[]>([]);
const [loadingColumns, setLoadingColumns] = useState(false);
// 엔티티 조인 컬럼 상태
const [entityJoinColumns, setEntityJoinColumns] = useState<{
availableColumns: EntityJoinColumn[];
@ -64,18 +75,80 @@ export const CardDisplayConfigPanel: React.FC<CardDisplayConfigPanelProps> = ({
}>({ availableColumns: [], joinTables: [] });
const [loadingEntityJoins, setLoadingEntityJoins] = useState(false);
// 현재 사용할 테이블명
const targetTableName = useMemo(() => {
if (config.useCustomTable && config.customTableName) {
return config.customTableName;
}
return config.tableName || screenTableName;
}, [config.useCustomTable, config.customTableName, config.tableName, screenTableName]);
// 전체 테이블 목록 로드
useEffect(() => {
const loadAllTables = async () => {
setLoadingTables(true);
try {
const response = await tableManagementApi.getTableList();
if (response.success && response.data) {
setAllTables(response.data.map((t: any) => ({
tableName: t.tableName || t.table_name,
displayName: t.tableLabel || t.displayName || t.tableName || t.table_name,
})));
}
} catch (error) {
console.error("테이블 목록 로드 실패:", error);
} finally {
setLoadingTables(false);
}
};
loadAllTables();
}, []);
// 선택된 테이블의 컬럼 로드
useEffect(() => {
const loadColumns = async () => {
if (!targetTableName) {
setAvailableColumns([]);
return;
}
// 커스텀 테이블이 아니면 props로 받은 tableColumns 사용
if (!config.useCustomTable && tableColumns && tableColumns.length > 0) {
setAvailableColumns(tableColumns);
return;
}
setLoadingColumns(true);
try {
const result = await tableManagementApi.getColumnList(targetTableName);
if (result.success && result.data?.columns) {
setAvailableColumns(result.data.columns.map((col: any) => ({
columnName: col.columnName,
columnLabel: col.displayName || col.columnLabel || col.columnName,
dataType: col.dataType,
})));
}
} catch (error) {
console.error("컬럼 목록 로드 실패:", error);
setAvailableColumns([]);
} finally {
setLoadingColumns(false);
}
};
loadColumns();
}, [targetTableName, config.useCustomTable, tableColumns]);
// 엔티티 조인 컬럼 정보 가져오기
useEffect(() => {
const fetchEntityJoinColumns = async () => {
const tableName = config.tableName || screenTableName;
if (!tableName) {
if (!targetTableName) {
setEntityJoinColumns({ availableColumns: [], joinTables: [] });
return;
}
setLoadingEntityJoins(true);
try {
const result = await entityJoinApi.getEntityJoinColumns(tableName);
const result = await entityJoinApi.getEntityJoinColumns(targetTableName);
setEntityJoinColumns({
availableColumns: result.availableColumns || [],
joinTables: result.joinTables || [],
@ -89,7 +162,38 @@ export const CardDisplayConfigPanel: React.FC<CardDisplayConfigPanelProps> = ({
};
fetchEntityJoinColumns();
}, [config.tableName, screenTableName]);
}, [targetTableName]);
// 테이블 선택 핸들러
const handleTableSelect = (tableName: string, isScreenTable: boolean) => {
if (isScreenTable) {
// 화면 기본 테이블 선택
onChange({
...config,
useCustomTable: false,
customTableName: undefined,
tableName: tableName,
columnMapping: { displayColumns: [] }, // 컬럼 매핑 초기화
});
} else {
// 다른 테이블 선택
onChange({
...config,
useCustomTable: true,
customTableName: tableName,
tableName: tableName,
columnMapping: { displayColumns: [] }, // 컬럼 매핑 초기화
});
}
setTableComboboxOpen(false);
};
// 현재 선택된 테이블 표시명 가져오기
const getSelectedTableDisplay = () => {
if (!targetTableName) return "테이블을 선택하세요";
const found = allTables.find(t => t.tableName === targetTableName);
return found?.displayName || targetTableName;
};
const handleChange = (key: string, value: any) => {
onChange({ ...config, [key]: value });
@ -219,6 +323,9 @@ export const CardDisplayConfigPanel: React.FC<CardDisplayConfigPanelProps> = ({
joinColumnsByTable[col.tableName].push(col);
});
// 현재 사용할 컬럼 목록 (커스텀 테이블이면 로드한 컬럼, 아니면 props)
const currentTableColumns = config.useCustomTable ? availableColumns : (tableColumns.length > 0 ? tableColumns : availableColumns);
// 컬럼 선택 셀렉트 박스 렌더링 (Shadcn UI)
const renderColumnSelect = (
value: string,
@ -240,12 +347,12 @@ export const CardDisplayConfigPanel: React.FC<CardDisplayConfigPanelProps> = ({
</SelectItem>
{/* 기본 테이블 컬럼 */}
{tableColumns.length > 0 && (
{currentTableColumns.length > 0 && (
<SelectGroup>
<SelectLabel className="text-xs font-semibold text-muted-foreground">
</SelectLabel>
{tableColumns.map((column) => (
{currentTableColumns.map((column: any) => (
<SelectItem
key={column.columnName}
value={column.columnName}
@ -283,13 +390,99 @@ export const CardDisplayConfigPanel: React.FC<CardDisplayConfigPanelProps> = ({
<div className="space-y-4">
<div className="text-sm font-medium"> </div>
{/* 테이블 선택 */}
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
<Popover open={tableComboboxOpen} onOpenChange={setTableComboboxOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={tableComboboxOpen}
className="h-9 w-full justify-between text-xs"
disabled={loadingTables}
>
<div className="flex items-center gap-2 truncate">
<Database className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="truncate">{getSelectedTableDisplay()}</span>
</div>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[300px] p-0" align="start">
<Command>
<CommandInput placeholder="테이블 검색..." className="text-xs" />
<CommandList>
<CommandEmpty className="py-3 text-center text-xs text-muted-foreground">
.
</CommandEmpty>
{/* 화면 기본 테이블 */}
{screenTableName && (
<CommandGroup heading="기본 (화면 테이블)">
<CommandItem
value={screenTableName}
onSelect={() => handleTableSelect(screenTableName, true)}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-4 w-4",
targetTableName === screenTableName && !config.useCustomTable
? "opacity-100"
: "opacity-0"
)}
/>
<Database className="mr-2 h-3.5 w-3.5 text-blue-500" />
{allTables.find(t => t.tableName === screenTableName)?.displayName || screenTableName}
</CommandItem>
</CommandGroup>
)}
{/* 전체 테이블 */}
<CommandGroup heading="전체 테이블">
{allTables
.filter(t => t.tableName !== screenTableName)
.map((table) => (
<CommandItem
key={table.tableName}
value={table.tableName}
onSelect={() => handleTableSelect(table.tableName, false)}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-4 w-4",
config.useCustomTable && targetTableName === table.tableName
? "opacity-100"
: "opacity-0"
)}
/>
<Database className="mr-2 h-3.5 w-3.5 text-muted-foreground" />
<span className="truncate">{table.displayName}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{config.useCustomTable && (
<p className="text-[10px] text-muted-foreground">
.
</p>
)}
</div>
{/* 테이블이 선택된 경우 컬럼 매핑 설정 */}
{tableColumns && tableColumns.length > 0 && (
{(currentTableColumns.length > 0 || loadingColumns) && (
<div className="space-y-3">
<h5 className="text-xs font-medium text-muted-foreground"> </h5>
{loadingEntityJoins && (
<div className="text-xs text-muted-foreground"> ...</div>
{(loadingEntityJoins || loadingColumns) && (
<div className="text-xs text-muted-foreground">
{loadingColumns ? "컬럼 로딩 중..." : "조인 컬럼 로딩 중..."}
</div>
)}
<div className="space-y-1">

View File

@ -45,6 +45,12 @@ export interface CardDisplayConfig extends ComponentConfig {
// 컬럼 매핑 설정
columnMapping?: ColumnMappingConfig;
// 컴포넌트별 테이블 설정
useCustomTable?: boolean;
customTableName?: string;
tableName?: string;
isReadOnly?: boolean;
// 테이블 데이터 설정
dataSource?: "static" | "table" | "api";
tableId?: string;