1111 lines
33 KiB
Plaintext
1111 lines
33 KiB
Plaintext
---
|
|
description: 화면 컴포넌트 개발 시 필수 가이드 - V2 컴포넌트, 엔티티 조인, 폼 데이터, 다국어 지원
|
|
alwaysApply: false
|
|
---
|
|
|
|
# 화면 컴포넌트 개발 가이드
|
|
|
|
새로운 화면 컴포넌트를 개발할 때 반드시 따라야 하는 핵심 원칙과 패턴을 설명합니다.
|
|
이 가이드는 컴포넌트가 시스템의 핵심 기능(엔티티 조인, 다국어, 폼 데이터 관리 등)과
|
|
올바르게 통합되도록 하는 방법을 설명합니다.
|
|
|
|
---
|
|
|
|
## 목차
|
|
|
|
0. [V2 컴포넌트 규칙 (최우선)](#0-v2-컴포넌트-규칙-최우선)
|
|
1. [컴포넌트별 테이블 설정 (핵심 원칙)](#1-컴포넌트별-테이블-설정-핵심-원칙)
|
|
2. [엔티티 조인 컬럼 활용 (필수)](#2-엔티티-조인-컬럼-활용-필수)
|
|
3. [폼 데이터 관리](#3-폼-데이터-관리)
|
|
4. [다국어 지원](#4-다국어-지원)
|
|
5. [컬럼 설정 패널 구현](#5-컬럼-설정-패널-구현)
|
|
6. [체크리스트](#6-체크리스트)
|
|
|
|
---
|
|
|
|
## 0. V2 컴포넌트 규칙 (최우선)
|
|
|
|
### 핵심 원칙
|
|
|
|
**화면관리 시스템에서는 반드시 V2 컴포넌트만 사용하고 수정합니다.**
|
|
|
|
원본 컴포넌트(v2 접두사 없는 것)는 더 이상 사용하지 않으며, 모든 수정/개발은 V2 폴더에서 진행합니다.
|
|
|
|
### V2 컴포넌트 목록 (18개)
|
|
|
|
| 컴포넌트 ID | 이름 | 경로 |
|
|
|------------|------|------|
|
|
| `v2-button-primary` | 기본 버튼 | `v2-button-primary/` |
|
|
| `v2-text-display` | 텍스트 표시 | `v2-text-display/` |
|
|
| `v2-divider-line` | 구분선 | `v2-divider-line/` |
|
|
| `v2-table-list` | 테이블 리스트 | `v2-table-list/` |
|
|
| `v2-card-display` | 카드 디스플레이 | `v2-card-display/` |
|
|
| `v2-split-panel-layout` | 분할 패널 | `v2-split-panel-layout/` |
|
|
| `v2-numbering-rule` | 채번 규칙 | `v2-numbering-rule/` |
|
|
| `v2-table-search-widget` | 검색 필터 | `v2-table-search-widget/` |
|
|
| `v2-repeat-screen-modal` | 반복 화면 모달 | `v2-repeat-screen-modal/` |
|
|
| `v2-section-paper` | 섹션 페이퍼 | `v2-section-paper/` |
|
|
| `v2-section-card` | 섹션 카드 | `v2-section-card/` |
|
|
| `v2-tabs-widget` | 탭 위젯 | `v2-tabs-widget/` |
|
|
| `v2-location-swap-selector` | 출발지/도착지 선택 | `v2-location-swap-selector/` |
|
|
| `v2-rack-structure` | 렉 구조 | `v2-rack-structure/` |
|
|
| `v2-unified-repeater` | 통합 리피터 | `v2-unified-repeater/` |
|
|
| `v2-pivot-grid` | 피벗 그리드 | `v2-pivot-grid/` |
|
|
| `v2-aggregation-widget` | 집계 위젯 | `v2-aggregation-widget/` |
|
|
| `v2-repeat-container` | 리피터 컨테이너 | `v2-repeat-container/` |
|
|
|
|
### 파일 경로
|
|
|
|
```
|
|
frontend/lib/registry/components/
|
|
├── v2-button-primary/ ← V2 컴포넌트 (수정 대상)
|
|
├── v2-table-list/ ← V2 컴포넌트 (수정 대상)
|
|
├── v2-split-panel-layout/ ← V2 컴포넌트 (수정 대상)
|
|
├── ...
|
|
├── button-primary/ ← 원본 (수정 금지)
|
|
├── table-list/ ← 원본 (수정 금지)
|
|
├── split-panel-layout/ ← 원본 (수정 금지)
|
|
└── ...
|
|
```
|
|
|
|
### 수정/개발 시 규칙
|
|
|
|
1. **버그 수정**: V2 폴더의 파일만 수정
|
|
2. **기능 추가**: V2 폴더에만 추가
|
|
3. **새 컴포넌트 생성**: `v2-` 접두사로 폴더 생성, ID도 `v2-` 접두사 사용
|
|
4. **원본 폴더는 절대 수정하지 않음**
|
|
|
|
### 컴포넌트 등록
|
|
|
|
V2 컴포넌트는 `frontend/lib/registry/components/index.ts`에서 등록됩니다:
|
|
|
|
```typescript
|
|
// V2 컴포넌트들 (화면관리 전용)
|
|
import "./v2-unified-repeater/UnifiedRepeaterRenderer";
|
|
import "./v2-button-primary/ButtonPrimaryRenderer";
|
|
import "./v2-split-panel-layout/SplitPanelLayoutRenderer";
|
|
// ... 기타 v2 컴포넌트들
|
|
```
|
|
|
|
### Definition 네이밍 규칙
|
|
|
|
V2 컴포넌트의 Definition은 `V2` 접두사를 사용합니다:
|
|
|
|
```typescript
|
|
// index.ts
|
|
export const V2TableListDefinition = createComponentDefinition({
|
|
id: "v2-table-list",
|
|
name: "테이블 리스트",
|
|
// ...
|
|
});
|
|
|
|
// Renderer.tsx
|
|
import { V2TableListDefinition } from "./index";
|
|
|
|
export class TableListRenderer extends AutoRegisteringComponentRenderer {
|
|
static componentDefinition = V2TableListDefinition;
|
|
// ...
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 1. 컴포넌트별 테이블 설정 (핵심 원칙)
|
|
|
|
### 핵심 원칙
|
|
|
|
**하나의 화면에서 여러 테이블을 다룰 수 있습니다.**
|
|
|
|
화면 생성 시 "메인 테이블"을 필수로 지정하지 않으며, 컴포넌트별로 사용할 테이블을 지정할 수 있습니다.
|
|
|
|
### 왜 필요한가?
|
|
|
|
일반적인 ERP 화면에서는 여러 테이블이 동시에 필요합니다:
|
|
|
|
| 예시: 입고 화면 | 테이블 | 용도 |
|
|
| --------------- | ----------------------- | ------------------------------- |
|
|
| 메인 폼 | `receiving_mng` | 입고 마스터 정보 입력/저장 |
|
|
| 조회 리스트 | `purchase_order_detail` | 발주 상세 목록 조회 (읽기 전용) |
|
|
| 입력 리피터 | `receiving_detail` | 입고 상세 항목 입력/저장 |
|
|
|
|
### 컴포넌트 설정 패턴
|
|
|
|
#### 1. 테이블 리스트 (조회용)
|
|
|
|
```typescript
|
|
interface TableListConfig {
|
|
// 조회용 테이블 (화면 메인 테이블과 다를 수 있음)
|
|
customTableName?: string; // 사용할 테이블명
|
|
useCustomTable?: boolean; // true: customTableName 사용
|
|
isReadOnly?: boolean; // true: 조회만, 저장 안 함
|
|
}
|
|
```
|
|
|
|
#### 2. 리피터 (입력/저장용)
|
|
|
|
```typescript
|
|
interface UnifiedRepeaterConfig {
|
|
// 저장 대상 테이블 (화면 메인 테이블과 다를 수 있음)
|
|
mainTableName?: string; // 저장할 테이블명
|
|
useCustomTable?: boolean; // true: mainTableName 사용
|
|
|
|
// FK 자동 연결 (마스터-디테일 관계)
|
|
foreignKeyColumn?: string; // 이 테이블의 FK 컬럼 (예: receiving_id)
|
|
foreignKeySourceColumn?: string; // 마스터 테이블의 PK 컬럼 (예: id)
|
|
}
|
|
```
|
|
|
|
### 조회 테이블 설정 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에서:
|
|
|
|
```tsx
|
|
// 1. 테이블 선택 Combobox
|
|
<Popover open={tableComboboxOpen} onOpenChange={setTableComboboxOpen}>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
role="combobox"
|
|
className="w-full justify-between"
|
|
>
|
|
{selectedTableName || "테이블 선택..."}
|
|
<ChevronsUpDown className="ml-2 h-4 w-4" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="p-0">
|
|
<Command>
|
|
<CommandInput placeholder="테이블 검색..." />
|
|
<CommandList>
|
|
{/* 그룹 1: 현재 화면 테이블 (기본) */}
|
|
<CommandGroup heading="기본">
|
|
<CommandItem value={currentTableName}>
|
|
<Database className="h-3 w-3 text-blue-500" />
|
|
{currentTableName}
|
|
</CommandItem>
|
|
</CommandGroup>
|
|
|
|
{/* 그룹 2: 연관 테이블 (FK 자동 설정) */}
|
|
{relatedTables.length > 0 && (
|
|
<CommandGroup heading="연관 테이블 (FK 자동 설정)">
|
|
{relatedTables.map((table) => (
|
|
<CommandItem key={table.tableName} value={table.tableName}>
|
|
<Link2 className="h-3 w-3 text-green-500" />
|
|
{table.tableName}
|
|
<span className="text-xs text-muted-foreground ml-auto">
|
|
FK: {table.foreignKeyColumn}
|
|
</span>
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
)}
|
|
|
|
{/* 그룹 3: 전체 테이블 */}
|
|
<CommandGroup heading="전체 테이블">
|
|
{allTables.map((table) => (
|
|
<CommandItem key={table.tableName} value={table.tableName}>
|
|
{table.displayName || table.tableName}
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
</CommandList>
|
|
</Command>
|
|
</PopoverContent>
|
|
</Popover>;
|
|
|
|
// 2. 연관 테이블 선택 시 FK/PK 자동 설정
|
|
const handleSaveTableSelect = (tableName: string) => {
|
|
const relation = relatedTables.find((r) => r.tableName === tableName);
|
|
|
|
if (relation) {
|
|
// 엔티티 관계에서 자동으로 FK/PK 가져옴
|
|
updateConfig({
|
|
useCustomTable: true,
|
|
mainTableName: tableName,
|
|
foreignKeyColumn: relation.foreignKeyColumn,
|
|
foreignKeySourceColumn: relation.referenceColumn,
|
|
});
|
|
} else {
|
|
// 연관 테이블이 아니면 수동 입력 필요
|
|
updateConfig({
|
|
useCustomTable: true,
|
|
mainTableName: tableName,
|
|
foreignKeyColumn: undefined,
|
|
foreignKeySourceColumn: undefined,
|
|
});
|
|
}
|
|
};
|
|
```
|
|
|
|
### 연관 테이블 조회 API
|
|
|
|
엔티티 관계에서 현재 테이블을 참조하는 테이블 목록을 조회합니다:
|
|
|
|
```typescript
|
|
// API 호출
|
|
const response = await apiClient.get(
|
|
`/api/table-management/columns/${currentTableName}/referenced-by`
|
|
);
|
|
|
|
// 응답
|
|
{
|
|
success: true,
|
|
data: [
|
|
{
|
|
tableName: "receiving_detail", // 참조하는 테이블
|
|
columnName: "receiving_id", // FK 컬럼
|
|
referenceColumn: "id", // 참조되는 컬럼 (PK)
|
|
},
|
|
// ...
|
|
]
|
|
}
|
|
```
|
|
|
|
### FK 자동 연결 동작
|
|
|
|
마스터 저장 후 디테일 저장 시 FK가 자동으로 설정됩니다:
|
|
|
|
```typescript
|
|
// 1. 마스터 저장 이벤트 발생 (ButtonConfigPanel에서)
|
|
window.dispatchEvent(
|
|
new CustomEvent("repeaterSave", {
|
|
detail: {
|
|
masterRecordId: savedId, // 마스터 테이블에 저장된 ID
|
|
tableName: "receiving_mng",
|
|
mainFormData: formData,
|
|
},
|
|
})
|
|
);
|
|
|
|
// 2. 리피터에서 이벤트 수신 및 FK 설정
|
|
useEffect(() => {
|
|
const handleSaveEvent = (event: CustomEvent) => {
|
|
const { masterRecordId } = event.detail;
|
|
|
|
if (config.foreignKeyColumn && masterRecordId) {
|
|
// 모든 행에 FK 값 자동 설정
|
|
const updatedRows = rows.map((row) => ({
|
|
...row,
|
|
[config.foreignKeyColumn]: masterRecordId,
|
|
}));
|
|
|
|
// 저장 실행
|
|
saveRows(updatedRows);
|
|
}
|
|
};
|
|
|
|
window.addEventListener("repeaterSave", handleSaveEvent);
|
|
return () => window.removeEventListener("repeaterSave", handleSaveEvent);
|
|
}, [config.foreignKeyColumn, rows]);
|
|
```
|
|
|
|
### 저장 테이블 변경 시 컬럼 자동 로드
|
|
|
|
저장 테이블이 변경되면 해당 테이블의 컬럼이 자동으로 로드됩니다:
|
|
|
|
```typescript
|
|
// 저장 테이블 또는 화면 테이블 기준으로 컬럼 로드
|
|
const targetTableForColumns =
|
|
config.useCustomTable && config.mainTableName
|
|
? config.mainTableName
|
|
: currentTableName;
|
|
|
|
useEffect(() => {
|
|
const loadColumns = async () => {
|
|
if (!targetTableForColumns) return;
|
|
|
|
const columnData = await tableTypeApi.getColumns(targetTableForColumns);
|
|
setCurrentTableColumns(columnData);
|
|
};
|
|
|
|
loadColumns();
|
|
}, [targetTableForColumns]);
|
|
```
|
|
|
|
### 요약
|
|
|
|
| 상황 | 처리 방법 |
|
|
| ------------------------------------- | ----------------------------------- |
|
|
| 화면과 같은 테이블에 저장 | `useCustomTable: false` (기본값) |
|
|
| 다른 테이블에 저장 + 엔티티 관계 있음 | 연관 테이블 선택 → FK/PK 자동 설정 |
|
|
| 다른 테이블에 저장 + 엔티티 관계 없음 | 전체 테이블에서 선택 → FK 수동 입력 |
|
|
| 조회만 (저장 안 함) | `isReadOnly: true` 설정 |
|
|
|
|
---
|
|
|
|
## 2. 엔티티 조인 컬럼 활용 (필수)
|
|
|
|
### 핵심 원칙
|
|
|
|
**화면을 새로 만들어서 화면 안에 넣는 방식을 사용하지 않습니다.**
|
|
|
|
대신, 현재 화면의 메인 테이블을 기준으로 테이블 타입관리의 엔티티 관계를 불러와서
|
|
조인되어 있는 컬럼들을 모두 사용 가능하게 해야 합니다.
|
|
|
|
### API 사용법
|
|
|
|
```typescript
|
|
import { entityJoinApi } from "@/lib/api/entityJoin";
|
|
|
|
// 테이블의 엔티티 조인 컬럼 정보 가져오기
|
|
const result = await entityJoinApi.getEntityJoinColumns(tableName);
|
|
|
|
// 응답 구조
|
|
{
|
|
tableName: string;
|
|
joinTables: Array<{
|
|
tableName: string; // 조인 테이블명 (예: item_info)
|
|
currentDisplayColumn: string; // 현재 표시 컬럼
|
|
availableColumns: Array<{
|
|
// 사용 가능한 컬럼들
|
|
columnName: string;
|
|
columnLabel: string;
|
|
dataType: string;
|
|
description?: string;
|
|
}>;
|
|
}>;
|
|
availableColumns: Array<{
|
|
// 플랫한 구조의 전체 사용 가능 컬럼
|
|
tableName: string;
|
|
columnName: string;
|
|
columnLabel: string;
|
|
dataType: string;
|
|
joinAlias: string; // 예: item_code_item_name
|
|
suggestedLabel: string; // 예: 품목명
|
|
}>;
|
|
summary: {
|
|
totalJoinTables: number;
|
|
totalAvailableColumns: number;
|
|
}
|
|
}
|
|
```
|
|
|
|
### 컬럼 선택 UI 구현
|
|
|
|
ConfigPanel에서 엔티티 조인 컬럼을 표시하는 표준 패턴입니다.
|
|
|
|
```typescript
|
|
// 상태 정의
|
|
const [entityJoinColumns, setEntityJoinColumns] = useState<{
|
|
availableColumns: Array<{
|
|
tableName: string;
|
|
columnName: string;
|
|
columnLabel: string;
|
|
dataType: string;
|
|
joinAlias: string;
|
|
suggestedLabel: string;
|
|
}>;
|
|
joinTables: Array<{
|
|
tableName: string;
|
|
currentDisplayColumn: string;
|
|
availableColumns: Array<{
|
|
columnName: string;
|
|
columnLabel: string;
|
|
dataType: string;
|
|
description?: string;
|
|
}>;
|
|
}>;
|
|
}>({ availableColumns: [], joinTables: [] });
|
|
|
|
const [loadingEntityJoins, setLoadingEntityJoins] = useState(false);
|
|
|
|
// 엔티티 조인 컬럼 로드
|
|
useEffect(() => {
|
|
const fetchEntityJoinColumns = async () => {
|
|
const tableName = config.selectedTable || screenTableName;
|
|
if (!tableName) {
|
|
setEntityJoinColumns({ availableColumns: [], joinTables: [] });
|
|
return;
|
|
}
|
|
|
|
setLoadingEntityJoins(true);
|
|
try {
|
|
const result = await entityJoinApi.getEntityJoinColumns(tableName);
|
|
setEntityJoinColumns({
|
|
availableColumns: result.availableColumns || [],
|
|
joinTables: result.joinTables || [],
|
|
});
|
|
} catch (error) {
|
|
console.error("엔티티 조인 컬럼 조회 오류:", error);
|
|
setEntityJoinColumns({ availableColumns: [], joinTables: [] });
|
|
} finally {
|
|
setLoadingEntityJoins(false);
|
|
}
|
|
};
|
|
|
|
fetchEntityJoinColumns();
|
|
}, [config.selectedTable, screenTableName]);
|
|
```
|
|
|
|
### 컬럼 선택 UI 렌더링
|
|
|
|
```tsx
|
|
{
|
|
/* 엔티티 조인 컬럼 섹션 */
|
|
}
|
|
{
|
|
entityJoinColumns.joinTables.length > 0 && (
|
|
<div className="space-y-2 mt-4">
|
|
<Label className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
<Link2 className="h-3 w-3" />
|
|
엔티티 조인 컬럼
|
|
</Label>
|
|
|
|
{entityJoinColumns.joinTables.map((joinTable) => (
|
|
<div key={joinTable.tableName} className="border rounded-lg p-3">
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<Badge variant="secondary" className="text-xs">
|
|
{joinTable.tableName}
|
|
</Badge>
|
|
<span className="text-xs text-muted-foreground">
|
|
({joinTable.availableColumns.length})
|
|
</span>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-2">
|
|
{joinTable.availableColumns.map((col) => {
|
|
// "테이블명.컬럼명" 형식으로 컬럼 이름 생성
|
|
const fullColumnName = `${joinTable.tableName}.${col.columnName}`;
|
|
const isSelected = config.columns?.some(
|
|
(c) => c.columnName === fullColumnName
|
|
);
|
|
|
|
return (
|
|
<div
|
|
key={col.columnName}
|
|
className={cn(
|
|
"flex items-center gap-2 p-2 border rounded cursor-pointer",
|
|
isSelected
|
|
? "bg-primary/10 border-primary"
|
|
: "hover:bg-muted"
|
|
)}
|
|
onClick={() => {
|
|
if (isSelected) {
|
|
removeColumn(fullColumnName);
|
|
} else {
|
|
addEntityJoinColumn(joinTable.tableName, col);
|
|
}
|
|
}}
|
|
>
|
|
<Checkbox checked={isSelected} />
|
|
<div className="flex-1 min-w-0">
|
|
<div className="text-sm truncate">{col.columnLabel}</div>
|
|
<div className="text-xs text-muted-foreground truncate">
|
|
{col.columnName}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
### 엔티티 조인 컬럼 추가 함수
|
|
|
|
```typescript
|
|
const addEntityJoinColumn = (tableName: string, column: any) => {
|
|
const fullColumnName = `${tableName}.${column.columnName}`;
|
|
|
|
const newColumn: ColumnConfig = {
|
|
columnName: fullColumnName,
|
|
displayName: column.columnLabel || column.columnName,
|
|
visible: true,
|
|
sortable: true,
|
|
searchable: true,
|
|
align: "left",
|
|
format: "text",
|
|
order: config.columns?.length || 0,
|
|
isEntityJoin: true, // 엔티티 조인 컬럼 표시
|
|
entityJoinTable: tableName,
|
|
entityJoinColumn: column.columnName,
|
|
};
|
|
|
|
onChange({
|
|
...config,
|
|
columns: [...(config.columns || []), newColumn],
|
|
});
|
|
};
|
|
```
|
|
|
|
### 데이터 조회 시 엔티티 조인 활용
|
|
|
|
```typescript
|
|
// 엔티티 조인이 포함된 데이터 조회
|
|
const response = await entityJoinApi.getTableDataWithJoins(tableName, {
|
|
page: 1,
|
|
size: 10,
|
|
enableEntityJoin: true,
|
|
// 추가 조인 컬럼 지정 (화면 설정에서 선택한 컬럼들)
|
|
additionalJoinColumns: config.columns
|
|
?.filter((col) => col.isEntityJoin)
|
|
?.map((col) => ({
|
|
sourceTable: col.entityJoinTable!,
|
|
sourceColumn: col.entityJoinColumn!,
|
|
joinAlias: col.columnName,
|
|
})),
|
|
});
|
|
```
|
|
|
|
### 셀 값 추출 헬퍼
|
|
|
|
엔티티 조인 컬럼의 값을 데이터에서 추출하는 헬퍼 함수입니다.
|
|
|
|
```typescript
|
|
const getEntityJoinValue = (item: any, columnName: string): any => {
|
|
// 직접 매칭 시도
|
|
if (item[columnName] !== undefined) {
|
|
return item[columnName];
|
|
}
|
|
|
|
// "테이블명.컬럼명" 형식인 경우
|
|
if (columnName.includes(".")) {
|
|
const [tableName, fieldName] = columnName.split(".");
|
|
|
|
// 1. 소스 컬럼 추론 (item_info → item_code)
|
|
const inferredSourceColumn = tableName
|
|
.replace("_info", "_code")
|
|
.replace("_mng", "_id");
|
|
|
|
// 2. 정확한 키 매핑: 소스컬럼_필드명
|
|
const exactKey = `${inferredSourceColumn}_${fieldName}`;
|
|
if (item[exactKey] !== undefined) {
|
|
return item[exactKey];
|
|
}
|
|
|
|
// 3. item_id 패턴 시도
|
|
const idPatternKey = `${tableName.replace("_info", "_id")}_${fieldName}`;
|
|
if (item[idPatternKey] !== undefined) {
|
|
return item[idPatternKey];
|
|
}
|
|
|
|
// 4. 단순 필드명으로 시도
|
|
if (item[fieldName] !== undefined) {
|
|
return item[fieldName];
|
|
}
|
|
}
|
|
|
|
return undefined;
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
## 3. 폼 데이터 관리
|
|
|
|
### 통합 폼 시스템 (UnifiedFormContext)
|
|
|
|
새 컴포넌트는 통합 폼 시스템을 사용해야 합니다.
|
|
|
|
```typescript
|
|
import { useFormCompatibility } from "@/hooks/useFormCompatibility";
|
|
|
|
const MyComponent = ({ onFormDataChange, formData, ...props }) => {
|
|
// 호환성 브릿지 사용
|
|
const { getValue, setValue, submit } = useFormCompatibility({
|
|
legacyOnFormDataChange: onFormDataChange,
|
|
});
|
|
|
|
// 값 읽기
|
|
const currentValue = getValue("fieldName");
|
|
|
|
// 값 설정 (모든 시스템에 전파됨)
|
|
const handleChange = (value: any) => {
|
|
setValue("fieldName", value);
|
|
};
|
|
|
|
// 저장
|
|
const handleSave = async () => {
|
|
const result = await submit({
|
|
tableName: "my_table",
|
|
mode: "insert",
|
|
});
|
|
};
|
|
};
|
|
```
|
|
|
|
### 레거시 컴포넌트와의 호환성
|
|
|
|
기존 `beforeFormSave` 이벤트를 사용하는 컴포넌트(리피터 등)와 호환됩니다.
|
|
|
|
```typescript
|
|
import { useBeforeFormSave } from "@/hooks/useFormCompatibility";
|
|
|
|
const MyRepeaterComponent = ({ value, columnName }) => {
|
|
// beforeFormSave 이벤트에서 데이터 수집
|
|
useEffect(() => {
|
|
const handleSaveRequest = (event: CustomEvent) => {
|
|
if (event.detail && columnName) {
|
|
event.detail.formData[columnName] = value;
|
|
}
|
|
};
|
|
|
|
window.addEventListener("beforeFormSave", handleSaveRequest);
|
|
return () =>
|
|
window.removeEventListener("beforeFormSave", handleSaveRequest);
|
|
}, [value, columnName]);
|
|
};
|
|
```
|
|
|
|
### onChange 핸들러 패턴
|
|
|
|
컴포넌트에서 값이 변경될 때 사용하는 표준 패턴입니다.
|
|
|
|
```typescript
|
|
// 기본 패턴 (권장)
|
|
const handleChange = useCallback(
|
|
(value: any) => {
|
|
// 1. UnifiedFormContext가 있으면 사용
|
|
if (unifiedContext) {
|
|
unifiedContext.setValue(fieldName, value);
|
|
}
|
|
|
|
// 2. ScreenContext가 있으면 사용
|
|
if (screenContext?.updateFormData) {
|
|
screenContext.updateFormData(fieldName, value);
|
|
}
|
|
|
|
// 3. 레거시 콜백이 있으면 호출
|
|
if (onFormDataChange) {
|
|
onFormDataChange(fieldName, value);
|
|
}
|
|
},
|
|
[fieldName, unifiedContext, screenContext, onFormDataChange]
|
|
);
|
|
```
|
|
|
|
---
|
|
|
|
## 4. 다국어 지원
|
|
|
|
### 타입 정의 시 다국어 필드 추가
|
|
|
|
텍스트가 표시되는 **모든 속성**에 `langKeyId`와 `langKey` 필드를 추가합니다.
|
|
|
|
```typescript
|
|
interface MyComponentConfig {
|
|
// 기본 텍스트
|
|
title?: string;
|
|
titleLangKeyId?: number;
|
|
titleLangKey?: string;
|
|
|
|
// 컬럼 배열
|
|
columns?: Array<{
|
|
name: string;
|
|
label: string;
|
|
langKeyId?: number;
|
|
langKey?: string;
|
|
}>;
|
|
}
|
|
```
|
|
|
|
### 라벨 추출 로직 등록
|
|
|
|
파일: `frontend/lib/utils/multilangLabelExtractor.ts`
|
|
|
|
```typescript
|
|
// extractMultilangLabels 함수에 추가
|
|
if (comp.componentType === "my-new-component") {
|
|
const config = comp.componentConfig as MyComponentConfig;
|
|
|
|
// 제목 추출
|
|
if (config?.title) {
|
|
addLabel({
|
|
id: `${comp.id}_title`,
|
|
componentId: `${comp.id}_title`,
|
|
label: config.title,
|
|
type: "title",
|
|
parentType: "my-new-component",
|
|
parentLabel: config.title,
|
|
langKeyId: config.titleLangKeyId,
|
|
langKey: config.titleLangKey,
|
|
});
|
|
}
|
|
|
|
// 컬럼 추출
|
|
if (config?.columns && Array.isArray(config.columns)) {
|
|
config.columns.forEach((col, index) => {
|
|
addLabel({
|
|
id: `${comp.id}_col_${index}`,
|
|
componentId: `${comp.id}_col_${index}`,
|
|
label: col.label || col.name,
|
|
type: "column",
|
|
parentType: "my-new-component",
|
|
parentLabel: config.title || "컴포넌트",
|
|
langKeyId: col.langKeyId,
|
|
langKey: col.langKey,
|
|
});
|
|
});
|
|
}
|
|
}
|
|
```
|
|
|
|
### 매핑 적용 로직 등록
|
|
|
|
```typescript
|
|
// applyMultilangMappings 함수에 추가
|
|
if (comp.componentType === "my-new-component") {
|
|
const config = comp.componentConfig as MyComponentConfig;
|
|
|
|
// 제목 매핑
|
|
const titleMapping = mappingMap.get(`${comp.id}_title`);
|
|
if (titleMapping) {
|
|
updated.componentConfig = {
|
|
...updated.componentConfig,
|
|
titleLangKeyId: titleMapping.keyId,
|
|
titleLangKey: titleMapping.langKey,
|
|
};
|
|
}
|
|
|
|
// 컬럼 매핑
|
|
if (config?.columns && Array.isArray(config.columns)) {
|
|
const updatedColumns = config.columns.map((col, index) => {
|
|
const colMapping = mappingMap.get(`${comp.id}_col_${index}`);
|
|
if (colMapping) {
|
|
return {
|
|
...col,
|
|
langKeyId: colMapping.keyId,
|
|
langKey: colMapping.langKey,
|
|
};
|
|
}
|
|
return col;
|
|
});
|
|
updated.componentConfig = {
|
|
...updated.componentConfig,
|
|
columns: updatedColumns,
|
|
};
|
|
}
|
|
}
|
|
```
|
|
|
|
### 번역 표시 로직
|
|
|
|
```typescript
|
|
import { useScreenMultiLang } from "@/contexts/ScreenMultiLangContext";
|
|
|
|
const MyComponent = ({ component }) => {
|
|
const { getTranslatedText } = useScreenMultiLang();
|
|
const config = component.componentConfig;
|
|
|
|
// 제목 번역
|
|
const displayTitle = config?.titleLangKey
|
|
? getTranslatedText(config.titleLangKey, config.title || "")
|
|
: config?.title || "";
|
|
|
|
// 컬럼 헤더 번역
|
|
const translatedColumns = config?.columns?.map((col) => ({
|
|
...col,
|
|
displayLabel: col.langKey
|
|
? getTranslatedText(col.langKey, col.label)
|
|
: col.label,
|
|
}));
|
|
|
|
return (
|
|
<div>
|
|
<h2>{displayTitle}</h2>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
{translatedColumns?.map((col, idx) => (
|
|
<th key={idx}>{col.displayLabel}</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
</table>
|
|
</div>
|
|
);
|
|
};
|
|
```
|
|
|
|
### ScreenMultiLangContext에 키 수집 로직 추가
|
|
|
|
파일: `frontend/contexts/ScreenMultiLangContext.tsx`
|
|
|
|
```typescript
|
|
// collectLangKeys 함수에 추가
|
|
if (comp.componentType === "my-new-component") {
|
|
const config = comp.componentConfig;
|
|
|
|
if (config?.titleLangKey) {
|
|
keys.add(config.titleLangKey);
|
|
}
|
|
|
|
if (config?.columns && Array.isArray(config.columns)) {
|
|
config.columns.forEach((col: any) => {
|
|
if (col.langKey) {
|
|
keys.add(col.langKey);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 5. 컬럼 설정 패널 구현
|
|
|
|
### 필수 구조
|
|
|
|
모든 테이블/목록 기반 컴포넌트의 설정 패널은 다음 구조를 따릅니다:
|
|
|
|
```typescript
|
|
interface ConfigPanelProps {
|
|
config: MyComponentConfig;
|
|
onChange: (config: Partial<MyComponentConfig>) => void;
|
|
screenTableName?: string; // 화면에 연결된 테이블명
|
|
tableColumns?: any[]; // 테이블 컬럼 정보
|
|
}
|
|
|
|
export const MyComponentConfigPanel: React.FC<ConfigPanelProps> = ({
|
|
config,
|
|
onChange,
|
|
screenTableName,
|
|
tableColumns,
|
|
}) => {
|
|
// 1. 기본 테이블 컬럼 상태
|
|
const [availableColumns, setAvailableColumns] = useState<Array<{
|
|
columnName: string;
|
|
dataType: string;
|
|
label?: string;
|
|
input_type?: string;
|
|
}>>([]);
|
|
|
|
// 2. 엔티티 조인 컬럼 상태 (필수!)
|
|
const [entityJoinColumns, setEntityJoinColumns] = useState<{
|
|
availableColumns: Array<{...}>;
|
|
joinTables: Array<{...}>;
|
|
}>({ availableColumns: [], joinTables: [] });
|
|
|
|
// 3. 로딩 상태
|
|
const [loadingEntityJoins, setLoadingEntityJoins] = useState(false);
|
|
|
|
// 4. 화면 테이블명이 있으면 자동 설정
|
|
useEffect(() => {
|
|
if (screenTableName && !config.selectedTable) {
|
|
onChange({
|
|
...config,
|
|
selectedTable: screenTableName,
|
|
columns: config.columns || [],
|
|
});
|
|
}
|
|
}, [screenTableName]);
|
|
|
|
// 5. 기본 컬럼 로드
|
|
useEffect(() => {
|
|
// tableColumns prop 또는 API에서 로드
|
|
}, [config.selectedTable, screenTableName, tableColumns]);
|
|
|
|
// 6. 엔티티 조인 컬럼 로드 (필수!)
|
|
useEffect(() => {
|
|
const fetchEntityJoinColumns = async () => {
|
|
const tableName = config.selectedTable || screenTableName;
|
|
if (!tableName) {
|
|
setEntityJoinColumns({ availableColumns: [], joinTables: [] });
|
|
return;
|
|
}
|
|
|
|
setLoadingEntityJoins(true);
|
|
try {
|
|
const result = await entityJoinApi.getEntityJoinColumns(tableName);
|
|
setEntityJoinColumns({
|
|
availableColumns: result.availableColumns || [],
|
|
joinTables: result.joinTables || [],
|
|
});
|
|
} catch (error) {
|
|
setEntityJoinColumns({ availableColumns: [], joinTables: [] });
|
|
} finally {
|
|
setLoadingEntityJoins(false);
|
|
}
|
|
};
|
|
|
|
fetchEntityJoinColumns();
|
|
}, [config.selectedTable, screenTableName]);
|
|
|
|
// 7. UI 렌더링
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* 기본 테이블 컬럼 */}
|
|
<div>
|
|
<Label>표시할 컬럼 선택</Label>
|
|
{/* 기본 컬럼 체크박스들 */}
|
|
</div>
|
|
|
|
{/* 엔티티 조인 컬럼 (필수!) */}
|
|
{entityJoinColumns.joinTables.length > 0 && (
|
|
<div>
|
|
<Label className="flex items-center gap-2">
|
|
<Link2 className="h-3 w-3" />
|
|
엔티티 조인 컬럼
|
|
</Label>
|
|
{/* 조인 테이블별 컬럼 선택 UI */}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
## 6. 체크리스트
|
|
|
|
새 컴포넌트 개발 시 다음 항목을 확인하세요:
|
|
|
|
### V2 컴포넌트 규칙 (최우선)
|
|
|
|
- [ ] V2 폴더(`v2-*/`)에서 작업 중인지 확인
|
|
- [ ] 원본 폴더는 수정하지 않음
|
|
- [ ] 컴포넌트 ID에 `v2-` 접두사 사용
|
|
- [ ] Definition 이름에 `V2` 접두사 사용 (예: `V2TableListDefinition`)
|
|
- [ ] Renderer에서 올바른 V2 Definition 참조 확인
|
|
|
|
### 컴포넌트별 테이블 설정 (핵심)
|
|
|
|
- [ ] 화면 메인 테이블과 다른 테이블을 사용할 수 있는지 확인
|
|
- [ ] `useCustomTable`, `mainTableName` (또는 `customTableName`) 설정 지원
|
|
- [ ] 연관 테이블 선택 시 FK/PK 자동 설정 (`/api/table-management/columns/:tableName/referenced-by` API 활용)
|
|
- [ ] 저장 테이블 변경 시 해당 테이블의 컬럼 자동 로드
|
|
- [ ] 테이블 선택 UI는 Combobox 형태로 그룹별 표시 (기본/연관/전체)
|
|
- [ ] FK 자동 연결: `repeaterSave` 이벤트에서 `masterRecordId` 수신 및 적용
|
|
|
|
### 엔티티 조인 (필수)
|
|
|
|
- [ ] `entityJoinApi.getEntityJoinColumns()` 호출하여 조인 컬럼 로드
|
|
- [ ] 설정 패널에 "엔티티 조인 컬럼" 섹션 추가
|
|
- [ ] 조인 컬럼 선택 시 `tableName.columnName` 형식으로 저장
|
|
- [ ] 데이터 조회 시 `getTableDataWithJoins()` 사용
|
|
- [ ] 셀 값 추출 시 `getEntityJoinValue()` 헬퍼 사용
|
|
|
|
### 폼 데이터 관리
|
|
|
|
- [ ] `useFormCompatibility` 훅 사용
|
|
- [ ] 값 변경 시 `setValue()` 호출
|
|
- [ ] 리피터 컴포넌트는 `beforeFormSave` 이벤트 처리
|
|
|
|
### 다국어 지원
|
|
|
|
- [ ] 타입 정의에 `langKeyId`, `langKey` 필드 추가
|
|
- [ ] `extractMultilangLabels` 함수에 라벨 추출 로직 추가
|
|
- [ ] `applyMultilangMappings` 함수에 매핑 적용 로직 추가
|
|
- [ ] `collectLangKeys` 함수에 키 수집 로직 추가
|
|
- [ ] 컴포넌트에서 `useScreenMultiLang` 훅으로 번역 표시
|
|
|
|
### 설정 패널
|
|
|
|
- [ ] `screenTableName` prop 처리
|
|
- [ ] `tableColumns` prop 처리
|
|
- [ ] 엔티티 조인 컬럼 로드 및 표시
|
|
- [ ] 컬럼 추가/제거/순서변경 기능
|
|
|
|
---
|
|
|
|
## 관련 파일 목록
|
|
|
|
| 파일 | 역할 |
|
|
| ---------------------------------------------------- | --------------------- |
|
|
| `frontend/lib/api/entityJoin.ts` | 엔티티 조인 API |
|
|
| `frontend/hooks/useFormCompatibility.ts` | 폼 호환성 브릿지 |
|
|
| `frontend/components/unified/UnifiedFormContext.tsx` | 통합 폼 Context |
|
|
| `frontend/lib/utils/multilangLabelExtractor.ts` | 다국어 라벨 추출/매핑 |
|
|
| `frontend/contexts/ScreenMultiLangContext.tsx` | 다국어 번역 Context |
|
|
|
|
---
|
|
|
|
## 참고: TableListConfigPanel 예시
|
|
|
|
`frontend/lib/registry/components/table-list/TableListConfigPanel.tsx` 파일에서
|
|
엔티티 조인 컬럼을 어떻게 표시하는지 참고하세요.
|
|
|
|
주요 패턴:
|
|
|
|
1. `entityJoinApi.getEntityJoinColumns(tableName)` 호출
|
|
2. `joinTables` 배열을 순회하며 각 조인 테이블의 컬럼 표시
|
|
3. `tableName.columnName` 형식으로 컬럼명 생성
|
|
4. `isEntityJoin: true` 플래그로 일반 컬럼과 구분
|