Compare commits
3 Commits
ea8b4ce5dc
...
ac2da7a1d7
| Author | SHA1 | Date |
|---|---|---|
|
|
ac2da7a1d7 | |
|
|
f97edad1ea | |
|
|
93d9df3e5a |
|
|
@ -308,6 +308,7 @@ export const deleteScreenGroup = async (req: AuthenticatedRequest, res: Response
|
|||
const client = await pool.connect();
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const deleteNumberingRules = req.query.deleteNumberingRules === "true";
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
await client.query('BEGIN');
|
||||
|
|
@ -380,31 +381,29 @@ export const deleteScreenGroup = async (req: AuthenticatedRequest, res: Response
|
|||
});
|
||||
}
|
||||
|
||||
// 2-4. 해당 회사의 채번 규칙 삭제 (최상위 그룹 삭제 시)
|
||||
// 삭제되는 그룹이 최상위인지 확인
|
||||
const isRootGroup = await client.query(
|
||||
`SELECT 1 FROM screen_groups WHERE id = $1 AND parent_group_id IS NULL`,
|
||||
[id]
|
||||
);
|
||||
// 2-4. 해당 회사의 채번 규칙 삭제 (최상위 그룹 삭제 + 사용자가 명시적으로 요청한 경우에만)
|
||||
if (deleteNumberingRules) {
|
||||
const isRootGroup = await client.query(
|
||||
`SELECT 1 FROM screen_groups WHERE id = $1 AND parent_group_id IS NULL`,
|
||||
[id]
|
||||
);
|
||||
|
||||
if (isRootGroup.rows.length > 0) {
|
||||
// 최상위 그룹 삭제 시 해당 회사의 채번 규칙도 삭제
|
||||
// 먼저 파트 삭제
|
||||
await client.query(
|
||||
`DELETE FROM numbering_rule_parts
|
||||
WHERE rule_id IN (SELECT rule_id FROM numbering_rules WHERE company_code = $1)`,
|
||||
[targetCompanyCode]
|
||||
);
|
||||
// 규칙 삭제
|
||||
const deletedRules = await client.query(
|
||||
`DELETE FROM numbering_rules WHERE company_code = $1 RETURNING rule_id`,
|
||||
[targetCompanyCode]
|
||||
);
|
||||
if (deletedRules.rowCount && deletedRules.rowCount > 0) {
|
||||
logger.info("그룹 삭제 시 채번 규칙 삭제", {
|
||||
companyCode: targetCompanyCode,
|
||||
deletedCount: deletedRules.rowCount
|
||||
});
|
||||
if (isRootGroup.rows.length > 0) {
|
||||
await client.query(
|
||||
`DELETE FROM numbering_rule_parts
|
||||
WHERE rule_id IN (SELECT rule_id FROM numbering_rules WHERE company_code = $1)`,
|
||||
[targetCompanyCode]
|
||||
);
|
||||
const deletedRules = await client.query(
|
||||
`DELETE FROM numbering_rules WHERE company_code = $1 RETURNING rule_id`,
|
||||
[targetCompanyCode]
|
||||
);
|
||||
if (deletedRules.rowCount && deletedRules.rowCount > 0) {
|
||||
logger.warn("최상위 그룹 삭제 시 채번 규칙 삭제 (사용자 명시 요청)", {
|
||||
companyCode: targetCompanyCode,
|
||||
deletedCount: deletedRules.rowCount
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -513,6 +513,15 @@ export class TableManagementService {
|
|||
detailSettingsStr = JSON.stringify(settings.detailSettings);
|
||||
}
|
||||
|
||||
// 입력타입에 해당하지 않는 설정값은 NULL로 강제 초기화
|
||||
const inputType = settings.inputType;
|
||||
const referenceTable = inputType === "entity" ? (settings.referenceTable || null) : null;
|
||||
const referenceColumn = inputType === "entity" ? (settings.referenceColumn || null) : null;
|
||||
const displayColumn = inputType === "entity" ? (settings.displayColumn || null) : null;
|
||||
const codeCategory = inputType === "code" ? (settings.codeCategory || null) : null;
|
||||
const codeValue = inputType === "code" ? (settings.codeValue || null) : null;
|
||||
const categoryRef = inputType === "category" ? (settings.categoryRef || null) : null;
|
||||
|
||||
await query(
|
||||
`INSERT INTO table_type_columns (
|
||||
table_name, column_name, column_label, input_type, detail_settings,
|
||||
|
|
@ -525,11 +534,11 @@ export class TableManagementService {
|
|||
column_label = COALESCE(EXCLUDED.column_label, table_type_columns.column_label),
|
||||
input_type = COALESCE(EXCLUDED.input_type, table_type_columns.input_type),
|
||||
detail_settings = COALESCE(EXCLUDED.detail_settings, table_type_columns.detail_settings),
|
||||
code_category = COALESCE(EXCLUDED.code_category, table_type_columns.code_category),
|
||||
code_value = COALESCE(EXCLUDED.code_value, table_type_columns.code_value),
|
||||
reference_table = COALESCE(EXCLUDED.reference_table, table_type_columns.reference_table),
|
||||
reference_column = COALESCE(EXCLUDED.reference_column, table_type_columns.reference_column),
|
||||
display_column = COALESCE(EXCLUDED.display_column, table_type_columns.display_column),
|
||||
code_category = EXCLUDED.code_category,
|
||||
code_value = EXCLUDED.code_value,
|
||||
reference_table = EXCLUDED.reference_table,
|
||||
reference_column = EXCLUDED.reference_column,
|
||||
display_column = EXCLUDED.display_column,
|
||||
display_order = COALESCE(EXCLUDED.display_order, table_type_columns.display_order),
|
||||
is_visible = COALESCE(EXCLUDED.is_visible, table_type_columns.is_visible),
|
||||
category_ref = EXCLUDED.category_ref,
|
||||
|
|
@ -538,17 +547,17 @@ export class TableManagementService {
|
|||
tableName,
|
||||
columnName,
|
||||
settings.columnLabel,
|
||||
settings.inputType,
|
||||
inputType,
|
||||
detailSettingsStr,
|
||||
settings.codeCategory,
|
||||
settings.codeValue,
|
||||
settings.referenceTable,
|
||||
settings.referenceColumn,
|
||||
settings.displayColumn,
|
||||
codeCategory,
|
||||
codeValue,
|
||||
referenceTable,
|
||||
referenceColumn,
|
||||
displayColumn,
|
||||
settings.displayOrder || 0,
|
||||
settings.isVisible !== undefined ? settings.isVisible : true,
|
||||
companyCode,
|
||||
settings.categoryRef || null,
|
||||
categoryRef,
|
||||
]
|
||||
);
|
||||
|
||||
|
|
@ -849,7 +858,11 @@ export class TableManagementService {
|
|||
...detailSettings,
|
||||
};
|
||||
|
||||
// table_type_columns 테이블에서 업데이트 (company_code 추가)
|
||||
// 입력타입 변경 시 이전 타입의 설정값 초기화
|
||||
const clearEntity = finalInputType !== "entity";
|
||||
const clearCode = finalInputType !== "code";
|
||||
const clearCategory = finalInputType !== "category";
|
||||
|
||||
await query(
|
||||
`INSERT INTO table_type_columns (
|
||||
table_name, column_name, input_type, detail_settings,
|
||||
|
|
@ -859,6 +872,12 @@ export class TableManagementService {
|
|||
DO UPDATE SET
|
||||
input_type = EXCLUDED.input_type,
|
||||
detail_settings = EXCLUDED.detail_settings,
|
||||
reference_table = CASE WHEN $6 THEN NULL ELSE table_type_columns.reference_table END,
|
||||
reference_column = CASE WHEN $6 THEN NULL ELSE table_type_columns.reference_column END,
|
||||
display_column = CASE WHEN $6 THEN NULL ELSE table_type_columns.display_column END,
|
||||
code_category = CASE WHEN $7 THEN NULL ELSE table_type_columns.code_category END,
|
||||
code_value = CASE WHEN $7 THEN NULL ELSE table_type_columns.code_value END,
|
||||
category_ref = CASE WHEN $8 THEN NULL ELSE table_type_columns.category_ref END,
|
||||
updated_date = now()`,
|
||||
[
|
||||
tableName,
|
||||
|
|
@ -866,6 +885,9 @@ export class TableManagementService {
|
|||
finalInputType,
|
||||
JSON.stringify(finalDetailSettings),
|
||||
companyCode,
|
||||
clearEntity,
|
||||
clearCode,
|
||||
clearCategory,
|
||||
]
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,263 @@
|
|||
# v2-table-list Entity 조인 기능 분석
|
||||
|
||||
v2-repeater에 동일 기능을 추가하기 위한 상세 분석 문서입니다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 개요
|
||||
|
||||
v2-table-list의 Entity 조인 기능은 두 가지 유형으로 구분됩니다:
|
||||
|
||||
| 유형 | 설명 | 설정 방식 |
|
||||
|------|------|-----------|
|
||||
| **isEntityJoin** | 테이블 컬럼이 `input_type=entity`인 경우 (테이블 타입 관리에서 참조 테이블 설정됨) | 자동 감지 + entityDisplayConfig로 표시 컬럼 선택 |
|
||||
| **additionalJoinInfo** | ConfigPanel "Entity 조인 컬럼" 탭에서 수동 추가한 참조 테이블 컬럼 | addEntityColumn으로 추가, additionalJoinInfo 저장 |
|
||||
|
||||
---
|
||||
|
||||
## 2. Entity 조인 설정 UI 구조 (TableListConfigPanel)
|
||||
|
||||
### 2.1 데이터 소스
|
||||
|
||||
- **entityJoinApi.getEntityJoinColumns(tableName)** 호출
|
||||
- targetTableName 변경 시 useEffect로 재호출
|
||||
|
||||
### 2.2 entityJoinColumns 상태 구조
|
||||
|
||||
```typescript
|
||||
{
|
||||
availableColumns: Array<{
|
||||
tableName: string; // 참조 테이블명 (예: dept_info)
|
||||
columnName: string; // 참조 테이블 컬럼명 (예: company_name)
|
||||
columnLabel: string;
|
||||
dataType: string;
|
||||
joinAlias: string; // 예: dept_code_company_name (sourceColumn_columnName)
|
||||
suggestedLabel: string;
|
||||
}>;
|
||||
joinTables: Array<{
|
||||
tableName: string; // 참조 테이블명
|
||||
currentDisplayColumn: string;
|
||||
joinConfig: { // 백엔드 entity-join-columns API에서 반환
|
||||
sourceColumn: string; // 기준 테이블 FK 컬럼 (예: dept_code)
|
||||
referenceTable: string;
|
||||
referenceColumn: string;
|
||||
displayColumn: string;
|
||||
// ...
|
||||
};
|
||||
availableColumns: Array<{
|
||||
columnName: string;
|
||||
columnLabel: string;
|
||||
dataType: string;
|
||||
inputType?: string;
|
||||
}>;
|
||||
}>;
|
||||
}
|
||||
```
|
||||
|
||||
### 2.3 Entity 조인 컬럼 UI (ConfigPanel)
|
||||
|
||||
- **위치**: 기본 컬럼 선택 영역 아래, "Entity 조인 컬럼" 섹션
|
||||
- **조건**: `entityJoinColumns.joinTables.length > 0` 일 때만 표시
|
||||
- **구조**: joinTables별로 그룹화 → 각 그룹 내 availableColumns를 체크박스로 표시
|
||||
- **추가 로직**: `addEntityColumn(joinColumn)` 호출
|
||||
|
||||
### 2.4 addEntityColumn 함수 (핵심)
|
||||
|
||||
```typescript
|
||||
const addEntityColumn = (joinColumn: availableColumns[0]) => {
|
||||
// joinTables에서 sourceColumn 추출 (필수!)
|
||||
const joinTableInfo = entityJoinColumns.joinTables?.find(
|
||||
(jt) => jt.tableName === joinColumn.tableName
|
||||
);
|
||||
const sourceColumn = joinTableInfo?.joinConfig?.sourceColumn || "";
|
||||
|
||||
const newColumn: ColumnConfig = {
|
||||
columnName: joinColumn.joinAlias, // 예: dept_code_company_name
|
||||
displayName: joinColumn.columnLabel,
|
||||
// ...
|
||||
isEntityJoin: false, // 조인 탭에서 추가한 컬럼은 엔티티 타입이 아님
|
||||
additionalJoinInfo: {
|
||||
sourceTable: config.selectedTable || screenTableName || "",
|
||||
sourceColumn: sourceColumn, // dept_code
|
||||
referenceTable: joinColumn.tableName, // dept_info
|
||||
joinAlias: joinColumn.joinAlias, // dept_code_company_name
|
||||
},
|
||||
};
|
||||
handleChange("columns", [...config.columns, newColumn]);
|
||||
};
|
||||
```
|
||||
|
||||
**주의**: `sourceColumn`은 반드시 `joinTableInfo.joinConfig.sourceColumn`에서 가져와야 합니다. `joinColumn`에는 없습니다.
|
||||
|
||||
---
|
||||
|
||||
## 3. additionalJoinInfo 데이터 구조
|
||||
|
||||
### 3.1 타입 정의 (types.ts)
|
||||
|
||||
```typescript
|
||||
additionalJoinInfo?: {
|
||||
sourceTable: string; // 기준 테이블 (예: user_info)
|
||||
sourceColumn: string; // 기준 테이블 FK 컬럼 (예: dept_code)
|
||||
referenceTable?: string; // 참조 테이블 (예: dept_info)
|
||||
joinAlias: string; // 조인 결과 컬럼 별칭 (예: dept_code_company_name)
|
||||
};
|
||||
```
|
||||
|
||||
### 3.2 네이밍 규칙
|
||||
|
||||
- **joinAlias**: `${sourceColumn}_${referenceTable컬럼명}`
|
||||
- 예: `dept_code` + `company_name` → `dept_code_company_name`
|
||||
- 백엔드가 이 규칙으로 SELECT 시 alias를 생성하고, 응답 row에 `dept_code_company_name` 키로 값이 들어옴
|
||||
|
||||
---
|
||||
|
||||
## 4. 백엔드 API 호출 흐름
|
||||
|
||||
### 4.1 TableListComponent 데이터 로딩
|
||||
|
||||
```typescript
|
||||
// 1. additionalJoinInfo가 있는 컬럼만 추출
|
||||
const entityJoinColumns = (tableConfig.columns || [])
|
||||
.filter((col) => col.additionalJoinInfo)
|
||||
.map((col) => ({
|
||||
sourceTable: col.additionalJoinInfo!.sourceTable,
|
||||
sourceColumn: col.additionalJoinInfo!.sourceColumn,
|
||||
joinAlias: col.additionalJoinInfo!.joinAlias,
|
||||
referenceTable: col.additionalJoinInfo!.referenceTable,
|
||||
}));
|
||||
|
||||
// 2. entityDisplayConfig가 있는 컬럼 (isEntityJoin) - 화면별 표시 설정
|
||||
const screenEntityConfigs: Record<string, any> = {};
|
||||
(tableConfig.columns || [])
|
||||
.filter((col) => col.entityDisplayConfig?.displayColumns?.length > 0)
|
||||
.forEach((col) => {
|
||||
screenEntityConfigs[col.columnName] = {
|
||||
displayColumns: col.entityDisplayConfig!.displayColumns,
|
||||
separator: col.entityDisplayConfig!.separator || " - ",
|
||||
sourceTable: col.entityDisplayConfig!.sourceTable || tableConfig.selectedTable,
|
||||
joinTable: col.entityDisplayConfig!.joinTable,
|
||||
};
|
||||
});
|
||||
|
||||
// 3. API 호출
|
||||
response = await entityJoinApi.getTableDataWithJoins(tableConfig.selectedTable, {
|
||||
page, size, sortBy, sortOrder,
|
||||
search: hasFilters ? filters : undefined,
|
||||
enableEntityJoin: true,
|
||||
additionalJoinColumns: entityJoinColumns.length > 0 ? entityJoinColumns : undefined,
|
||||
screenEntityConfigs: Object.keys(screenEntityConfigs).length > 0 ? screenEntityConfigs : undefined,
|
||||
dataFilter: tableConfig.dataFilter,
|
||||
excludeFilter: excludeFilterParam,
|
||||
});
|
||||
```
|
||||
|
||||
### 4.2 entityJoinApi.getTableDataWithJoins 파라미터
|
||||
|
||||
```typescript
|
||||
additionalJoinColumns?: Array<{
|
||||
sourceTable: string;
|
||||
sourceColumn: string;
|
||||
joinAlias: string;
|
||||
referenceTable?: string; // 백엔드에서 referenceTable로 기존 조인 찾을 때 사용
|
||||
}>;
|
||||
```
|
||||
|
||||
- **전달 방식**: `JSON.stringify(additionalJoinColumns)` 후 쿼리 파라미터로 전달
|
||||
- **백엔드**: `entityJoinController` → `tableManagementService.getTableDataWithEntityJoins`
|
||||
|
||||
### 4.3 백엔드 처리 (tableManagementService)
|
||||
|
||||
1. `detectEntityJoins`로 기본 Entity 조인 설정 조회
|
||||
2. `additionalJoinColumns`가 있으면:
|
||||
- `sourceColumn` 또는 `referenceTable`로 기존 joinConfig 찾기
|
||||
- `joinAlias`에서 실제 컬럼명 추출 (예: `dept_code_company_name` → `company_name`)
|
||||
- 기존 config에 `displayColumns` 병합 또는 새 config 추가
|
||||
- `aliasColumn`: `${sourceColumn}_${actualColumnName}` (예: `dept_code_company_name`)
|
||||
3. `additionalJoinColumns`가 있으면 **full_join** 전략 강제 사용 (캐시 미사용)
|
||||
|
||||
---
|
||||
|
||||
## 5. 데이터 표시 시 조인 데이터 매핑
|
||||
|
||||
### 5.1 additionalJoinInfo 컬럼 (조인 탭에서 추가한 컬럼)
|
||||
|
||||
- **백엔드 응답**: row에 `joinAlias` 키로 값이 직접 들어옴
|
||||
- 예: `row.dept_code_company_name = "개발팀"`
|
||||
- **프론트엔드**: `column.columnName`이 `joinAlias`와 동일하므로 `rowData[column.columnName]`으로 바로 접근
|
||||
- **formatCellValue**: `entityDisplayConfig`가 없으면 일반 컬럼처럼 `value` 사용 (이미 row에 joinAlias로 들어있음)
|
||||
|
||||
### 5.2 entityDisplayConfig 컬럼 (isEntityJoin, 테이블 타입 관리에서 entity 설정된 컬럼)
|
||||
|
||||
- **formatCellValue** 로직:
|
||||
```typescript
|
||||
if (column.entityDisplayConfig && rowData) {
|
||||
const displayColumns = column.entityDisplayConfig.displayColumns;
|
||||
const separator = column.entityDisplayConfig.separator;
|
||||
const values = displayColumns.map((colName) => {
|
||||
const joinedKey = `${column.columnName}_${colName}`; // 예: manager_user_name
|
||||
let cellValue = rowData[joinedKey];
|
||||
if (cellValue == null) cellValue = rowData[colName];
|
||||
return cellValue ?? "";
|
||||
});
|
||||
return values.filter(v => v !== "").join(separator || " - ");
|
||||
}
|
||||
```
|
||||
- **백엔드 alias 규칙**: `${sourceColumn}_${displayColumn}` (예: `manager_user_name`)
|
||||
|
||||
### 5.3 joinedColumnMeta (inputType/category 매핑)
|
||||
|
||||
- additionalJoinInfo 컬럼도 `joinedColumnMeta`에 등록됨
|
||||
- `actualColumn` 추출: `joinAlias.replace(\`${sourceColumn}_\`, "")` → 참조 테이블의 실제 컬럼명
|
||||
- 조인 테이블별로 `tableTypeApi.getColumnInputTypes` 호출하여 inputType 로드
|
||||
|
||||
---
|
||||
|
||||
## 6. entity-join-columns API (ConfigPanel용)
|
||||
|
||||
- **엔드포인트**: `GET /api/table-management/tables/:tableName/entity-join-columns`
|
||||
- **역할**: 화면 편집기에서 "Entity 조인 컬럼" 탭에 표시할 데이터 제공
|
||||
- **응답**:
|
||||
- `joinTables`: 각 Entity 조인별 `joinConfig`, `tableName`, `availableColumns`
|
||||
- `availableColumns`: 모든 조인 컬럼을 flat하게 (joinAlias 포함)
|
||||
- **joinConfig**: `entityJoinService.detectEntityJoins` 결과에서 옴 (테이블 타입 관리의 reference_table 설정 기반)
|
||||
|
||||
---
|
||||
|
||||
## 7. v2-repeater 적용 시 체크리스트
|
||||
|
||||
### ConfigPanel
|
||||
|
||||
- [ ] `entityJoinApi.getEntityJoinColumns(targetTableName)` 호출
|
||||
- [ ] `entityJoinColumns` 상태 (availableColumns, joinTables)
|
||||
- [ ] "Entity 조인 컬럼" UI 섹션 (joinTables.length > 0일 때)
|
||||
- [ ] `addEntityColumn` 함수: `joinConfig.sourceColumn` 사용
|
||||
- [ ] RepeaterColumnConfig에 `additionalJoinInfo` 타입 추가
|
||||
|
||||
### 데이터 로딩 (RepeaterComponent)
|
||||
|
||||
- [ ] `additionalJoinInfo`가 있는 컬럼 추출 → `entityJoinColumns` 배열 생성
|
||||
- [ ] `entityJoinApi.getTableDataWithJoins` 호출 시 `additionalJoinColumns` 전달
|
||||
- [ ] `entityDisplayConfig`가 있으면 `screenEntityConfigs`에도 포함 (isEntityJoin 컬럼용)
|
||||
|
||||
### 셀 렌더링
|
||||
|
||||
- [ ] additionalJoinInfo 컬럼: `rowData[column.columnName]` (joinAlias와 동일)
|
||||
- [ ] entityDisplayConfig 컬럼: displayColumns + separator로 조합, `joinedKey = ${columnName}_${colName}`
|
||||
|
||||
### 타입 정의
|
||||
|
||||
- [ ] `RepeaterColumnConfig`에 `additionalJoinInfo?: { sourceTable, sourceColumn, referenceTable, joinAlias }` 추가
|
||||
|
||||
---
|
||||
|
||||
## 8. 참고 파일
|
||||
|
||||
| 파일 | 용도 |
|
||||
|------|------|
|
||||
| `frontend/lib/registry/components/v2-table-list/TableListConfigPanel.tsx` | Entity 조인 UI, addEntityColumn |
|
||||
| `frontend/lib/registry/components/v2-table-list/TableListComponent.tsx` | 데이터 로딩, formatCellValue |
|
||||
| `frontend/lib/registry/components/v2-table-list/types.ts` | additionalJoinInfo 타입 |
|
||||
| `frontend/lib/api/entityJoin.ts` | getTableDataWithJoins, getEntityJoinColumns |
|
||||
| `backend-node/src/controllers/entityJoinController.ts` | entity-join-columns, data-with-joins |
|
||||
| `backend-node/src/services/tableManagementService.ts` | additionalJoinColumns 병합 로직 |
|
||||
|
|
@ -453,18 +453,39 @@ export default function TableManagementPage() {
|
|||
[loadColumnTypes, loadConstraints, pageSize, tables],
|
||||
);
|
||||
|
||||
// 입력 타입 변경
|
||||
// 입력 타입 변경 - 이전 타입의 설정값 초기화 포함
|
||||
const handleInputTypeChange = useCallback(
|
||||
(columnName: string, newInputType: string) => {
|
||||
setColumns((prev) =>
|
||||
prev.map((col) => {
|
||||
if (col.columnName === columnName) {
|
||||
const inputTypeOption = memoizedInputTypeOptions.find((option) => option.value === newInputType);
|
||||
return {
|
||||
const updated: typeof col = {
|
||||
...col,
|
||||
inputType: newInputType,
|
||||
detailSettings: inputTypeOption?.description || col.detailSettings,
|
||||
};
|
||||
|
||||
// 엔티티가 아닌 타입으로 변경 시 참조 설정 초기화
|
||||
if (newInputType !== "entity") {
|
||||
updated.referenceTable = undefined;
|
||||
updated.referenceColumn = undefined;
|
||||
updated.displayColumn = undefined;
|
||||
}
|
||||
|
||||
// 코드가 아닌 타입으로 변경 시 코드 설정 초기화
|
||||
if (newInputType !== "code") {
|
||||
updated.codeCategory = undefined;
|
||||
updated.codeValue = undefined;
|
||||
updated.hierarchyRole = undefined;
|
||||
}
|
||||
|
||||
// 카테고리가 아닌 타입으로 변경 시 카테고리 참조 초기화
|
||||
if (newInputType !== "category") {
|
||||
updated.categoryRef = undefined;
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
return col;
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -135,6 +135,7 @@ export function ScreenGroupTreeView({
|
|||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [deletingGroup, setDeletingGroup] = useState<ScreenGroup | null>(null);
|
||||
const [deleteScreensWithGroup, setDeleteScreensWithGroup] = useState(false); // 화면도 함께 삭제 체크박스
|
||||
const [deleteNumberingRules, setDeleteNumberingRules] = useState(false); // 채번 규칙도 함께 삭제 체크박스
|
||||
const [isDeleting, setIsDeleting] = useState(false); // 삭제 진행 중 상태
|
||||
const [deleteProgress, setDeleteProgress] = useState({ current: 0, total: 0, message: "" }); // 삭제 진행 상태
|
||||
|
||||
|
|
@ -439,7 +440,8 @@ export function ScreenGroupTreeView({
|
|||
const handleDeleteGroup = (group: ScreenGroup, e?: React.MouseEvent) => {
|
||||
e?.stopPropagation();
|
||||
setDeletingGroup(group);
|
||||
setDeleteScreensWithGroup(false); // 기본값: 화면 삭제 안함
|
||||
setDeleteScreensWithGroup(false);
|
||||
setDeleteNumberingRules(false);
|
||||
setIsDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
|
|
@ -572,11 +574,17 @@ export function ScreenGroupTreeView({
|
|||
// 최종적으로 대상 그룹 삭제
|
||||
currentStep++;
|
||||
setDeleteProgress({ current: currentStep, total: totalSteps, message: "그룹 삭제 완료 중..." });
|
||||
const response = await deleteScreenGroup(deletingGroup.id);
|
||||
const isRootGroup = !deletingGroup.parent_group_id;
|
||||
const response = await deleteScreenGroup(deletingGroup.id, {
|
||||
deleteNumberingRules: isRootGroup && deleteNumberingRules,
|
||||
});
|
||||
if (response.success) {
|
||||
const messages = [];
|
||||
if (deleteScreensWithGroup) messages.push(`화면 ${totalScreensToDelete}개`);
|
||||
if (isRootGroup && deleteNumberingRules) messages.push("채번 규칙");
|
||||
toast.success(
|
||||
deleteScreensWithGroup
|
||||
? `그룹과 화면 ${totalScreensToDelete}개가 삭제되었습니다`
|
||||
messages.length > 0
|
||||
? `그룹과 ${messages.join(", ")}이(가) 삭제되었습니다`
|
||||
: "그룹이 삭제되었습니다"
|
||||
);
|
||||
await loadGroupsData();
|
||||
|
|
@ -593,6 +601,7 @@ export function ScreenGroupTreeView({
|
|||
setIsDeleteDialogOpen(false);
|
||||
setDeletingGroup(null);
|
||||
setDeleteScreensWithGroup(false);
|
||||
setDeleteNumberingRules(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -1479,7 +1488,7 @@ export function ScreenGroupTreeView({
|
|||
</p>
|
||||
<p className="mt-2 text-destructive/80">
|
||||
{deleteScreensWithGroup
|
||||
? "⚠️ 그룹에 속한 모든 화면, 플로우, 관련 데이터가 영구적으로 삭제됩니다. 이 작업은 되돌릴 수 없습니다."
|
||||
? "그룹에 속한 모든 화면, 플로우, 관련 데이터가 영구적으로 삭제됩니다. 이 작업은 되돌릴 수 없습니다."
|
||||
: "그룹에 속한 화면들은 미분류로 이동됩니다."
|
||||
}
|
||||
</p>
|
||||
|
|
@ -1521,6 +1530,43 @@ export function ScreenGroupTreeView({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* 최상위 그룹일 때 채번 삭제 경고 */}
|
||||
{deletingGroup && !deletingGroup.parent_group_id && (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-md border-2 border-destructive bg-destructive/5 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="mt-0.5 h-6 w-6 shrink-0 text-destructive" />
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-bold text-destructive">
|
||||
최상위 그룹 삭제 - 채번 규칙 경고
|
||||
</p>
|
||||
<p className="text-xs text-destructive/90 leading-relaxed">
|
||||
이 그룹은 최상위 그룹입니다.
|
||||
아래 체크박스를 선택하면 해당 회사의 <span className="font-bold underline">모든 채번 규칙과 채번 파트가 영구적으로 삭제</span>됩니다.
|
||||
삭제된 채번 데이터는 복구할 수 없으며, 채번이 필요한 모든 기능이 중단됩니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2 rounded-md border border-destructive/30 bg-destructive/5 p-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="deleteNumberingRules"
|
||||
checked={deleteNumberingRules}
|
||||
onChange={(e) => setDeleteNumberingRules(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-destructive text-destructive focus:ring-destructive"
|
||||
/>
|
||||
<label
|
||||
htmlFor="deleteNumberingRules"
|
||||
className="cursor-pointer text-sm font-semibold text-destructive"
|
||||
>
|
||||
채번 규칙도 함께 삭제 (위험)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 로딩 오버레이 */}
|
||||
{isDeleting && (
|
||||
<div className="absolute inset-0 z-50 flex flex-col items-center justify-center rounded-lg bg-background/90 backdrop-blur-sm">
|
||||
|
|
@ -1551,7 +1597,7 @@ export function ScreenGroupTreeView({
|
|||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={(e) => {
|
||||
e.preventDefault(); // 자동 닫힘 방지
|
||||
e.preventDefault();
|
||||
confirmDeleteGroup();
|
||||
}}
|
||||
disabled={isDeleting}
|
||||
|
|
|
|||
|
|
@ -89,6 +89,67 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
|||
const onDataChangeRef = useRef(onDataChange);
|
||||
onDataChangeRef.current = onDataChange;
|
||||
|
||||
// Entity 조인 설정을 ref로 보관 (이벤트 핸들러 closure에서 항상 최신값 참조)
|
||||
const entityJoinsRef = useRef(config.entityJoins);
|
||||
useEffect(() => {
|
||||
entityJoinsRef.current = config.entityJoins;
|
||||
}, [config.entityJoins]);
|
||||
|
||||
// Entity 조인 해석: FK 값을 기반으로 참조 테이블에서 표시 데이터를 가져와 행에 채움
|
||||
const resolveEntityJoins = useCallback(async (rows: any[]): Promise<any[]> => {
|
||||
const entityJoins = entityJoinsRef.current;
|
||||
console.log("🔍 [V2Repeater] resolveEntityJoins 시작:", {
|
||||
entityJoins,
|
||||
rowCount: rows.length,
|
||||
sampleRow: rows[0],
|
||||
});
|
||||
|
||||
if (!entityJoins || entityJoins.length === 0) {
|
||||
console.warn("⚠️ [V2Repeater] entityJoins 설정 없음 - 해석 스킵");
|
||||
return rows;
|
||||
}
|
||||
|
||||
const resolvedRows = rows.map((r) => ({ ...r }));
|
||||
|
||||
for (const join of entityJoins) {
|
||||
const fkValues = [...new Set(resolvedRows.map((r) => r[join.sourceColumn]).filter(Boolean))];
|
||||
console.log(`🔍 [V2Repeater] FK 값 추출: ${join.sourceColumn} → [${fkValues.join(", ")}]`);
|
||||
if (fkValues.length === 0) continue;
|
||||
|
||||
try {
|
||||
const response = await apiClient.post(`/table-management/tables/${join.referenceTable}/data`, {
|
||||
page: 1,
|
||||
size: fkValues.length + 10,
|
||||
dataFilter: {
|
||||
enabled: true,
|
||||
filters: [{ columnName: "id", operator: "in", value: fkValues }],
|
||||
},
|
||||
autoFilter: true,
|
||||
});
|
||||
|
||||
console.log(`🔍 [V2Repeater] API 응답:`, response.data);
|
||||
const refData = response.data?.data?.data || response.data?.data?.rows || [];
|
||||
const lookupMap = new Map(refData.map((r: any) => [String(r.id), r]));
|
||||
|
||||
resolvedRows.forEach((row) => {
|
||||
const fkVal = String(row[join.sourceColumn] || "");
|
||||
const refRecord = lookupMap.get(fkVal);
|
||||
if (refRecord) {
|
||||
join.columns.forEach((col) => {
|
||||
row[col.displayField] = refRecord[col.referenceField];
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`✅ [V2Repeater] Entity 조인 해석 완료: ${join.referenceTable} (${fkValues.length}건, 조회결과: ${refData.length}건)`);
|
||||
} catch (error) {
|
||||
console.error(`❌ [V2Repeater] Entity 조인 해석 실패: ${join.referenceTable}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return resolvedRows;
|
||||
}, []);
|
||||
|
||||
const handleReceiveData = useCallback(
|
||||
async (incomingData: any[], configOrMode?: any) => {
|
||||
console.log("📥 [V2Repeater] 데이터 수신:", { count: incomingData?.length, configOrMode });
|
||||
|
|
@ -98,6 +159,9 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
|||
return;
|
||||
}
|
||||
|
||||
// mappingRules 처리: configOrMode에 mappingRules가 있으면 적용
|
||||
const mappingRules = configOrMode?.mappingRules;
|
||||
|
||||
// 데이터 정규화: {0: {...}} 형태 처리 + 소스 테이블 메타 필드 제거
|
||||
const metaFieldsToStrip = new Set([
|
||||
"id",
|
||||
|
|
@ -107,12 +171,33 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
|||
"updated_by",
|
||||
"company_code",
|
||||
]);
|
||||
const normalizedData = incomingData.map((item: any) => {
|
||||
let normalizedData = incomingData.map((item: any, index: number) => {
|
||||
let raw = item;
|
||||
if (item && typeof item === "object" && item[0] && typeof item[0] === "object") {
|
||||
const { 0: originalData, ...additionalFields } = item;
|
||||
raw = { ...originalData, ...additionalFields };
|
||||
}
|
||||
|
||||
// mappingRules가 있으면 규칙에 따라 매핑 (필요한 필드만 추출)
|
||||
if (mappingRules && mappingRules.length > 0) {
|
||||
const mapped: Record<string, any> = { _id: `receive_${Date.now()}_${index}` };
|
||||
for (const rule of mappingRules) {
|
||||
mapped[rule.targetField] = raw[rule.sourceField];
|
||||
}
|
||||
// additionalSources에서 추가된 필드도 유지 (mappingRules에 없는 필드 중 메타가 아닌 것)
|
||||
for (const [key, value] of Object.entries(raw)) {
|
||||
if (!metaFieldsToStrip.has(key) && !(key in mapped) && !key.startsWith("_")) {
|
||||
// 소스 테이블의 컬럼이 아닌 추가 데이터만 유지 (additionalSources 등)
|
||||
const isMappingSource = mappingRules.some((r: any) => r.sourceField === key);
|
||||
if (!isMappingSource) {
|
||||
mapped[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return mapped;
|
||||
}
|
||||
|
||||
// mappingRules 없으면 기존 로직: 메타 필드만 제거
|
||||
const cleaned: Record<string, any> = {};
|
||||
for (const [key, value] of Object.entries(raw)) {
|
||||
if (!metaFieldsToStrip.has(key)) {
|
||||
|
|
@ -122,10 +207,16 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
|||
return cleaned;
|
||||
});
|
||||
|
||||
console.log("📥 [V2Repeater] 매핑 후 데이터:", normalizedData);
|
||||
|
||||
// Entity 조인 해석 (FK → 참조 테이블 데이터)
|
||||
normalizedData = await resolveEntityJoins(normalizedData);
|
||||
|
||||
console.log("📥 [V2Repeater] Entity 조인 후 데이터:", normalizedData);
|
||||
|
||||
const mode = configOrMode?.mode || configOrMode || "append";
|
||||
|
||||
// 카테고리 코드 → 라벨 변환
|
||||
// allCategoryColumns 또는 fromMainForm 컬럼의 값을 라벨로 변환
|
||||
const codesToResolve = new Set<string>();
|
||||
for (const item of normalizedData) {
|
||||
for (const [key, val] of Object.entries(item)) {
|
||||
|
|
@ -167,7 +258,7 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
|||
|
||||
toast.success(`${normalizedData.length}개 항목이 추가되었습니다.`);
|
||||
},
|
||||
[],
|
||||
[resolveEntityJoins],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -1412,32 +1503,31 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
|||
}
|
||||
|
||||
// 데이터 매핑 처리
|
||||
const mappedData = transferData.map((item: any, index: number) => {
|
||||
let mappedData = transferData.map((item: any, index: number) => {
|
||||
const newRow: any = { _id: `transfer_${Date.now()}_${index}` };
|
||||
|
||||
if (mappingRules && mappingRules.length > 0) {
|
||||
// 매핑 규칙이 있으면 적용
|
||||
mappingRules.forEach((rule: any) => {
|
||||
newRow[rule.targetField] = item[rule.sourceField];
|
||||
});
|
||||
} else {
|
||||
// 매핑 규칙 없으면 그대로 복사
|
||||
Object.assign(newRow, item);
|
||||
}
|
||||
|
||||
return newRow;
|
||||
});
|
||||
|
||||
// Entity 조인 해석 (FK → 참조 테이블 데이터)
|
||||
mappedData = await resolveEntityJoins(mappedData);
|
||||
|
||||
// mode에 따라 데이터 처리
|
||||
if (mode === "replace") {
|
||||
handleDataChange(mappedData);
|
||||
} else if (mode === "merge") {
|
||||
// 중복 제거 후 병합 (id 기준)
|
||||
const existingIds = new Set(data.map((row) => row.id || row._id));
|
||||
const newItems = mappedData.filter((row: any) => !existingIds.has(row.id || row._id));
|
||||
handleDataChange([...data, ...newItems]);
|
||||
} else {
|
||||
// 기본: append
|
||||
handleDataChange([...data, ...mappedData]);
|
||||
}
|
||||
};
|
||||
|
|
@ -1447,12 +1537,21 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
|||
const customEvent = event as CustomEvent;
|
||||
const { data: transferData, mappingRules, mode, sourcePosition } = customEvent.detail || {};
|
||||
|
||||
console.log("📨 [V2Repeater] splitPanelDataTransfer 수신:", {
|
||||
dataCount: transferData?.length,
|
||||
mappingRules,
|
||||
mode,
|
||||
sourcePosition,
|
||||
sampleSourceData: transferData?.[0],
|
||||
entityJoinsConfig: entityJoinsRef.current,
|
||||
});
|
||||
|
||||
if (!transferData || transferData.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 데이터 매핑 처리
|
||||
const mappedData = transferData.map((item: any, index: number) => {
|
||||
let mappedData = transferData.map((item: any, index: number) => {
|
||||
const newRow: any = { _id: `transfer_${Date.now()}_${index}` };
|
||||
|
||||
if (mappingRules && mappingRules.length > 0) {
|
||||
|
|
@ -1466,6 +1565,13 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
|||
return newRow;
|
||||
});
|
||||
|
||||
console.log("📨 [V2Repeater] 매핑 후 데이터:", mappedData);
|
||||
|
||||
// Entity 조인 해석 (FK → 참조 테이블 데이터)
|
||||
mappedData = await resolveEntityJoins(mappedData);
|
||||
|
||||
console.log("📨 [V2Repeater] Entity 조인 후 데이터:", mappedData);
|
||||
|
||||
// mode에 따라 데이터 처리
|
||||
if (mode === "replace") {
|
||||
handleDataChange(mappedData);
|
||||
|
|
|
|||
|
|
@ -48,12 +48,14 @@ import {
|
|||
} from "@/components/ui/popover";
|
||||
import { tableTypeApi } from "@/lib/api/screen";
|
||||
import { tableManagementApi } from "@/lib/api/tableManagement";
|
||||
import { entityJoinApi } from "@/lib/api/entityJoin";
|
||||
import { getAvailableNumberingRules, getAvailableNumberingRulesForScreen } from "@/lib/api/numberingRule";
|
||||
import { NumberingRuleConfig } from "@/types/numbering-rule";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
V2RepeaterConfig,
|
||||
RepeaterColumnConfig,
|
||||
RepeaterEntityJoin,
|
||||
DEFAULT_REPEATER_CONFIG,
|
||||
RENDER_MODE_OPTIONS,
|
||||
MODAL_SIZE_OPTIONS,
|
||||
|
|
@ -151,6 +153,28 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
|
|||
const [loadingRelations, setLoadingRelations] = useState(false);
|
||||
const [tableComboboxOpen, setTableComboboxOpen] = useState(false); // 테이블 Combobox 열림 상태
|
||||
|
||||
// Entity 조인 관련 상태
|
||||
const [entityJoinData, setEntityJoinData] = useState<{
|
||||
joinTables: Array<{
|
||||
tableName: string;
|
||||
currentDisplayColumn: string;
|
||||
joinConfig?: { sourceColumn?: string };
|
||||
availableColumns: Array<{
|
||||
columnName: string;
|
||||
columnLabel: string;
|
||||
dataType: string;
|
||||
inputType?: string;
|
||||
}>;
|
||||
}>;
|
||||
availableColumns: Array<{
|
||||
tableName: string;
|
||||
columnName: string;
|
||||
columnLabel: string;
|
||||
joinAlias: string;
|
||||
}>;
|
||||
}>({ joinTables: [], availableColumns: [] });
|
||||
const [loadingEntityJoins, setLoadingEntityJoins] = useState(false);
|
||||
|
||||
// 🆕 확장된 컬럼 (상세 설정 표시용)
|
||||
const [expandedColumn, setExpandedColumn] = useState<string | null>(null);
|
||||
|
||||
|
|
@ -316,6 +340,89 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
|
|||
loadRelatedTables();
|
||||
}, [currentTableName, config.mainTableName]);
|
||||
|
||||
// Entity 조인 컬럼 정보 로드 (저장 테이블 기준)
|
||||
const entityJoinTargetTable = config.useCustomTable && config.mainTableName
|
||||
? config.mainTableName
|
||||
: currentTableName;
|
||||
|
||||
useEffect(() => {
|
||||
const fetchEntityJoinColumns = async () => {
|
||||
if (!entityJoinTargetTable) return;
|
||||
setLoadingEntityJoins(true);
|
||||
try {
|
||||
const result = await entityJoinApi.getEntityJoinColumns(entityJoinTargetTable);
|
||||
setEntityJoinData({
|
||||
joinTables: result.joinTables || [],
|
||||
availableColumns: result.availableColumns || [],
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Entity 조인 컬럼 조회 오류:", error);
|
||||
setEntityJoinData({ joinTables: [], availableColumns: [] });
|
||||
} finally {
|
||||
setLoadingEntityJoins(false);
|
||||
}
|
||||
};
|
||||
fetchEntityJoinColumns();
|
||||
}, [entityJoinTargetTable]);
|
||||
|
||||
// Entity 조인 컬럼 토글 (추가/제거)
|
||||
const toggleEntityJoinColumn = useCallback(
|
||||
(joinTableName: string, sourceColumn: string, refColumnName: string, refColumnLabel: string, displayField: string) => {
|
||||
const currentJoins = config.entityJoins || [];
|
||||
const existingJoinIdx = currentJoins.findIndex(
|
||||
(j) => j.sourceColumn === sourceColumn && j.referenceTable === joinTableName,
|
||||
);
|
||||
|
||||
if (existingJoinIdx >= 0) {
|
||||
const existingJoin = currentJoins[existingJoinIdx];
|
||||
const existingColIdx = existingJoin.columns.findIndex((c) => c.referenceField === refColumnName);
|
||||
|
||||
if (existingColIdx >= 0) {
|
||||
const updatedColumns = existingJoin.columns.filter((_, i) => i !== existingColIdx);
|
||||
if (updatedColumns.length === 0) {
|
||||
updateConfig({ entityJoins: currentJoins.filter((_, i) => i !== existingJoinIdx) });
|
||||
} else {
|
||||
const updated = [...currentJoins];
|
||||
updated[existingJoinIdx] = { ...existingJoin, columns: updatedColumns };
|
||||
updateConfig({ entityJoins: updated });
|
||||
}
|
||||
} else {
|
||||
const updated = [...currentJoins];
|
||||
updated[existingJoinIdx] = {
|
||||
...existingJoin,
|
||||
columns: [...existingJoin.columns, { referenceField: refColumnName, displayField }],
|
||||
};
|
||||
updateConfig({ entityJoins: updated });
|
||||
}
|
||||
} else {
|
||||
updateConfig({
|
||||
entityJoins: [
|
||||
...currentJoins,
|
||||
{
|
||||
sourceColumn,
|
||||
referenceTable: joinTableName,
|
||||
columns: [{ referenceField: refColumnName, displayField }],
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
},
|
||||
[config.entityJoins, updateConfig],
|
||||
);
|
||||
|
||||
// Entity 조인에 특정 컬럼이 설정되어 있는지 확인
|
||||
const isEntityJoinColumnActive = useCallback(
|
||||
(joinTableName: string, sourceColumn: string, refColumnName: string) => {
|
||||
return (config.entityJoins || []).some(
|
||||
(j) =>
|
||||
j.sourceColumn === sourceColumn &&
|
||||
j.referenceTable === joinTableName &&
|
||||
j.columns.some((c) => c.referenceField === refColumnName),
|
||||
);
|
||||
},
|
||||
[config.entityJoins],
|
||||
);
|
||||
|
||||
// 설정 업데이트 헬퍼
|
||||
const updateConfig = useCallback(
|
||||
(updates: Partial<V2RepeaterConfig>) => {
|
||||
|
|
@ -654,9 +761,10 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
|
|||
return (
|
||||
<div className="space-y-4">
|
||||
<Tabs defaultValue="basic" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="basic" className="text-xs">기본</TabsTrigger>
|
||||
<TabsTrigger value="columns" className="text-xs">컬럼</TabsTrigger>
|
||||
<TabsTrigger value="entityJoin" className="text-xs">Entity 조인</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 기본 설정 탭 */}
|
||||
|
|
@ -1704,6 +1812,120 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
|
|||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Entity 조인 설정 탭 */}
|
||||
<TabsContent value="entityJoin" className="mt-4 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">Entity 조인 연결</h3>
|
||||
<p className="text-muted-foreground text-[10px]">
|
||||
FK 컬럼을 기반으로 참조 테이블의 데이터를 자동으로 조회하여 표시합니다
|
||||
</p>
|
||||
</div>
|
||||
<hr className="border-border" />
|
||||
|
||||
{loadingEntityJoins ? (
|
||||
<p className="text-muted-foreground py-2 text-center text-xs">로딩 중...</p>
|
||||
) : entityJoinData.joinTables.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed p-4 text-center">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{entityJoinTargetTable
|
||||
? `${entityJoinTargetTable} 테이블에 Entity 조인 가능한 컬럼이 없습니다`
|
||||
: "저장 테이블을 먼저 설정해주세요"}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{entityJoinData.joinTables.map((joinTable, tableIndex) => {
|
||||
const sourceColumn = (joinTable as any).joinConfig?.sourceColumn || "";
|
||||
|
||||
return (
|
||||
<div key={tableIndex} className="space-y-1">
|
||||
<div className="mb-1 flex items-center gap-2 text-[10px] font-medium text-blue-600">
|
||||
<Link2 className="h-3 w-3" />
|
||||
<span>{joinTable.tableName}</span>
|
||||
<span className="text-muted-foreground">({sourceColumn})</span>
|
||||
</div>
|
||||
<div className="max-h-40 space-y-0.5 overflow-y-auto rounded-md border border-blue-200 bg-blue-50/30 p-2">
|
||||
{joinTable.availableColumns.map((column, colIndex) => {
|
||||
const isActive = isEntityJoinColumnActive(
|
||||
joinTable.tableName,
|
||||
sourceColumn,
|
||||
column.columnName,
|
||||
);
|
||||
const matchingCol = config.columns.find((c) => c.key === column.columnName);
|
||||
const displayField = matchingCol?.key || column.columnName;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={colIndex}
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center gap-2 rounded px-2 py-1 hover:bg-blue-100/50",
|
||||
isActive && "bg-blue-100",
|
||||
)}
|
||||
onClick={() =>
|
||||
toggleEntityJoinColumn(
|
||||
joinTable.tableName,
|
||||
sourceColumn,
|
||||
column.columnName,
|
||||
column.columnLabel,
|
||||
displayField,
|
||||
)
|
||||
}
|
||||
>
|
||||
<Checkbox
|
||||
checked={isActive}
|
||||
className="pointer-events-none h-3.5 w-3.5"
|
||||
/>
|
||||
<Link2 className="h-3 w-3 flex-shrink-0 text-blue-500" />
|
||||
<span className="truncate text-xs">{column.columnLabel}</span>
|
||||
<span className="ml-auto text-[10px] text-blue-400">
|
||||
{column.inputType || column.dataType}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 현재 설정된 Entity 조인 목록 */}
|
||||
{config.entityJoins && config.entityJoins.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-xs font-medium">설정된 조인</h4>
|
||||
<div className="space-y-1">
|
||||
{config.entityJoins.map((join, idx) => (
|
||||
<div key={idx} className="flex items-center gap-1 rounded border bg-muted/30 px-2 py-1 text-[10px]">
|
||||
<Database className="h-3 w-3 text-blue-500" />
|
||||
<span className="font-medium">{join.sourceColumn}</span>
|
||||
<ArrowRight className="h-3 w-3 text-muted-foreground" />
|
||||
<span>{join.referenceTable}</span>
|
||||
<span className="text-muted-foreground">
|
||||
({join.columns.map((c) => c.referenceField).join(", ")})
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
updateConfig({
|
||||
entityJoins: config.entityJoins!.filter((_, i) => i !== idx),
|
||||
});
|
||||
}}
|
||||
className="ml-auto h-4 w-4 p-0 text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -156,9 +156,15 @@ export async function updateScreenGroup(id: number, data: Partial<ScreenGroup>):
|
|||
}
|
||||
}
|
||||
|
||||
export async function deleteScreenGroup(id: number): Promise<ApiResponse<void>> {
|
||||
export async function deleteScreenGroup(id: number, options?: { deleteNumberingRules?: boolean }): Promise<ApiResponse<void>> {
|
||||
try {
|
||||
const response = await apiClient.delete(`/screen-groups/groups/${id}`);
|
||||
const params = new URLSearchParams();
|
||||
if (options?.deleteNumberingRules) {
|
||||
params.set("deleteNumberingRules", "true");
|
||||
}
|
||||
const queryString = params.toString();
|
||||
const url = `/screen-groups/groups/${id}${queryString ? `?${queryString}` : ""}`;
|
||||
const response = await apiClient.delete(url);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message };
|
||||
|
|
|
|||
|
|
@ -305,12 +305,26 @@ export function ItemSelectionModal({
|
|||
onOpenChange(false);
|
||||
};
|
||||
|
||||
// 이미 추가된 항목인지 확인
|
||||
// 이미 추가된 항목인지 확인 (매핑된 데이터의 _sourceData도 검사)
|
||||
const isAlreadyAdded = (item: any): boolean => {
|
||||
if (!uniqueField) return false;
|
||||
return alreadySelected.some(
|
||||
(selected) => selected[uniqueField] === item[uniqueField]
|
||||
);
|
||||
const checkField = uniqueField || "id";
|
||||
const itemValue = item[checkField];
|
||||
if (itemValue === undefined || itemValue === null) return false;
|
||||
const strItemValue = String(itemValue);
|
||||
|
||||
return alreadySelected.some((selected) => {
|
||||
// _sourceData 우선 확인 (DB 로드 항목의 참조 ID가 매핑되어 있음)
|
||||
const sourceValue = selected._sourceData?.[checkField];
|
||||
if (sourceValue !== undefined && sourceValue !== null && String(sourceValue) === strItemValue) {
|
||||
return true;
|
||||
}
|
||||
// _sourceData에 없으면 직접 필드 비교 (동일 필드명인 경우)
|
||||
const directValue = selected[checkField];
|
||||
if (directValue !== undefined && directValue !== null && String(directValue) === strItemValue) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
};
|
||||
|
||||
// 이미 추가된 항목 제외한 결과 필터링
|
||||
|
|
|
|||
|
|
@ -642,6 +642,12 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
|||
}
|
||||
}
|
||||
|
||||
// 메인 레코드 ID 전달 (분할패널에서는 좌측 선택 항목이 메인 레코드)
|
||||
const leftPkColumn = config.leftPanel?.primaryKeyColumn || "id";
|
||||
if (selectedLeftItem?.[leftPkColumn]) {
|
||||
initialData._mainRecordId = selectedLeftItem[leftPkColumn];
|
||||
}
|
||||
|
||||
// EditModal 열기 이벤트 발생
|
||||
const event = new CustomEvent("openEditModal", {
|
||||
detail: {
|
||||
|
|
@ -649,11 +655,12 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
|||
title: config.rightPanel?.addButtonLabel || "추가",
|
||||
modalSize: "lg",
|
||||
editData: initialData,
|
||||
isCreateMode: true, // 생성 모드
|
||||
isCreateMode: true,
|
||||
onSave: () => {
|
||||
if (selectedLeftItem) {
|
||||
loadRightData(selectedLeftItem);
|
||||
}
|
||||
loadLeftData();
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -661,9 +668,11 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
|||
}, [
|
||||
config.rightPanel?.addModalScreenId,
|
||||
config.rightPanel?.addButtonLabel,
|
||||
config.leftPanel?.primaryKeyColumn,
|
||||
config.dataTransferFields,
|
||||
selectedLeftItem,
|
||||
loadRightData,
|
||||
loadLeftData,
|
||||
]);
|
||||
|
||||
// 기본키 컬럼명 가져오기 (우측 패널)
|
||||
|
|
@ -716,25 +725,32 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
|||
}
|
||||
}
|
||||
|
||||
// 메인 레코드 ID 전달 (디테일 레코드의 id와 구분)
|
||||
const editItemLeftPkColumn = config.leftPanel?.primaryKeyColumn || "id";
|
||||
if (selectedLeftItem?.[editItemLeftPkColumn]) {
|
||||
editData._mainRecordId = selectedLeftItem[editItemLeftPkColumn];
|
||||
}
|
||||
|
||||
// EditModal 열기 이벤트 발생 (수정 모드)
|
||||
const event = new CustomEvent("openEditModal", {
|
||||
detail: {
|
||||
screenId: modalScreenId,
|
||||
title: "수정",
|
||||
modalSize: "lg",
|
||||
editData: editData, // 병합된 데이터 전달
|
||||
isCreateMode: false, // 수정 모드
|
||||
editData: editData,
|
||||
isCreateMode: false,
|
||||
onSave: () => {
|
||||
if (selectedLeftItem) {
|
||||
loadRightData(selectedLeftItem);
|
||||
}
|
||||
loadLeftData();
|
||||
},
|
||||
},
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
console.log("[SplitPanelLayout2] 우측 수정 모달 열기:", editData);
|
||||
},
|
||||
[config.rightPanel?.editModalScreenId, config.rightPanel?.addModalScreenId, config.rightPanel?.mainTableForEdit, selectedLeftItem, loadRightData],
|
||||
[config.rightPanel?.editModalScreenId, config.rightPanel?.addModalScreenId, config.rightPanel?.mainTableForEdit, config.leftPanel?.primaryKeyColumn, selectedLeftItem, loadRightData, loadLeftData],
|
||||
);
|
||||
|
||||
// 좌측 패널 수정 버튼 클릭
|
||||
|
|
@ -835,10 +851,11 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
|||
// 데이터 새로고침
|
||||
if (deleteTargetPanel === "left") {
|
||||
loadLeftData();
|
||||
setSelectedLeftItem(null); // 좌측 선택 초기화
|
||||
setRightData([]); // 우측 데이터도 초기화
|
||||
setSelectedLeftItem(null);
|
||||
setRightData([]);
|
||||
} else if (selectedLeftItem) {
|
||||
loadRightData(selectedLeftItem);
|
||||
loadLeftData();
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("[SplitPanelLayout2] 삭제 실패:", error);
|
||||
|
|
@ -892,6 +909,12 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
|||
}
|
||||
}
|
||||
|
||||
// 메인 레코드 ID 전달 (분할패널에서는 좌측 선택 항목이 메인 레코드)
|
||||
const addLeftPkColumn = config.leftPanel?.primaryKeyColumn || "id";
|
||||
if (selectedLeftItem?.[addLeftPkColumn]) {
|
||||
initialData._mainRecordId = selectedLeftItem[addLeftPkColumn];
|
||||
}
|
||||
|
||||
const event = new CustomEvent("openEditModal", {
|
||||
detail: {
|
||||
screenId: btn.modalScreenId,
|
||||
|
|
@ -903,6 +926,7 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
|||
if (selectedLeftItem) {
|
||||
loadRightData(selectedLeftItem);
|
||||
}
|
||||
loadLeftData();
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -917,7 +941,6 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
|||
const selectedId = Array.from(selectedRightItems)[0];
|
||||
const item = rightData.find((d) => d[pkColumn] === selectedId);
|
||||
if (item) {
|
||||
// 액션 버튼에 모달 화면이 설정되어 있으면 해당 화면 사용
|
||||
const modalScreenId = btn.modalScreenId || config.rightPanel?.editModalScreenId || config.rightPanel?.addModalScreenId;
|
||||
|
||||
if (!modalScreenId) {
|
||||
|
|
@ -925,17 +948,25 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
|||
return;
|
||||
}
|
||||
|
||||
// 메인 레코드 ID 전달 (디테일 레코드의 id와 구분)
|
||||
const editLeftPkColumn = config.leftPanel?.primaryKeyColumn || "id";
|
||||
const editData = { ...item };
|
||||
if (selectedLeftItem?.[editLeftPkColumn]) {
|
||||
editData._mainRecordId = selectedLeftItem[editLeftPkColumn];
|
||||
}
|
||||
|
||||
const event = new CustomEvent("openEditModal", {
|
||||
detail: {
|
||||
screenId: modalScreenId,
|
||||
title: btn.label || "수정",
|
||||
modalSize: "lg",
|
||||
editData: item,
|
||||
editData,
|
||||
isCreateMode: false,
|
||||
onSave: () => {
|
||||
if (selectedLeftItem) {
|
||||
loadRightData(selectedLeftItem);
|
||||
}
|
||||
loadLeftData();
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -966,6 +997,7 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
|||
selectedLeftItem,
|
||||
config.dataTransferFields,
|
||||
loadRightData,
|
||||
loadLeftData,
|
||||
selectedRightItems,
|
||||
getPrimaryKeyColumn,
|
||||
rightData,
|
||||
|
|
|
|||
|
|
@ -1262,62 +1262,54 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
return;
|
||||
}
|
||||
|
||||
if (categoryColumns.length === 0) {
|
||||
setCategoryMappings({});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const mappings: Record<string, Record<string, { label: string; color?: string }>> = {};
|
||||
const apiClient = (await import("@/lib/api/client")).apiClient;
|
||||
|
||||
// 트리 구조를 평탄화하는 헬퍼 함수 (메인 테이블 + 엔티티 조인 공통 사용)
|
||||
const flattenTree = (items: any[], mapping: Record<string, { label: string; color?: string }>) => {
|
||||
items.forEach((item: any) => {
|
||||
if (item.valueCode) {
|
||||
mapping[String(item.valueCode)] = {
|
||||
label: item.valueLabel,
|
||||
color: item.color,
|
||||
};
|
||||
}
|
||||
if (item.valueId !== undefined && item.valueId !== null) {
|
||||
mapping[String(item.valueId)] = {
|
||||
label: item.valueLabel,
|
||||
color: item.color,
|
||||
};
|
||||
}
|
||||
if (item.children && Array.isArray(item.children) && item.children.length > 0) {
|
||||
flattenTree(item.children, mapping);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
for (const columnName of categoryColumns) {
|
||||
try {
|
||||
// 🆕 엔티티 조인 컬럼 처리: "테이블명.컬럼명" 형태인지 확인
|
||||
let targetTable = tableConfig.selectedTable;
|
||||
let targetColumn = columnName;
|
||||
|
||||
if (columnName.includes(".")) {
|
||||
const parts = columnName.split(".");
|
||||
targetTable = parts[0]; // 조인된 테이블명 (예: item_info)
|
||||
targetColumn = parts[1]; // 실제 컬럼명 (예: material)
|
||||
targetTable = parts[0];
|
||||
targetColumn = parts[1];
|
||||
}
|
||||
|
||||
const response = await apiClient.get(`/table-categories/${targetTable}/${targetColumn}/values`);
|
||||
|
||||
if (response.data.success && response.data.data && Array.isArray(response.data.data)) {
|
||||
const mapping: Record<string, { label: string; color?: string }> = {};
|
||||
|
||||
response.data.data.forEach((item: any) => {
|
||||
// valueCode를 문자열로 변환하여 키로 사용
|
||||
const key = String(item.valueCode);
|
||||
mapping[key] = {
|
||||
label: item.valueLabel,
|
||||
color: item.color,
|
||||
};
|
||||
});
|
||||
flattenTree(response.data.data, mapping);
|
||||
|
||||
if (Object.keys(mapping).length > 0) {
|
||||
// 🆕 원래 컬럼명(item_info.material)으로 매핑 저장
|
||||
mappings[columnName] = mapping;
|
||||
} else {
|
||||
console.warn(`⚠️ [TableList] 매핑 데이터가 비어있음 [${columnName}]`);
|
||||
}
|
||||
} else {
|
||||
console.warn(`⚠️ [TableList] 카테고리 값 없음 [${columnName}]:`, {
|
||||
success: response.data.success,
|
||||
hasData: !!response.data.data,
|
||||
isArray: Array.isArray(response.data.data),
|
||||
response: response.data,
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(`❌ [TableList] 카테고리 값 로드 실패 [${columnName}]:`, {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
response: error.response?.data,
|
||||
status: error.response?.status,
|
||||
});
|
||||
console.error(`[TableList] 카테고리 값 로드 실패 [${columnName}]:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1393,14 +1385,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
|
||||
if (response.data.success && response.data.data && Array.isArray(response.data.data)) {
|
||||
const mapping: Record<string, { label: string; color?: string }> = {};
|
||||
|
||||
response.data.data.forEach((item: any) => {
|
||||
const key = String(item.valueCode);
|
||||
mapping[key] = {
|
||||
label: item.valueLabel,
|
||||
color: item.color,
|
||||
};
|
||||
});
|
||||
flattenTree(response.data.data, mapping);
|
||||
|
||||
if (Object.keys(mapping).length > 0) {
|
||||
mappings[col.columnName] = mapping;
|
||||
|
|
@ -1449,8 +1434,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
// 연쇄관계 매핑이 없는 경우 무시 (404 등)
|
||||
}
|
||||
|
||||
setCategoryMappings(mappings);
|
||||
if (Object.keys(mappings).length > 0) {
|
||||
setCategoryMappings(mappings);
|
||||
setCategoryMappingsKey((prev) => prev + 1);
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -1464,7 +1449,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
categoryColumns.length,
|
||||
JSON.stringify(categoryColumns),
|
||||
JSON.stringify(tableConfig.columns),
|
||||
]); // 더 명확한 의존성
|
||||
]);
|
||||
|
||||
// ========================================
|
||||
// 데이터 가져오기
|
||||
|
|
|
|||
|
|
@ -2132,6 +2132,101 @@ export function TableSectionRenderer({
|
|||
return Object.values(conditionalTableData).reduce((sum, data) => sum + data.length, 0);
|
||||
}, [conditionalTableData]);
|
||||
|
||||
// 조건부 테이블: 모달 중복 체크용 alreadySelected 구성
|
||||
// DB에서 로드된 항목은 _sourceData가 없으므로 참조 ID 필드를 기반으로 _sourceData를 생성
|
||||
const conditionalAlreadySelected = useMemo(() => {
|
||||
const allItems = Object.values(conditionalTableData).flat();
|
||||
if (allItems.length === 0) return allItems;
|
||||
|
||||
// 참조 ID 필드 탐색 (소스 테이블의 id를 저장하는 디테일 테이블 컬럼)
|
||||
const referenceIdField = (tableConfig.columns || [])
|
||||
.map((col) => col.saveConfig?.referenceDisplay?.referenceIdField)
|
||||
.find(Boolean)
|
||||
|| (tableConfig.columns || [])
|
||||
.map((col) => (col as any).dynamicSelectOptions?.rowSelectionMode?.targetIdField)
|
||||
.find(Boolean);
|
||||
|
||||
// sourceField 매핑 수집 (소스 테이블 필드 → 디테일 테이블 필드)
|
||||
const sourceFieldMap: Record<string, string> = {};
|
||||
for (const col of tableConfig.columns || []) {
|
||||
if (col.sourceField && col.sourceField !== col.field) {
|
||||
sourceFieldMap[col.sourceField] = col.field;
|
||||
}
|
||||
}
|
||||
|
||||
return allItems.map((item) => {
|
||||
if (item._sourceData) return item;
|
||||
|
||||
// DB에서 로드된 항목: _sourceData 재구성
|
||||
const sourceData: any = {};
|
||||
|
||||
// 참조 ID 필드가 있으면 소스 테이블의 id로 매핑
|
||||
if (referenceIdField && item[referenceIdField] !== undefined) {
|
||||
sourceData.id = item[referenceIdField];
|
||||
}
|
||||
|
||||
// sourceField 매핑을 역으로 적용 (디테일 필드 → 소스 필드)
|
||||
for (const [srcField, detailField] of Object.entries(sourceFieldMap)) {
|
||||
if (item[detailField] !== undefined) {
|
||||
sourceData[srcField] = item[detailField];
|
||||
}
|
||||
}
|
||||
|
||||
// 디테일 테이블의 필드도 소스 데이터에 포함 (동일 필드명인 경우)
|
||||
for (const col of tableConfig.columns || []) {
|
||||
if (!col.sourceField && item[col.field] !== undefined) {
|
||||
sourceData[col.field] = item[col.field];
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(sourceData).length > 0
|
||||
? { ...item, _sourceData: sourceData }
|
||||
: item;
|
||||
});
|
||||
}, [conditionalTableData, tableConfig.columns]);
|
||||
|
||||
// 일반 테이블: 모달 중복 체크용 alreadySelected 구성 (DB 로드 항목 대응)
|
||||
const normalAlreadySelected = useMemo(() => {
|
||||
if (tableData.length === 0) return tableData;
|
||||
|
||||
const referenceIdField = (tableConfig.columns || [])
|
||||
.map((col) => col.saveConfig?.referenceDisplay?.referenceIdField)
|
||||
.find(Boolean)
|
||||
|| (tableConfig.columns || [])
|
||||
.map((col) => (col as any).dynamicSelectOptions?.rowSelectionMode?.targetIdField)
|
||||
.find(Boolean);
|
||||
|
||||
const sourceFieldMap: Record<string, string> = {};
|
||||
for (const col of tableConfig.columns || []) {
|
||||
if (col.sourceField && col.sourceField !== col.field) {
|
||||
sourceFieldMap[col.sourceField] = col.field;
|
||||
}
|
||||
}
|
||||
|
||||
return tableData.map((item) => {
|
||||
if (item._sourceData) return item;
|
||||
|
||||
const sourceData: any = {};
|
||||
if (referenceIdField && item[referenceIdField] !== undefined) {
|
||||
sourceData.id = item[referenceIdField];
|
||||
}
|
||||
for (const [srcField, detailField] of Object.entries(sourceFieldMap)) {
|
||||
if (item[detailField] !== undefined) {
|
||||
sourceData[srcField] = item[detailField];
|
||||
}
|
||||
}
|
||||
for (const col of tableConfig.columns || []) {
|
||||
if (!col.sourceField && item[col.field] !== undefined) {
|
||||
sourceData[col.field] = item[col.field];
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(sourceData).length > 0
|
||||
? { ...item, _sourceData: sourceData }
|
||||
: item;
|
||||
});
|
||||
}, [tableData, tableConfig.columns]);
|
||||
|
||||
// ============================================
|
||||
// 조건부 테이블 렌더링
|
||||
// ============================================
|
||||
|
|
@ -2449,7 +2544,7 @@ export function TableSectionRenderer({
|
|||
multiSelect={multiSelect}
|
||||
filterCondition={conditionalFilterCondition}
|
||||
modalTitle={`${effectiveOptions.find((o) => o.value === modalCondition)?.label || modalCondition} - ${modalTitle}`}
|
||||
alreadySelected={conditionalTableData[modalCondition] || []}
|
||||
alreadySelected={conditionalAlreadySelected}
|
||||
uniqueField={tableConfig.saveConfig?.uniqueField}
|
||||
onSelect={handleConditionalAddItems}
|
||||
columnLabels={columnLabels}
|
||||
|
|
@ -2560,7 +2655,7 @@ export function TableSectionRenderer({
|
|||
multiSelect={multiSelect}
|
||||
filterCondition={baseFilterCondition}
|
||||
modalTitle={modalTitle}
|
||||
alreadySelected={tableData}
|
||||
alreadySelected={normalAlreadySelected}
|
||||
uniqueField={tableConfig.saveConfig?.uniqueField}
|
||||
onSelect={handleAddItems}
|
||||
columnLabels={columnLabels}
|
||||
|
|
|
|||
|
|
@ -468,6 +468,11 @@ export function UniversalFormModalComponent({
|
|||
console.log(`[UniversalFormModal] 원본 그룹 데이터 병합: ${originalGroupedData.length}개`);
|
||||
}
|
||||
|
||||
// 분할패널에서 전달한 메인 레코드 ID 전달
|
||||
if (latestFormData._mainRecordId) {
|
||||
event.detail.formData._mainRecordId = latestFormData._mainRecordId;
|
||||
}
|
||||
|
||||
// 🆕 부모 formData의 중첩 객체(modalKey)도 최신 데이터로 업데이트
|
||||
// onChange(setTimeout)가 아직 부모에 전파되지 않았을 수 있으므로 직접 업데이트
|
||||
for (const parentKey of Object.keys(event.detail.formData)) {
|
||||
|
|
@ -993,6 +998,11 @@ export function UniversalFormModalComponent({
|
|||
}
|
||||
}
|
||||
|
||||
// 분할패널에서 전달한 메인 레코드 ID 보존
|
||||
if (effectiveInitialData?._mainRecordId) {
|
||||
newFormData._mainRecordId = effectiveInitialData._mainRecordId;
|
||||
}
|
||||
|
||||
setFormData(newFormData);
|
||||
formDataRef.current = newFormData;
|
||||
setRepeatSections(newRepeatSections);
|
||||
|
|
|
|||
|
|
@ -44,9 +44,11 @@ const V2RepeaterRenderer: React.FC<V2RepeaterRendererProps> = ({
|
|||
console.log("📋 V2RepeaterRenderer config 추출:", {
|
||||
hasComponentConfig: !!component?.componentConfig,
|
||||
hasConfig: !!component?.config,
|
||||
hasOverrides: !!(component as any)?.overrides,
|
||||
useCustomTable: componentConfig.useCustomTable,
|
||||
mainTableName: componentConfig.mainTableName,
|
||||
foreignKeyColumn: componentConfig.foreignKeyColumn,
|
||||
entityJoins: componentConfig.entityJoins,
|
||||
});
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -1396,15 +1396,31 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
return;
|
||||
}
|
||||
|
||||
if (categoryColumns.length === 0) {
|
||||
setCategoryMappings({});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const mappings: Record<string, Record<string, { label: string; color?: string }>> = {};
|
||||
const apiClient = (await import("@/lib/api/client")).apiClient;
|
||||
|
||||
// 트리 구조를 평탄화하는 헬퍼 함수 (메인 테이블 + 엔티티 조인 공통 사용)
|
||||
const flattenTree = (items: any[], mapping: Record<string, { label: string; color?: string }>) => {
|
||||
items.forEach((item: any) => {
|
||||
if (item.valueCode) {
|
||||
mapping[String(item.valueCode)] = {
|
||||
label: item.valueLabel,
|
||||
color: item.color,
|
||||
};
|
||||
}
|
||||
if (item.valueId !== undefined && item.valueId !== null) {
|
||||
mapping[String(item.valueId)] = {
|
||||
label: item.valueLabel,
|
||||
color: item.color,
|
||||
};
|
||||
}
|
||||
if (item.children && Array.isArray(item.children) && item.children.length > 0) {
|
||||
flattenTree(item.children, mapping);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
for (const columnName of categoryColumns) {
|
||||
try {
|
||||
let targetTable = tableConfig.selectedTable;
|
||||
|
|
@ -1429,39 +1445,10 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
|
||||
if (response.data.success && response.data.data && Array.isArray(response.data.data)) {
|
||||
const mapping: Record<string, { label: string; color?: string }> = {};
|
||||
|
||||
// 트리 구조를 평탄화하는 헬퍼 함수
|
||||
const flattenTree = (items: any[]) => {
|
||||
items.forEach((item: any) => {
|
||||
// valueCode를 문자열로 변환하여 키로 사용
|
||||
if (item.valueCode) {
|
||||
const key = String(item.valueCode);
|
||||
mapping[key] = {
|
||||
label: item.valueLabel,
|
||||
color: item.color,
|
||||
};
|
||||
}
|
||||
// valueId도 키로 추가 (숫자 ID 저장 시 라벨 표시용)
|
||||
if (item.valueId !== undefined && item.valueId !== null) {
|
||||
mapping[String(item.valueId)] = {
|
||||
label: item.valueLabel,
|
||||
color: item.color,
|
||||
};
|
||||
}
|
||||
// 자식 노드도 재귀적으로 처리
|
||||
if (item.children && Array.isArray(item.children) && item.children.length > 0) {
|
||||
flattenTree(item.children);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
flattenTree(response.data.data);
|
||||
flattenTree(response.data.data, mapping);
|
||||
|
||||
if (Object.keys(mapping).length > 0) {
|
||||
// 🆕 원래 컬럼명(item_info.material)으로 매핑 저장
|
||||
mappings[columnName] = mapping;
|
||||
} else {
|
||||
// 매핑 데이터가 비어있음 - 해당 컬럼에 카테고리 값이 없음
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
|
|
@ -1541,24 +1528,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
|
||||
if (response.data.success && response.data.data && Array.isArray(response.data.data)) {
|
||||
const mapping: Record<string, { label: string; color?: string }> = {};
|
||||
|
||||
response.data.data.forEach((item: any) => {
|
||||
// valueCode로 매핑
|
||||
if (item.valueCode) {
|
||||
const key = String(item.valueCode);
|
||||
mapping[key] = {
|
||||
label: item.valueLabel,
|
||||
color: item.color,
|
||||
};
|
||||
}
|
||||
// valueId도 키로 추가 (숫자 ID 저장 시 라벨 표시용)
|
||||
if (item.valueId !== undefined && item.valueId !== null) {
|
||||
mapping[String(item.valueId)] = {
|
||||
label: item.valueLabel,
|
||||
color: item.color,
|
||||
};
|
||||
}
|
||||
});
|
||||
flattenTree(response.data.data, mapping);
|
||||
|
||||
if (Object.keys(mapping).length > 0) {
|
||||
mappings[col.columnName] = mapping;
|
||||
|
|
@ -1608,8 +1578,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
// 연쇄관계 매핑이 없는 경우 무시
|
||||
}
|
||||
|
||||
setCategoryMappings(mappings);
|
||||
if (Object.keys(mappings).length > 0) {
|
||||
setCategoryMappings(mappings);
|
||||
setCategoryMappingsKey((prev) => prev + 1);
|
||||
}
|
||||
} catch {
|
||||
|
|
|
|||
|
|
@ -2339,9 +2339,12 @@ export class ButtonActionExecutor {
|
|||
}
|
||||
|
||||
// 🎯 채번 규칙 할당 처리 (저장 시점에 실제 순번 증가)
|
||||
// 🔧 수정 모드 체크: formData.id 또는 originalGroupedData가 있으면 UPDATE 모드
|
||||
// 🔧 수정 모드 체크: _mainRecordId, formData.id 또는 originalGroupedData가 있으면 UPDATE 모드
|
||||
const parentMainRecordId = modalData._mainRecordId || formData._mainRecordId;
|
||||
const isEditModeUniversal =
|
||||
(formData.id !== undefined && formData.id !== null && formData.id !== "") || originalGroupedData.length > 0;
|
||||
(parentMainRecordId !== undefined && parentMainRecordId !== null && parentMainRecordId !== "") ||
|
||||
(formData.id !== undefined && formData.id !== null && formData.id !== "") ||
|
||||
originalGroupedData.length > 0;
|
||||
|
||||
const fieldsWithNumbering: Record<string, string> = {};
|
||||
|
||||
|
|
@ -2417,6 +2420,13 @@ export class ButtonActionExecutor {
|
|||
);
|
||||
|
||||
if (hasSeparateTargetTable && Object.keys(commonFieldsData).length > 0) {
|
||||
// _mainRecordId: 분할패널에서 전달한 실제 메인 레코드 ID (formData.id는 디테일 레코드 ID일 수 있음)
|
||||
const mainRecordIdFromParent =
|
||||
modalData._mainRecordId || formData._mainRecordId || commonFieldsData._mainRecordId;
|
||||
// 메인 테이블 ID 결정: _mainRecordId > formData.id 순서로 탐색
|
||||
const existingMainId = mainRecordIdFromParent || formData.id;
|
||||
const isMainUpdate = existingMainId !== undefined && existingMainId !== null && existingMainId !== "";
|
||||
|
||||
const mainRowToSave = { ...commonFieldsData, ...userInfo };
|
||||
|
||||
// 메타데이터 제거
|
||||
|
|
@ -2426,24 +2436,15 @@ export class ButtonActionExecutor {
|
|||
}
|
||||
});
|
||||
|
||||
// 🆕 메인 테이블 UPDATE/INSERT 판단
|
||||
// - formData.id가 있으면 편집 모드 → UPDATE
|
||||
// - formData.id가 없으면 신규 등록 → INSERT
|
||||
const existingMainId = formData.id;
|
||||
const isMainUpdate = existingMainId !== undefined && existingMainId !== null && existingMainId !== "";
|
||||
|
||||
let mainSaveResult: { success: boolean; data?: any; message?: string };
|
||||
|
||||
if (isMainUpdate) {
|
||||
// 🔄 편집 모드: UPDATE 실행
|
||||
|
||||
mainSaveResult = await DynamicFormApi.updateFormData(existingMainId, {
|
||||
tableName: tableName!,
|
||||
data: mainRowToSave,
|
||||
});
|
||||
mainRecordId = existingMainId;
|
||||
} else {
|
||||
// ➕ 신규 등록: INSERT 실행
|
||||
console.log("➕ [handleUniversalFormModalTableSectionSave] 메인 테이블 INSERT 실행");
|
||||
mainSaveResult = await DynamicFormApi.saveFormData({
|
||||
screenId: screenId!,
|
||||
|
|
@ -2471,8 +2472,32 @@ export class ButtonActionExecutor {
|
|||
|
||||
// 1️⃣ 신규 품목 INSERT (id가 없는 항목)
|
||||
const newItems = currentItems.filter((item) => !item.id);
|
||||
const existingItemsForRef = currentItems.filter((item) => item.id);
|
||||
|
||||
// 기존 DB 항목에서 공통 필드 추출 (수정 모달에서 commonFieldsData에 누락된 필드 보완)
|
||||
// 예: item_code, item_name 등이 commonFieldsData에 없으면 기존 항목에서 가져옴
|
||||
const sharedFieldsFromExisting: Record<string, any> = {};
|
||||
if (existingItemsForRef.length > 0 && newItems.length > 0) {
|
||||
const refItem = existingItemsForRef[0];
|
||||
for (const [key, val] of Object.entries(refItem)) {
|
||||
if (
|
||||
key !== "id" &&
|
||||
!key.startsWith("_") &&
|
||||
val !== undefined &&
|
||||
val !== null &&
|
||||
val !== "" &&
|
||||
commonFieldsData[key] === undefined
|
||||
) {
|
||||
sharedFieldsFromExisting[key] = val;
|
||||
}
|
||||
}
|
||||
if (Object.keys(sharedFieldsFromExisting).length > 0) {
|
||||
console.log("📋 [INSERT] 기존 항목에서 공통 필드 보완:", Object.keys(sharedFieldsFromExisting));
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of newItems) {
|
||||
const rowToSave = { ...commonFieldsData, ...item, ...userInfo };
|
||||
const rowToSave = { ...sharedFieldsFromExisting, ...commonFieldsData, ...item, ...userInfo };
|
||||
|
||||
Object.keys(rowToSave).forEach((key) => {
|
||||
if (key.startsWith("_")) {
|
||||
|
|
@ -6484,6 +6509,12 @@ export class ButtonActionExecutor {
|
|||
return true;
|
||||
} else {
|
||||
// 기본: 분할 패널 데이터 전달 이벤트
|
||||
console.log("📤 [transferData] splitPanelDataTransfer 발송:", {
|
||||
rowCount: selectedRows.length,
|
||||
mappingRules,
|
||||
sampleRow: selectedRows[0],
|
||||
hasItemId: selectedRows[0]?.item_id,
|
||||
});
|
||||
|
||||
const transferEvent = new CustomEvent("splitPanelDataTransfer", {
|
||||
detail: {
|
||||
|
|
|
|||
|
|
@ -142,6 +142,16 @@ export interface CalculationRule {
|
|||
label?: string;
|
||||
}
|
||||
|
||||
// Entity 조인 설정 (리피터 컬럼의 FK를 참조 테이블과 조인하여 표시)
|
||||
export interface RepeaterEntityJoin {
|
||||
sourceColumn: string; // FK 컬럼 (예: "item_id")
|
||||
referenceTable: string; // 참조 테이블 (예: "item_info")
|
||||
columns: Array<{
|
||||
referenceField: string; // 참조 테이블 컬럼 (예: "item_name")
|
||||
displayField: string; // 리피터 표시 컬럼 키 (예: "item_name")
|
||||
}>;
|
||||
}
|
||||
|
||||
// 소스 디테일 설정 (모달에서 전달받은 마스터 데이터의 디테일을 자동 조회)
|
||||
export interface SourceDetailConfig {
|
||||
tableName: string; // 디테일 테이블명 (예: "sales_order_detail")
|
||||
|
|
@ -185,6 +195,9 @@ export interface V2RepeaterConfig {
|
|||
// 모달 설정 (modal, mixed 모드)
|
||||
modal?: RepeaterModalConfig;
|
||||
|
||||
// Entity 조인 설정 (FK 기반으로 참조 테이블 데이터를 자동 해석하여 표시)
|
||||
entityJoins?: RepeaterEntityJoin[];
|
||||
|
||||
// 기능 옵션
|
||||
features: RepeaterFeatureOptions;
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue