Compare commits

...

3 Commits

Author SHA1 Message Date
kjs ac2da7a1d7 feat: Implement entity join functionality in V2Repeater and configuration panel
- Added support for entity joins in the V2Repeater component, allowing for automatic resolution of foreign key references to display data from related tables.
- Introduced a new `resolveEntityJoins` function to handle the fetching and mapping of reference data based on configured entity joins.
- Enhanced the V2RepeaterConfigPanel to manage entity join configurations, including loading available columns and toggling join settings.
- Updated the data handling logic to incorporate mapping rules for incoming data, ensuring that only necessary fields are retained during processing.
- Improved user experience by providing clear logging and feedback during entity join resolution and data mapping operations.
2026-03-04 21:08:45 +09:00
kjs f97edad1ea feat: Enhance screen group deletion functionality with optional numbering rules deletion
- Added a new query parameter `deleteNumberingRules` to the `deleteScreenGroup` function, allowing users to specify if numbering rules should be deleted when a root screen group is removed.
- Updated the `deleteScreenGroup` controller to handle the deletion of numbering rules conditionally based on the new parameter.
- Enhanced the frontend `ScreenGroupTreeView` component to include a checkbox for users to confirm the deletion of numbering rules when deleting a root group, improving user control and clarity during deletion operations.
- Implemented appropriate warnings and messages to inform users about the implications of deleting numbering rules, ensuring better user experience and data integrity awareness.
2026-03-04 18:42:44 +09:00
kjs 93d9df3e5a feat: Refactor category mapping logic in TableListComponent
- Introduced a helper function to flatten tree structures for category mappings, improving code readability and maintainability.
- Removed redundant checks for empty category columns, streamlining the data fetching process.
- Enhanced error handling for category value loading, ensuring clearer logging of failures.
- Updated the mapping logic to utilize the new flattening function, ensuring consistent handling of category data across components.
2026-03-04 16:41:51 +09:00
17 changed files with 1021 additions and 184 deletions

View File

@ -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]
);
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]
// 2-4. 해당 회사의 채번 규칙 삭제 (최상위 그룹 삭제 + 사용자가 명시적으로 요청한 경우에만)
if (deleteNumberingRules) {
const isRootGroup = await client.query(
`SELECT 1 FROM screen_groups WHERE id = $1 AND parent_group_id IS NULL`,
[id]
);
// 규칙 삭제
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
});
}
}
}
}

View File

@ -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,16 +858,26 @@ 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,
table_name, column_name, input_type, detail_settings,
is_nullable, display_order, company_code, created_date, updated_date
) VALUES ($1, $2, $3, $4, 'Y', 0, $5, now(), now())
ON CONFLICT (table_name, column_name, company_code)
DO UPDATE SET
ON CONFLICT (table_name, column_name, company_code)
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,
]
);

View File

@ -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 병합 로직 |

View File

@ -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;
}),

View File

@ -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>
@ -1520,6 +1529,43 @@ export function ScreenGroupTreeView({
</label>
</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 && (
@ -1551,7 +1597,7 @@ export function ScreenGroupTreeView({
</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault(); // 자동 닫힘 방지
e.preventDefault();
confirmDeleteGroup();
}}
disabled={isDeleting}

View File

@ -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);

View File

@ -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>
);

View File

@ -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 };

View File

@ -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;
});
};
// 이미 추가된 항목 제외한 결과 필터링

View File

@ -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,

View File

@ -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),
]); // 더 명확한 의존성
]);
// ========================================
// 데이터 가져오기

View File

@ -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}

View File

@ -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);

View File

@ -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 {

View File

@ -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 {

View File

@ -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: {

View File

@ -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;