Compare commits

..

No commits in common. "7a7d06e785db4ab171ddfa9b5a3ecd574c106bfa" and "589f5b92229ccd0e228424e9a2feaae42f1ebe3f" have entirely different histories.

69 changed files with 2452 additions and 10978 deletions

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,279 @@
# inputType 사용 가이드
## 핵심 원칙
**컬럼 타입 판단 시 반드시 `inputType`을 사용해야 합니다. `webType`은 레거시이며 더 이상 사용하지 않습니다.**
---
## 올바른 사용법
### ✅ inputType 사용 (권장)
```typescript
// 카테고리 타입 체크
if (columnMeta.inputType === "category") {
// 카테고리 처리 로직
}
// 코드 타입 체크
if (meta.inputType === "code") {
// 코드 처리 로직
}
// 필터링
const categoryColumns = Object.entries(columnMeta)
.filter(([_, meta]) => meta.inputType === "category")
.map(([columnName, _]) => columnName);
```
### ❌ webType 사용 (금지)
```typescript
// ❌ 절대 사용 금지!
if (columnMeta.webType === "category") { ... }
// ❌ 이것도 금지!
const categoryColumns = columns.filter(col => col.webType === "category");
```
---
## API에서 inputType 가져오기
### Backend API
```typescript
// 컬럼 입력 타입 정보 가져오기
const inputTypes = await tableTypeApi.getColumnInputTypes(tableName);
// inputType 맵 생성
const inputTypeMap: Record<string, string> = {};
inputTypes.forEach((col: any) => {
inputTypeMap[col.columnName] = col.inputType;
});
```
### columnMeta 구조
```typescript
interface ColumnMeta {
webType?: string; // 레거시, 사용 금지
codeCategory?: string;
inputType?: string; // ✅ 반드시 이것 사용!
}
const columnMeta: Record<string, ColumnMeta> = {
material: {
webType: "category", // 무시
codeCategory: "",
inputType: "category", // ✅ 이것만 사용
},
};
```
---
## 캐시 사용 시 주의사항
### ❌ 잘못된 캐시 처리 (inputType 누락)
```typescript
const cached = tableColumnCache.get(cacheKey);
if (cached) {
const meta: Record<string, ColumnMeta> = {};
cached.columns.forEach((col: any) => {
meta[col.columnName] = {
webType: col.webType,
codeCategory: col.codeCategory,
// ❌ inputType 누락!
};
});
}
```
### ✅ 올바른 캐시 처리 (inputType 포함)
```typescript
const cached = tableColumnCache.get(cacheKey);
if (cached) {
const meta: Record<string, ColumnMeta> = {};
// 캐시된 inputTypes 맵 생성
const inputTypeMap: Record<string, string> = {};
if (cached.inputTypes) {
cached.inputTypes.forEach((col: any) => {
inputTypeMap[col.columnName] = col.inputType;
});
}
cached.columns.forEach((col: any) => {
meta[col.columnName] = {
webType: col.webType,
codeCategory: col.codeCategory,
inputType: inputTypeMap[col.columnName], // ✅ inputType 포함!
};
});
}
```
---
## 주요 inputType 종류
| inputType | 설명 | 사용 예시 |
| ---------- | ---------------- | ------------------ |
| `text` | 일반 텍스트 입력 | 이름, 설명 등 |
| `number` | 숫자 입력 | 금액, 수량 등 |
| `date` | 날짜 입력 | 생성일, 수정일 등 |
| `datetime` | 날짜+시간 입력 | 타임스탬프 등 |
| `category` | 카테고리 선택 | 분류, 상태 등 |
| `code` | 공통 코드 선택 | 코드 마스터 데이터 |
| `boolean` | 예/아니오 | 활성화 여부 등 |
| `email` | 이메일 입력 | 이메일 주소 |
| `url` | URL 입력 | 웹사이트 주소 |
| `image` | 이미지 업로드 | 프로필 사진 등 |
| `file` | 파일 업로드 | 첨부파일 등 |
---
## 실제 적용 사례
### 1. TableListComponent - 카테고리 매핑 로드
```typescript
// ✅ inputType으로 카테고리 컬럼 필터링
const categoryColumns = Object.entries(columnMeta)
.filter(([_, meta]) => meta.inputType === "category")
.map(([columnName, _]) => columnName);
// 각 카테고리 컬럼의 값 목록 조회
for (const columnName of categoryColumns) {
const response = await apiClient.get(
`/table-categories/${tableName}/${columnName}/values`
);
// 매핑 처리...
}
```
### 2. InteractiveDataTable - 셀 값 렌더링
```typescript
// ✅ inputType으로 렌더링 분기
const inputType = columnMeta[column.columnName]?.inputType;
switch (inputType) {
case "category":
// 카테고리 배지 렌더링
return <Badge>{categoryLabel}</Badge>;
case "code":
// 코드명 표시
return codeName;
case "date":
// 날짜 포맷팅
return formatDate(value);
default:
return value;
}
```
### 3. 검색 필터 생성
```typescript
// ✅ inputType에 따라 다른 검색 UI 제공
const renderSearchInput = (column: ColumnConfig) => {
const inputType = columnMeta[column.columnName]?.inputType;
switch (inputType) {
case "category":
return <CategorySelect column={column} />;
case "code":
return <CodeSelect column={column} />;
case "date":
return <DateRangePicker column={column} />;
case "number":
return <NumberRangeInput column={column} />;
default:
return <TextInput column={column} />;
}
};
```
---
## 마이그레이션 체크리스트
기존 코드에서 `webType`을 `inputType`으로 전환할 때:
- [ ] `webType` 참조를 모두 `inputType`으로 변경
- [ ] API 호출 시 `getColumnInputTypes()` 포함 확인
- [ ] 캐시 사용 시 `cached.inputTypes` 매핑 확인
- [ ] 타입 정의에서 `inputType` 필드 포함
- [ ] 조건문에서 `inputType` 체크로 변경
- [ ] 테스트 실행하여 정상 동작 확인
---
## 디버깅 팁
### inputType이 undefined인 경우
```typescript
// 디버깅 로그 추가
console.log("columnMeta:", columnMeta);
console.log("inputType:", columnMeta[columnName]?.inputType);
// 체크 포인트:
// 1. getColumnInputTypes() 호출 확인
// 2. inputTypeMap 생성 확인
// 3. meta 객체에 inputType 할당 확인
// 4. 캐시 사용 시 cached.inputTypes 확인
```
### webType만 있고 inputType이 없는 경우
```typescript
// ❌ 잘못된 데이터 구조
{
material: {
webType: "category",
codeCategory: "",
// inputType 누락!
}
}
// ✅ 올바른 데이터 구조
{
material: {
webType: "category", // 레거시, 무시됨
codeCategory: "",
inputType: "category" // ✅ 필수!
}
}
```
---
## 참고 자료
- **컴포넌트**: `/frontend/lib/registry/components/table-list/TableListComponent.tsx`
- **API 클라이언트**: `/frontend/lib/api/tableType.ts`
- **타입 정의**: `/frontend/types/table.ts`
---
## 요약
1. **항상 `inputType` 사용**, `webType` 사용 금지
2. **API에서 `getColumnInputTypes()` 호출** 필수
3. **캐시 사용 시 `inputTypes` 포함** 확인
4. **디버깅 시 `inputType` 값 확인**
5. **기존 코드 마이그레이션** 시 체크리스트 활용

View File

@ -0,0 +1,40 @@
---
description: (Deprecated) 이 파일은 component-development-guide.mdc로 통합되었습니다.
alwaysApply: false
---
# 다국어 지원 컴포넌트 개발 가이드 (Deprecated)
> **이 문서는 더 이상 사용되지 않습니다.**
>
> 새로운 통합 가이드를 참조하세요: `component-development-guide.mdc`
다국어 지원을 포함한 모든 컴포넌트 개발 가이드가 다음 파일로 통합되었습니다:
**[component-development-guide.mdc](.cursor/rules/component-development-guide.mdc)**
통합된 가이드에는 다음 내용이 포함되어 있습니다:
1. **엔티티 조인 컬럼 활용 (필수)**
- 화면을 새로 만들어 임베딩하는 방식 대신 엔티티 관계 활용
- `entityJoinApi.getEntityJoinColumns()` 사용법
- 설정 패널에서 조인 컬럼 표시 패턴
2. **폼 데이터 관리**
- `useFormCompatibility` 훅 사용법
- 레거시 `beforeFormSave` 이벤트 호환성
3. **다국어 지원**
- 타입 정의 시 `langKeyId`, `langKey` 필드 추가
- 라벨 추출/매핑 로직
- 번역 표시 로직
4. **컬럼 설정 패널 구현**
- 필수 구조 및 패턴
5. **체크리스트**
- 새 컴포넌트 개발 시 확인 항목

View File

@ -20,7 +20,7 @@ CREATE TABLE "테이블명" (
-- 시스템 기본 컬럼 (자동 포함) -- 시스템 기본 컬럼 (자동 포함)
"id" varchar(500) PRIMARY KEY DEFAULT gen_random_uuid()::text, "id" varchar(500) PRIMARY KEY DEFAULT gen_random_uuid()::text,
"created_date" timestamp DEFAULT now(), "created_date" timestamp DEFAULT now(),
"updated_date" timestamp DEFAULT now(),b "updated_date" timestamp DEFAULT now(),
"writer" varchar(500) DEFAULT NULL, "writer" varchar(500) DEFAULT NULL,
"company_code" varchar(500), "company_code" varchar(500),

135
PLAN.MD
View File

@ -1,75 +1,104 @@
# 프로젝트: 화면 복제 기능 개선 (DB 구조 개편 후) # 프로젝트: 화면 관리 기능 개선 (복제/삭제/그룹 관리/테이블 설정)
## 개요 ## 개요
채번/카테고리에서 `menu_objid` 의존성 제거 완료 후, 화면 복제 기능을 새 DB 구조에 맞게 수정하고 테스트합니다. 화면 관리 시스템의 복제, 삭제, 수정, 테이블 설정 기능을 전면 개선하여 효율적인 화면 관리를 지원합니다.
## 핵심 변경사항 ## 핵심 기능
### DB 구조 변경 (완료) ### 1. 단일 화면 복제
- 채번규칙: `menu_objid` 의존성 제거 → `table_name + column_name + company_code` 기반 - [x] 우클릭 컨텍스트 메뉴에서 "복제" 선택
- 카테고리: `menu_objid` 의존성 제거 → `table_name + column_name + company_code` 기반 - [x] 화면명, 화면 코드 자동 생성 (중복 시 `_COPY` 접미사 추가)
- 복제 순서 의존성 문제 해결 - [x] 연결된 모달 화면 함께 복제
- [x] 대상 그룹 선택 가능
- [x] 복제 후 목록 자동 새로고침
### 복제 옵션 정리 (완료) ### 2. 그룹(폴더) 전체 복제
- [x] **삭제**: 코드 카테고리 + 코드 복사 옵션 - [x] 대분류 폴더 복제 시 모든 하위 폴더 + 화면 재귀적 복제
- [x] **삭제**: 연쇄관계 설정 복사 옵션 - [x] 정렬 순서(display_order) 유지
- [x] **이름 변경**: "카테고리 매핑 + 값 복사" → "카테고리 값 복사" - [x] 대분류(최상위 그룹) 복제 시 경고 문구 표시
- [x] 정렬 순서 입력 필드 추가
- [x] 복제 모드 선택: 전체(폴더+화면), 폴더만, 화면만
- [x] 모달 스크롤 지원 (max-h-[90vh] overflow-y-auto)
### 현재 복제 옵션 (3개) ### 3. 고급 옵션: 이름 일괄 변경
1. **채번 규칙 복사** - 채번규칙 복제 - [x] 찾을 텍스트 / 대체할 텍스트 (Find & Replace)
2. **카테고리 값 복사** - 카테고리 값 복제 (table_column_category_values) - [x] 미리보기 기능
3. **테이블 타입관리 입력타입 설정 복사** - table_type_columns 복제
--- ### 4. 삭제 기능
- [x] 단일 화면 삭제 (휴지통으로 이동)
- [x] 그룹 삭제 (화면 함께 삭제 옵션)
- [x] 삭제 시 로딩 프로그레스 바 표시
## 테스트 계획 ### 5. 화면 수정 기능
- [x] 우클릭 "수정" 메뉴로 화면 이름/그룹/역할/정렬 순서 변경
- [x] 그룹 추가/수정 시 상위 그룹 기반 자동 회사 코드 설정
### 1. 화면 간 연결 복제 테스트 ### 6. 테이블 설정 기능 (TableSettingModal)
- [ ] 수주관리 1번→2번→3번→4번 화면 연결 상태에서 복제 - [x] 화면 설정 모달에 "테이블 설정" 탭 추가
- [ ] 복제 후 연결 관계가 유지되는지 확인 - [x] 입력 타입 변경 시 관련 참조 필드 자동 초기화
- [ ] 각 화면의 고유 키값이 새로운 화면을 참조하도록 변경되는지 확인 - 엔티티→텍스트: referenceTable, referenceColumn, displayColumn 초기화
- 코드→다른 타입: codeCategory, codeValue 초기화
- [x] 데이터 일관성 유지 (inputType ↔ referenceTable 연동)
- [x] 조인 배지 단일화 (FK 배지 제거, 조인 배지만 표시)
### 2. 제어관리 복제 테스트 ### 7. 회사 코드 지원 (최고 관리자)
- [ ] 다른 회사로 제어관리 복제 - [x] 대상 회사 선택 가능
- [ ] 복제된 플로우 스텝/연결이 정상 작동하는지 확인 - [x] 상위 그룹 선택 시 자동 회사 코드 설정
### 3. 추가 옵션 복제 테스트
- [ ] 채번규칙 복사 정상 작동 확인
- [ ] 카테고리 값 복사 정상 작동 확인
- [ ] 테이블 타입관리 입력타입 설정 복사 정상 작동 확인
### 4. 기본 복제 테스트
- [ ] 단일 화면 복제 (모달 포함)
- [ ] 그룹 전체 복제 (재귀적)
- [ ] 메뉴 동기화 정상 작동
---
## 관련 파일 ## 관련 파일
- `frontend/components/screen/CopyScreenModal.tsx` - 복제 모달 - `frontend/components/screen/CopyScreenModal.tsx` - 복제 모달
- `frontend/components/screen/ScreenGroupTreeView.tsx` - 트리 뷰 + 컨텍스트 메뉴 - `frontend/components/screen/ScreenGroupTreeView.tsx` - 트리 뷰 + 컨텍스트 메뉴
- `backend-node/src/services/screenManagementService.ts` - 복제 서비스 - `frontend/components/screen/TableSettingModal.tsx` - 테이블 설정 모달
- `backend-node/src/services/numberingRuleService.ts` - 채번규칙 서비스 - `frontend/components/screen/ScreenSettingModal.tsx` - 화면 설정 모달 (테이블 설정 탭 포함)
- `docs/DB_STRUCTURE_DIAGRAM.md` - DB 구조 문서 - `frontend/lib/api/screen.ts` - 화면 API
- `frontend/lib/api/screenGroup.ts` - 그룹 API
- `frontend/lib/api/tableManagement.ts` - 테이블 관리 API
## 진행 상태 ## 진행 상태
- [완료] DB 구조 개편 (menu_objid 의존성 제거) - [완료] 단일 화면 복제 + 새로고침
- [완료] 복제 옵션 정리 (코드카테고리/연쇄관계 삭제, 이름 변경) - [완료] 그룹 전체 복제 (재귀적)
- [완료] 화면 간 연결 복제 버그 수정 (targetScreenId 매핑 추가) - [완료] 고급 옵션: 이름 일괄 변경 (Find & Replace)
- [대기] 화면 간 연결 복제 테스트 - [완료] 단일 화면/그룹 삭제 + 로딩 프로그레스
- [대기] 제어관리 복제 테스트 - [완료] 화면 수정 (이름/그룹/역할/순서)
- [대기] 추가 옵션 복제 테스트 - [완료] 테이블 설정 탭 추가
- [완료] 입력 타입 변경 시 관련 필드 초기화
- [완료] 그룹 복제 모달 스크롤 문제 수정
--- ---
## 수정 이력 # 이전 프로젝트: 외부 REST API 커넥션 관리 확장 (POST/Body 지원)
### 2026-01-26: 버튼 targetScreenId 매핑 버그 수정 ## 개요
현재 GET 방식 위주로 구현된 외부 REST API 커넥션 관리 기능을 확장하여, POST, PUT, DELETE 등 다양한 HTTP 메서드와 JSON Request Body를 설정하고 테스트할 수 있도록 개선합니다. 이를 통해 토큰 발급 API나 데이터 전송 API 등 다양한 외부 시스템과의 연동을 지원합니다.
**문제**: 그룹 복제 시 버튼의 `targetScreenId`가 새 화면으로 매핑되지 않음 ## 핵심 기능
- 수주관리 1→2→3→4 화면 복제 시 연결이 깨지는 문제 1. **DB 스키마 확장**: `external_rest_api_connections` 테이블에 `default_method`, `default_body` 컬럼 추가
2. **백엔드 로직 개선**:
- 커넥션 생성/수정 시 메서드와 바디 정보 저장
- 연결 테스트 시 설정된 메서드와 바디를 사용하여 요청 수행
- SSL 인증서 검증 우회 옵션 적용 (내부망/테스트망 지원)
3. **프론트엔드 UI 개선**:
- 커넥션 설정 모달에 HTTP 메서드 선택(Select) 및 Body 입력(Textarea/JSON Editor) 필드 추가
- 테스트 기능에서 Body 데이터 포함하여 요청 전송
**수정 파일**: `backend-node/src/services/screenManagementService.ts` ## 테스트 계획
- `updateTabScreenReferences` 함수에 `targetScreenId` 처리 로직 추가 ### 1단계: 기본 기능 및 DB 마이그레이션
- 쿼리에 `targetScreenId` 검색 조건 추가 - [x] DB 마이그레이션 스크립트 작성 및 실행
- 문자열/숫자 타입 모두 처리 - [x] 백엔드 타입 정의 수정 (`default_method`, `default_body` 추가)
### 2단계: 백엔드 로직 구현
- [x] 커넥션 생성/수정 API 수정 (필드 추가)
- [x] 커넥션 상세 조회 API 확인
- [x] 연결 테스트 API 수정 (Method, Body 반영하여 요청 전송)
### 3단계: 프론트엔드 구현
- [x] 커넥션 관리 리스트/모달 UI 수정
- [x] 연결 테스트 UI 수정 및 기능 확인
## 에러 처리 계획
- **JSON 파싱 에러**: Body 입력값이 유효한 JSON이 아닐 경우 에러 처리
- **API 호출 에러**: 외부 API 호출 실패 시 상세 로그 기록 및 클라이언트에 에러 메시지 전달
- **SSL 인증 에러**: `rejectUnauthorized: false` 옵션으로 처리 (기존 `RestApiConnector` 활용)
## 진행 상태
- [완료] 모든 단계 구현 완료

View File

@ -3404,7 +3404,7 @@ export const resetUserPassword = async (
/** /**
* ( ) * ( )
* table_type_columns * column_labels
*/ */
export async function getTableSchema( export async function getTableSchema(
req: AuthenticatedRequest, req: AuthenticatedRequest,
@ -3424,7 +3424,7 @@ export async function getTableSchema(
logger.info("테이블 스키마 조회", { tableName, companyCode }); logger.info("테이블 스키마 조회", { tableName, companyCode });
// information_schema와 table_type_columns를 JOIN하여 컬럼 정보와 라벨 정보 함께 가져오기 // information_schema와 column_labels를 JOIN하여 컬럼 정보와 라벨 정보 함께 가져오기
const schemaQuery = ` const schemaQuery = `
SELECT SELECT
ic.column_name, ic.column_name,
@ -3434,16 +3434,15 @@ export async function getTableSchema(
ic.character_maximum_length, ic.character_maximum_length,
ic.numeric_precision, ic.numeric_precision,
ic.numeric_scale, ic.numeric_scale,
ttc.column_label, cl.column_label,
ttc.display_order cl.display_order
FROM information_schema.columns ic FROM information_schema.columns ic
LEFT JOIN table_type_columns ttc LEFT JOIN column_labels cl
ON ttc.table_name = ic.table_name ON cl.table_name = ic.table_name
AND ttc.column_name = ic.column_name AND cl.column_name = ic.column_name
AND ttc.company_code = '*'
WHERE ic.table_schema = 'public' WHERE ic.table_schema = 'public'
AND ic.table_name = $1 AND ic.table_name = $1
ORDER BY COALESCE(ttc.display_order, ic.ordinal_position), ic.ordinal_position ORDER BY COALESCE(cl.display_order, ic.ordinal_position), ic.ordinal_position
`; `;
const columns = await query<any>(schemaQuery, [tableName]); const columns = await query<any>(schemaQuery, [tableName]);

View File

@ -130,20 +130,9 @@ router.get("/test/value/:valueId", async (req: AuthenticatedRequest, res: Respon
router.post("/test/value", async (req: AuthenticatedRequest, res: Response) => { router.post("/test/value", async (req: AuthenticatedRequest, res: Response) => {
try { try {
const input: CreateCategoryValueInput = req.body; const input: CreateCategoryValueInput = req.body;
const userCompanyCode = req.user?.companyCode || "*"; const companyCode = req.user?.companyCode || "*";
const createdBy = req.user?.userId; const createdBy = req.user?.userId;
// 🔧 최고 관리자가 특정 회사를 선택한 경우, targetCompanyCode 우선 사용
// 단, 최고 관리자(companyCode = '*')만 다른 회사 코드 사용 가능
let companyCode = userCompanyCode;
if (input.targetCompanyCode && userCompanyCode === "*") {
companyCode = input.targetCompanyCode;
logger.info("🔓 최고 관리자 회사 코드 오버라이드 (카테고리 값 생성)", {
originalCompanyCode: userCompanyCode,
targetCompanyCode: input.targetCompanyCode,
});
}
if (!input.tableName || !input.columnName || !input.valueCode || !input.valueLabel) { if (!input.tableName || !input.columnName || !input.valueCode || !input.valueLabel) {
return res.status(400).json({ return res.status(400).json({
success: false, success: false,

View File

@ -36,10 +36,10 @@ export class EntityReferenceController {
search, search,
}); });
// 컬럼 정보 조회 (table_type_columns에서) // 컬럼 정보 조회
const columnInfo = await queryOne<any>( const columnInfo = await queryOne<any>(
`SELECT * FROM table_type_columns `SELECT * FROM column_labels
WHERE table_name = $1 AND column_name = $2 AND company_code = '*' WHERE table_name = $1 AND column_name = $2
LIMIT 1`, LIMIT 1`,
[tableName, columnName] [tableName, columnName]
); );
@ -51,15 +51,15 @@ export class EntityReferenceController {
}); });
} }
// inputType 확인 // webType 확인
if (columnInfo.input_type !== "entity") { if (columnInfo.web_type !== "entity") {
return res.status(400).json({ return res.status(400).json({
success: false, success: false,
message: `컬럼 '${tableName}.${columnName}'은 entity 타입이 아닙니다. inputType: ${columnInfo.input_type}`, message: `컬럼 '${tableName}.${columnName}'은 entity 타입이 아닙니다. webType: ${columnInfo.web_type}`,
}); });
} }
// table_type_columns에서 직접 참조 정보 가져오기 // column_labels에서 직접 참조 정보 가져오기
const referenceTable = columnInfo.reference_table; const referenceTable = columnInfo.reference_table;
const referenceColumn = columnInfo.reference_column; const referenceColumn = columnInfo.reference_column;
const displayColumn = columnInfo.display_column || "name"; const displayColumn = columnInfo.display_column || "name";
@ -68,7 +68,7 @@ export class EntityReferenceController {
if (!referenceTable || !referenceColumn) { if (!referenceTable || !referenceColumn) {
return res.status(400).json({ return res.status(400).json({
success: false, success: false,
message: `Entity 타입 컬럼 '${tableName}.${columnName}'에 참조 테이블 정보가 설정되지 않았습니다. table_type_columns에서 reference_table과 reference_column을 확인해주세요.`, message: `Entity 타입 컬럼 '${tableName}.${columnName}'에 참조 테이블 정보가 설정되지 않았습니다. column_labels에서 reference_table과 reference_column을 확인해주세요.`,
}); });
} }
@ -85,7 +85,7 @@ export class EntityReferenceController {
); );
return res.status(400).json({ return res.status(400).json({
success: false, success: false,
message: `참조 테이블 '${referenceTable}'이 존재하지 않습니다. table_type_columns의 reference_table 설정을 확인해주세요.`, message: `참조 테이블 '${referenceTable}'이 존재하지 않습니다. column_labels의 reference_table 설정을 확인해주세요.`,
}); });
} }

View File

@ -3,107 +3,6 @@ import { AuthenticatedRequest } from "../types/auth";
import { getPool } from "../database/db"; import { getPool } from "../database/db";
import { logger } from "../utils/logger"; import { logger } from "../utils/logger";
/**
* DISTINCT API (inputType: select )
* GET /api/entity/:tableName/distinct/:columnName
*
* DISTINCT
*/
export async function getDistinctColumnValues(req: AuthenticatedRequest, res: Response) {
try {
const { tableName, columnName } = req.params;
const { labelColumn } = req.query; // 선택적: 별도의 라벨 컬럼
// 유효성 검증
if (!tableName || tableName === "undefined" || tableName === "null") {
return res.status(400).json({
success: false,
message: "테이블명이 지정되지 않았습니다.",
});
}
if (!columnName || columnName === "undefined" || columnName === "null") {
return res.status(400).json({
success: false,
message: "컬럼명이 지정되지 않았습니다.",
});
}
const companyCode = req.user!.companyCode;
const pool = getPool();
// 테이블의 실제 컬럼 목록 조회
const columnsResult = await pool.query(
`SELECT column_name FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = $1`,
[tableName]
);
const existingColumns = new Set(columnsResult.rows.map((r: any) => r.column_name));
// 요청된 컬럼 검증
if (!existingColumns.has(columnName)) {
return res.status(400).json({
success: false,
message: `테이블 "${tableName}"에 컬럼 "${columnName}"이 존재하지 않습니다.`,
});
}
// 라벨 컬럼 결정 (지정되지 않으면 값 컬럼과 동일)
const effectiveLabelColumn = labelColumn && existingColumns.has(labelColumn as string)
? labelColumn as string
: columnName;
// WHERE 조건 (멀티테넌시)
const whereConditions: string[] = [];
const params: any[] = [];
let paramIndex = 1;
if (companyCode !== "*" && existingColumns.has("company_code")) {
whereConditions.push(`company_code = $${paramIndex}`);
params.push(companyCode);
paramIndex++;
}
// NULL 제외
whereConditions.push(`"${columnName}" IS NOT NULL`);
whereConditions.push(`"${columnName}" != ''`);
const whereClause = whereConditions.length > 0
? `WHERE ${whereConditions.join(" AND ")}`
: "";
// DISTINCT 쿼리 실행
const query = `
SELECT DISTINCT "${columnName}" as value, "${effectiveLabelColumn}" as label
FROM "${tableName}"
${whereClause}
ORDER BY "${effectiveLabelColumn}" ASC
LIMIT 500
`;
const result = await pool.query(query, params);
logger.info("컬럼 DISTINCT 값 조회 성공", {
tableName,
columnName,
labelColumn: effectiveLabelColumn,
companyCode,
rowCount: result.rowCount,
});
res.json({
success: true,
data: result.rows,
});
} catch (error: any) {
logger.error("컬럼 DISTINCT 값 조회 오류", {
error: error.message,
stack: error.stack,
});
res.status(500).json({ success: false, message: error.message });
}
}
/** /**
* API (UnifiedSelect용) * API (UnifiedSelect용)
* GET /api/entity/:tableName/options * GET /api/entity/:tableName/options

View File

@ -627,19 +627,19 @@ export class FlowController {
return; return;
} }
// table_type_columns 테이블에서 라벨 정보 조회 // column_labels 테이블에서 라벨 정보 조회
const { query } = await import("../database/db"); const { query } = await import("../database/db");
const labelRows = await query<{ const labelRows = await query<{
column_name: string; column_name: string;
column_label: string | null; column_label: string | null;
}>( }>(
`SELECT column_name, column_label `SELECT column_name, column_label
FROM table_type_columns FROM column_labels
WHERE table_name = $1 AND column_label IS NOT NULL AND company_code = '*'`, WHERE table_name = $1 AND column_label IS NOT NULL`,
[tableName] [tableName]
); );
console.log(`✅ [FlowController] table_type_columns 조회 완료:`, { console.log(`✅ [FlowController] column_labels 조회 완료:`, {
tableName, tableName,
rowCount: labelRows.length, rowCount: labelRows.length,
labels: labelRows.map((r) => ({ labels: labelRows.map((r) => ({

View File

@ -1310,8 +1310,8 @@ export const getScreenSubTables = async (req: AuthenticatedRequest, res: Respons
if (conditions.length > 0) { if (conditions.length > 0) {
const labelQuery = ` const labelQuery = `
SELECT table_name, column_name, column_label SELECT table_name, column_name, column_label
FROM table_type_columns FROM column_labels
WHERE (${conditions.join(' OR ')}) AND company_code = '*' WHERE ${conditions.join(' OR ')}
`; `;
const labelResult = await pool.query(labelQuery, params); const labelResult = await pool.query(labelQuery, params);
labelResult.rows.forEach((row: any) => { labelResult.rows.forEach((row: any) => {
@ -1407,7 +1407,7 @@ export const getScreenSubTables = async (req: AuthenticatedRequest, res: Respons
} }
}); });
// 2. 추가 방식: 화면에서 사용하는 컬럼 중 table_type_columns.reference_table이 설정된 경우 // 2. 추가 방식: 화면에서 사용하는 컬럼 중 column_labels.reference_table이 설정된 경우
// 화면의 usedColumns/joinColumns에서 reference_table 조회 // 화면의 usedColumns/joinColumns에서 reference_table 조회
const referenceQuery = ` const referenceQuery = `
WITH screen_used_columns AS ( WITH screen_used_columns AS (
@ -1513,8 +1513,8 @@ export const getScreenSubTables = async (req: AuthenticatedRequest, res: Respons
cl.reference_column, cl.reference_column,
ref_cl.column_label as target_display_name ref_cl.column_label as target_display_name
FROM screen_used_columns suc FROM screen_used_columns suc
JOIN table_type_columns cl ON cl.table_name = suc.main_table AND cl.column_name = suc.column_name AND cl.company_code = '*' JOIN column_labels cl ON cl.table_name = suc.main_table AND cl.column_name = suc.column_name
LEFT JOIN table_type_columns ref_cl ON ref_cl.table_name = cl.reference_table AND ref_cl.column_name = cl.reference_column AND ref_cl.company_code = '*' LEFT JOIN column_labels ref_cl ON ref_cl.table_name = cl.reference_table AND ref_cl.column_name = cl.reference_column
WHERE cl.reference_table IS NOT NULL WHERE cl.reference_table IS NOT NULL
AND cl.reference_table != '' AND cl.reference_table != ''
AND cl.reference_table != suc.main_table AND cl.reference_table != suc.main_table
@ -1524,7 +1524,7 @@ export const getScreenSubTables = async (req: AuthenticatedRequest, res: Respons
const referenceResult = await pool.query(referenceQuery, [screenIds]); const referenceResult = await pool.query(referenceQuery, [screenIds]);
logger.info("table_type_columns reference_table 조회 결과", { logger.info("column_labels reference_table 조회 결과", {
screenIds, screenIds,
referenceCount: referenceResult.rows.length, referenceCount: referenceResult.rows.length,
references: referenceResult.rows.map((r: any) => ({ references: referenceResult.rows.map((r: any) => ({
@ -1804,7 +1804,7 @@ export const getScreenSubTables = async (req: AuthenticatedRequest, res: Respons
rightPanelCount: rightPanelResult.rows.length rightPanelCount: rightPanelResult.rows.length
}); });
// 5. joinedTables에 대한 FK 컬럼을 table_type_columns에서 조회 // 5. joinedTables에 대한 FK 컬럼을 column_labels에서 조회
// rightPanelRelation에서 joinedTables가 있는 경우, 해당 테이블과 조인하는 FK 컬럼 찾기 // rightPanelRelation에서 joinedTables가 있는 경우, 해당 테이블과 조인하는 FK 컬럼 찾기
const joinedTableFKLookups: Array<{ subTableName: string; refTable: string }> = []; const joinedTableFKLookups: Array<{ subTableName: string; refTable: string }> = [];
Object.values(screenSubTables).forEach((screenData: any) => { Object.values(screenSubTables).forEach((screenData: any) => {
@ -1817,7 +1817,7 @@ export const getScreenSubTables = async (req: AuthenticatedRequest, res: Respons
}); });
}); });
// table_type_columns에서 FK 컬럼 조회 (reference_table로 조인하는 컬럼 찾기) // column_labels에서 FK 컬럼 조회 (reference_table로 조인하는 컬럼 찾기)
const joinColumnsByTable: { [key: string]: string[] } = {}; // tableName → [FK 컬럼들] const joinColumnsByTable: { [key: string]: string[] } = {}; // tableName → [FK 컬럼들]
if (joinedTableFKLookups.length > 0) { if (joinedTableFKLookups.length > 0) {
const uniqueLookups = joinedTableFKLookups.filter((item, index, self) => const uniqueLookups = joinedTableFKLookups.filter((item, index, self) =>
@ -1836,11 +1836,10 @@ export const getScreenSubTables = async (req: AuthenticatedRequest, res: Respons
cl.reference_table, cl.reference_table,
cl.reference_column, cl.reference_column,
tl.table_label as reference_table_label tl.table_label as reference_table_label
FROM table_type_columns cl FROM column_labels cl
LEFT JOIN table_labels tl ON cl.reference_table = tl.table_name LEFT JOIN table_labels tl ON cl.reference_table = tl.table_name
WHERE cl.table_name = ANY($1) WHERE cl.table_name = ANY($1)
AND cl.reference_table = ANY($2) AND cl.reference_table = ANY($2)
AND cl.company_code = '*'
`; `;
const fkResult = await pool.query(fkQuery, [subTableNames, refTableNames]); const fkResult = await pool.query(fkQuery, [subTableNames, refTableNames]);
@ -1885,7 +1884,7 @@ export const getScreenSubTables = async (req: AuthenticatedRequest, res: Respons
}); });
} }
// 5. 모든 fieldMappings의 한글명을 table_type_columns에서 가져와서 적용 // 5. 모든 fieldMappings의 한글명을 column_labels에서 가져와서 적용
// 모든 테이블/컬럼 조합을 수집 // 모든 테이블/컬럼 조합을 수집
const columnLookups: Array<{ tableName: string; columnName: string }> = []; const columnLookups: Array<{ tableName: string; columnName: string }> = [];
Object.values(screenSubTables).forEach((screenData: any) => { Object.values(screenSubTables).forEach((screenData: any) => {
@ -1910,7 +1909,7 @@ export const getScreenSubTables = async (req: AuthenticatedRequest, res: Respons
index === self.findIndex((t) => t.tableName === item.tableName && t.columnName === item.columnName) index === self.findIndex((t) => t.tableName === item.tableName && t.columnName === item.columnName)
); );
// table_type_columns에서 한글명 조회 // column_labels에서 한글명 조회
const columnLabelsMap: { [key: string]: string } = {}; const columnLabelsMap: { [key: string]: string } = {};
if (uniqueColumnLookups.length > 0) { if (uniqueColumnLookups.length > 0) {
const columnLabelsQuery = ` const columnLabelsQuery = `
@ -1918,11 +1917,10 @@ export const getScreenSubTables = async (req: AuthenticatedRequest, res: Respons
table_name, table_name,
column_name, column_name,
column_label column_label
FROM table_type_columns FROM column_labels
WHERE (table_name, column_name) IN ( WHERE (table_name, column_name) IN (
${uniqueColumnLookups.map((_, i) => `($${i * 2 + 1}, $${i * 2 + 2})`).join(', ')} ${uniqueColumnLookups.map((_, i) => `($${i * 2 + 1}, $${i * 2 + 2})`).join(', ')}
) )
AND company_code = '*'
`; `;
const columnLabelsParams = uniqueColumnLookups.flatMap(item => [item.tableName, item.columnName]); const columnLabelsParams = uniqueColumnLookups.flatMap(item => [item.tableName, item.columnName]);
@ -1932,9 +1930,9 @@ export const getScreenSubTables = async (req: AuthenticatedRequest, res: Respons
const key = `${row.table_name}.${row.column_name}`; const key = `${row.table_name}.${row.column_name}`;
columnLabelsMap[key] = row.column_label; columnLabelsMap[key] = row.column_label;
}); });
logger.info("table_type_columns 한글명 조회 완료", { count: columnLabelsResult.rows.length }); logger.info("column_labels 한글명 조회 완료", { count: columnLabelsResult.rows.length });
} catch (error: any) { } catch (error: any) {
logger.warn("table_type_columns 한글명 조회 실패 (무시하고 계속 진행):", error.message); logger.warn("column_labels 한글명 조회 실패 (무시하고 계속 진행):", error.message);
} }
} }
@ -2423,4 +2421,3 @@ export const getMenuTreeFromScreenGroups = async (req: AuthenticatedRequest, res
}); });
} }
}; };

View File

@ -674,63 +674,6 @@ export const getLayout = async (req: AuthenticatedRequest, res: Response) => {
} }
}; };
// V1 레이아웃 조회 (component_url + custom_config 기반)
export const getLayoutV1 = async (req: AuthenticatedRequest, res: Response) => {
try {
const { screenId } = req.params;
const { companyCode } = req.user as any;
const layout = await screenManagementService.getLayoutV1(
parseInt(screenId),
companyCode
);
res.json({ success: true, data: layout });
} catch (error) {
console.error("V3 레이아웃 조회 실패:", error);
res
.status(500)
.json({ success: false, message: "V3 레이아웃 조회에 실패했습니다." });
}
};
// V2 레이아웃 조회 (1 레코드 방식 - url + overrides)
export const getLayoutV2 = async (req: AuthenticatedRequest, res: Response) => {
try {
const { screenId } = req.params;
const { companyCode } = req.user as any;
const layout = await screenManagementService.getLayoutV2(
parseInt(screenId),
companyCode
);
res.json({ success: true, data: layout });
} catch (error) {
console.error("V2 레이아웃 조회 실패:", error);
res
.status(500)
.json({ success: false, message: "V2 레이아웃 조회에 실패했습니다." });
}
};
// V2 레이아웃 저장 (1 레코드 방식 - url + overrides)
export const saveLayoutV2 = async (req: AuthenticatedRequest, res: Response) => {
try {
const { screenId } = req.params;
const { companyCode } = req.user as any;
const layoutData = req.body;
await screenManagementService.saveLayoutV2(
parseInt(screenId),
layoutData,
companyCode
);
res.json({ success: true, message: "V2 레이아웃이 저장되었습니다." });
} catch (error) {
console.error("V2 레이아웃 저장 실패:", error);
res
.status(500)
.json({ success: false, message: "V2 레이아웃 저장에 실패했습니다." });
}
};
// 화면 코드 자동 생성 // 화면 코드 자동 생성
export const generateScreenCode = async ( export const generateScreenCode = async (
req: AuthenticatedRequest, req: AuthenticatedRequest,

View File

@ -1682,11 +1682,14 @@ export async function getCategoryColumnsByCompany(
) AS "tableLabel", ) AS "tableLabel",
ttc.column_name AS "columnName", ttc.column_name AS "columnName",
COALESCE( COALESCE(
ttc.column_label, cl.column_label,
initcap(replace(ttc.column_name, '_', ' ')) initcap(replace(ttc.column_name, '_', ' '))
) AS "columnLabel", ) AS "columnLabel",
ttc.input_type AS "inputType" ttc.input_type AS "inputType"
FROM table_type_columns ttc FROM table_type_columns ttc
LEFT JOIN column_labels cl
ON ttc.table_name = cl.table_name
AND ttc.column_name = cl.column_name
LEFT JOIN table_labels tl LEFT JOIN table_labels tl
ON ttc.table_name = tl.table_name ON ttc.table_name = tl.table_name
WHERE ttc.input_type = 'category' WHERE ttc.input_type = 'category'
@ -1709,11 +1712,14 @@ export async function getCategoryColumnsByCompany(
) AS "tableLabel", ) AS "tableLabel",
ttc.column_name AS "columnName", ttc.column_name AS "columnName",
COALESCE( COALESCE(
ttc.column_label, cl.column_label,
initcap(replace(ttc.column_name, '_', ' ')) initcap(replace(ttc.column_name, '_', ' '))
) AS "columnLabel", ) AS "columnLabel",
ttc.input_type AS "inputType" ttc.input_type AS "inputType"
FROM table_type_columns ttc FROM table_type_columns ttc
LEFT JOIN column_labels cl
ON ttc.table_name = cl.table_name
AND ttc.column_name = cl.column_name
LEFT JOIN table_labels tl LEFT JOIN table_labels tl
ON ttc.table_name = tl.table_name ON ttc.table_name = tl.table_name
WHERE ttc.input_type = 'category' WHERE ttc.input_type = 'category'
@ -1800,11 +1806,14 @@ export async function getCategoryColumnsByMenu(
) AS "tableLabel", ) AS "tableLabel",
ttc.column_name AS "columnName", ttc.column_name AS "columnName",
COALESCE( COALESCE(
ttc.column_label, cl.column_label,
initcap(replace(ttc.column_name, '_', ' ')) initcap(replace(ttc.column_name, '_', ' '))
) AS "columnLabel", ) AS "columnLabel",
ttc.input_type AS "inputType" ttc.input_type AS "inputType"
FROM table_type_columns ttc FROM table_type_columns ttc
LEFT JOIN column_labels cl
ON ttc.table_name = cl.table_name
AND ttc.column_name = cl.column_name
LEFT JOIN table_labels tl LEFT JOIN table_labels tl
ON ttc.table_name = tl.table_name ON ttc.table_name = tl.table_name
WHERE ttc.input_type = 'category' WHERE ttc.input_type = 'category'
@ -1827,11 +1836,14 @@ export async function getCategoryColumnsByMenu(
) AS "tableLabel", ) AS "tableLabel",
ttc.column_name AS "columnName", ttc.column_name AS "columnName",
COALESCE( COALESCE(
ttc.column_label, cl.column_label,
initcap(replace(ttc.column_name, '_', ' ')) initcap(replace(ttc.column_name, '_', ' '))
) AS "columnLabel", ) AS "columnLabel",
ttc.input_type AS "inputType" ttc.input_type AS "inputType"
FROM table_type_columns ttc FROM table_type_columns ttc
LEFT JOIN column_labels cl
ON ttc.table_name = cl.table_name
AND ttc.column_name = cl.column_name
LEFT JOIN table_labels tl LEFT JOIN table_labels tl
ON ttc.table_name = tl.table_name ON ttc.table_name = tl.table_name
WHERE ttc.input_type = 'category' WHERE ttc.input_type = 'category'
@ -2216,7 +2228,7 @@ export async function multiTableSave(
/** /**
* *
* table_type_columns의 entity/category * column_labels의 entity/category
*/ */
export async function getTableEntityRelations( export async function getTableEntityRelations(
req: AuthenticatedRequest, req: AuthenticatedRequest,
@ -2241,12 +2253,11 @@ export async function getTableEntityRelations(
table_name, table_name,
column_name, column_name,
column_label, column_label,
input_type as web_type, web_type,
detail_settings detail_settings
FROM table_type_columns FROM column_labels
WHERE table_name IN ($1, $2) WHERE table_name IN ($1, $2)
AND input_type IN ('entity', 'category') AND web_type IN ('entity', 'category')
AND company_code = '*'
`; `;
const result = await query(columnLabelsQuery, [leftTable, rightTable]); const result = await query(columnLabelsQuery, [leftTable, rightTable]);
@ -2321,7 +2332,7 @@ export async function getTableEntityRelations(
* (FK로 ) * (FK로 )
* GET /api/table-management/columns/:tableName/referenced-by * GET /api/table-management/columns/:tableName/referenced-by
* *
* table_type_columns에서 reference_table이 * column_labels에서 reference_table이
* FK . * FK .
*/ */
export async function getReferencedByTables( export async function getReferencedByTables(
@ -2348,22 +2359,21 @@ export async function getReferencedByTables(
return; return;
} }
// table_type_columns에서 reference_table이 현재 테이블인 레코드 조회 // column_labels에서 reference_table이 현재 테이블인 레코드 조회
// input_type이 'entity'인 것만 조회 (실제 FK 관계) // input_type이 'entity'인 것만 조회 (실제 FK 관계)
const sqlQuery = ` const sqlQuery = `
SELECT DISTINCT SELECT DISTINCT
ttc.table_name, cl.table_name,
ttc.column_name, cl.column_name,
ttc.column_label, cl.column_label,
ttc.reference_table, cl.reference_table,
ttc.reference_column, cl.reference_column,
ttc.display_column, cl.display_column,
ttc.table_name as table_label cl.table_name as table_label
FROM table_type_columns ttc FROM column_labels cl
WHERE ttc.reference_table = $1 WHERE cl.reference_table = $1
AND ttc.input_type = 'entity' AND cl.input_type = 'entity'
AND ttc.company_code = '*' ORDER BY cl.table_name, cl.column_name
ORDER BY ttc.table_name, ttc.column_name
`; `;
const result = await query(sqlQuery, [tableName]); const result = await query(sqlQuery, [tableName]);

View File

@ -1,6 +1,6 @@
import { Router } from "express"; import { Router } from "express";
import { authenticateToken } from "../middleware/authMiddleware"; import { authenticateToken } from "../middleware/authMiddleware";
import { searchEntity, getEntityOptions, getDistinctColumnValues } from "../controllers/entitySearchController"; import { searchEntity, getEntityOptions } from "../controllers/entitySearchController";
const router = Router(); const router = Router();
@ -21,9 +21,3 @@ export const entityOptionsRouter = Router();
*/ */
entityOptionsRouter.get("/:tableName/options", authenticateToken, getEntityOptions); entityOptionsRouter.get("/:tableName/options", authenticateToken, getEntityOptions);
/**
* DISTINCT API (inputType: select )
* GET /api/entity/:tableName/distinct/:columnName
*/
entityOptionsRouter.get("/:tableName/distinct/:columnName", authenticateToken, getDistinctColumnValues);

View File

@ -23,9 +23,6 @@ import {
getTableColumns, getTableColumns,
saveLayout, saveLayout,
getLayout, getLayout,
getLayoutV1,
getLayoutV2,
saveLayoutV2,
generateScreenCode, generateScreenCode,
generateMultipleScreenCodes, generateMultipleScreenCodes,
assignScreenToMenu, assignScreenToMenu,
@ -80,9 +77,6 @@ router.get("/tables/:tableName/columns", getTableColumns);
// 레이아웃 관리 // 레이아웃 관리
router.post("/screens/:screenId/layout", saveLayout); router.post("/screens/:screenId/layout", saveLayout);
router.get("/screens/:screenId/layout", getLayout); router.get("/screens/:screenId/layout", getLayout);
router.get("/screens/:screenId/layout-v1", getLayoutV1); // V1: component_url + custom_config 기반 (다중 레코드)
router.get("/screens/:screenId/layout-v2", getLayoutV2); // V2: 1 레코드 방식 (url + overrides)
router.post("/screens/:screenId/layout-v2", saveLayoutV2); // V2: 1 레코드 방식 저장
// 메뉴-화면 할당 관리 // 메뉴-화면 할당 관리
router.post("/screens/:screenId/assign-menu", assignScreenToMenu); router.post("/screens/:screenId/assign-menu", assignScreenToMenu);

View File

@ -45,7 +45,7 @@ router.get("/tables", getTableList);
* *
* GET /api/table-management/tables/entity-relations?leftTable=xxx&rightTable=yyy * GET /api/table-management/tables/entity-relations?leftTable=xxx&rightTable=yyy
* *
* table_type_columns에서 / * column_labels에서 /
* . * .
*/ */
router.get("/tables/entity-relations", getTableEntityRelations); router.get("/tables/entity-relations", getTableEntityRelations);

View File

@ -403,33 +403,6 @@ class CategoryTreeService {
} }
} }
/**
* ID
*/
private async collectAllChildValueIds(
companyCode: string,
valueId: number
): Promise<number[]> {
const pool = getPool();
// 재귀 CTE를 사용하여 모든 하위 카테고리 수집
const query = `
WITH RECURSIVE category_tree AS (
SELECT value_id FROM category_values_test
WHERE parent_value_id = $1 AND (company_code = $2 OR company_code = '*')
UNION ALL
SELECT cv.value_id
FROM category_values_test cv
INNER JOIN category_tree ct ON cv.parent_value_id = ct.value_id
WHERE cv.company_code = $2 OR cv.company_code = '*'
)
SELECT value_id FROM category_tree
`;
const result = await pool.query(query, [valueId, companyCode]);
return result.rows.map(row => row.value_id);
}
/** /**
* ( ) * ( )
*/ */
@ -437,33 +410,20 @@ class CategoryTreeService {
const pool = getPool(); const pool = getPool();
try { try {
// 1. 모든 하위 카테고리 ID 수집 const query = `
const childValueIds = await this.collectAllChildValueIds(companyCode, valueId); DELETE FROM category_values_test
const allValueIds = [valueId, ...childValueIds]; WHERE (company_code = $1 OR company_code = '*') AND value_id = $2
RETURNING value_id
logger.info("삭제 대상 카테고리 값 수집 완료", { `;
valueId,
childCount: childValueIds.length,
totalCount: allValueIds.length,
});
// 2. 하위 카테고리부터 역순으로 삭제 (외래키 제약 회피) const result = await pool.query(query, [companyCode, valueId]);
const reversedIds = [...allValueIds].reverse();
if (result.rowCount && result.rowCount > 0) {
for (const id of reversedIds) { logger.info("카테고리 값 삭제 완료", { valueId });
await pool.query( return true;
`DELETE FROM category_values_test WHERE (company_code = $1 OR company_code = '*') AND value_id = $2`,
[companyCode, id]
);
} }
logger.info("카테고리 값 삭제 완료", { return false;
valueId,
deletedCount: allValueIds.length,
deletedChildCount: childValueIds.length,
});
return true;
} catch (error: unknown) { } catch (error: unknown) {
const err = error as Error; const err = error as Error;
logger.error("카테고리 값 삭제 실패", { error: err.message, valueId }); logger.error("카테고리 값 삭제 실패", { error: err.message, valueId });
@ -563,10 +523,10 @@ class CategoryTreeService {
cv.table_name AS "tableName", cv.table_name AS "tableName",
cv.column_name AS "columnName", cv.column_name AS "columnName",
COALESCE(tl.table_label, cv.table_name) AS "tableLabel", COALESCE(tl.table_label, cv.table_name) AS "tableLabel",
COALESCE(ttc.column_label, cv.column_name) AS "columnLabel" COALESCE(cl.column_label, cv.column_name) AS "columnLabel"
FROM category_values_test cv FROM category_values_test cv
LEFT JOIN table_labels tl ON tl.table_name = cv.table_name LEFT JOIN table_labels tl ON tl.table_name = cv.table_name
LEFT JOIN table_type_columns ttc ON ttc.table_name = cv.table_name AND ttc.column_name = cv.column_name AND ttc.company_code = '*' LEFT JOIN column_labels cl ON cl.table_name = cv.table_name AND cl.column_name = cv.column_name
WHERE cv.company_code = $1 OR cv.company_code = '*' WHERE cv.company_code = $1 OR cv.company_code = '*'
ORDER BY cv.table_name, cv.column_name ORDER BY cv.table_name, cv.column_name
`; `;

View File

@ -467,18 +467,18 @@ class DataService {
columnName: string columnName: string
): Promise<string | null> { ): Promise<string | null> {
try { try {
// table_type_columns 테이블에서 라벨 조회 // column_labels 테이블에서 라벨 조회
const result = await query<{ column_label: string }>( const result = await query<{ label_ko: string }>(
`SELECT column_label `SELECT label_ko
FROM table_type_columns FROM column_labels
WHERE table_name = $1 AND column_name = $2 AND company_code = '*' WHERE table_name = $1 AND column_name = $2
LIMIT 1`, LIMIT 1`,
[tableName, columnName] [tableName, columnName]
); );
return result[0]?.column_label || null; return result[0]?.label_ko || null;
} catch (error) { } catch (error) {
// table_type_columns 테이블이 없거나 오류가 발생하면 null 반환 // column_labels 테이블이 없거나 오류가 발생하면 null 반환
return null; return null;
} }
} }

View File

@ -553,8 +553,77 @@ CREATE TABLE "${tableName}" (${baseColumns},
); );
} }
// 레거시 column_labels 테이블 지원 제거됨 (2026-01-26) // 레거시 지원: column_labels 테이블에도 등록 (기존 시스템 호환성)
// 모든 컬럼 메타데이터는 table_type_columns에서 관리 // 1. 기본 컬럼들을 column_labels에 등록
for (const defaultCol of defaultColumns) {
await client.query(
`
INSERT INTO column_labels (
table_name, column_name, column_label, input_type, detail_settings,
description, display_order, is_visible, created_date, updated_date
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, now(), now()
)
ON CONFLICT (table_name, column_name)
DO UPDATE SET
column_label = $3,
input_type = $4,
detail_settings = $5,
description = $6,
display_order = $7,
is_visible = $8,
updated_date = now()
`,
[
tableName,
defaultCol.name,
defaultCol.label,
defaultCol.inputType,
JSON.stringify({}),
defaultCol.description,
defaultCol.order,
defaultCol.isVisible,
]
);
}
// 2. 사용자 정의 컬럼들을 column_labels에 등록
for (const column of columns) {
const inputType = this.convertWebTypeToInputType(
column.webType || "text"
);
const detailSettings = JSON.stringify(column.detailSettings || {});
await client.query(
`
INSERT INTO column_labels (
table_name, column_name, column_label, input_type, detail_settings,
description, display_order, is_visible, created_date, updated_date
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, now(), now()
)
ON CONFLICT (table_name, column_name)
DO UPDATE SET
column_label = $3,
input_type = $4,
detail_settings = $5,
description = $6,
display_order = $7,
is_visible = $8,
updated_date = now()
`,
[
tableName,
column.name,
column.label || column.name,
inputType,
detailSettings,
column.description,
column.order || 0,
true,
]
);
}
} }
/** /**
@ -671,9 +740,9 @@ CREATE TABLE "${tableName}" (${baseColumns},
[tableName] [tableName]
); );
// 컬럼 정보 조회 (table_type_columns에서) // 컬럼 정보 조회
const columns = await query( const columns = await query(
`SELECT * FROM table_type_columns WHERE table_name = $1 AND company_code = '*' ORDER BY display_order ASC`, `SELECT * FROM column_labels WHERE table_name = $1 ORDER BY display_order ASC`,
[tableName] [tableName]
); );
@ -746,7 +815,7 @@ CREATE TABLE "${tableName}" (${baseColumns},
await client.query(ddlQuery); await client.query(ddlQuery);
// 4-2. 관련 메타데이터 삭제 // 4-2. 관련 메타데이터 삭제
await client.query(`DELETE FROM table_type_columns WHERE table_name = $1`, [ await client.query(`DELETE FROM column_labels WHERE table_name = $1`, [
tableName, tableName,
]); ]);
await client.query(`DELETE FROM table_labels WHERE table_name = $1`, [ await client.query(`DELETE FROM table_labels WHERE table_name = $1`, [

View File

@ -24,8 +24,7 @@ export class EntityJoinService {
try { try {
logger.info(`Entity 컬럼 감지 시작: ${tableName}`); logger.info(`Entity 컬럼 감지 시작: ${tableName}`);
// table_type_columns에서 entity 및 category 타입인 컬럼들 조회 // column_labels에서 entity 및 category 타입인 컬럼들 조회 (input_type 사용)
// company_code = '*' (공통 설정) 우선 조회
const entityColumns = await query<{ const entityColumns = await query<{
column_name: string; column_name: string;
input_type: string; input_type: string;
@ -34,12 +33,9 @@ export class EntityJoinService {
display_column: string | null; display_column: string | null;
}>( }>(
`SELECT column_name, input_type, reference_table, reference_column, display_column `SELECT column_name, input_type, reference_table, reference_column, display_column
FROM table_type_columns FROM column_labels
WHERE table_name = $1 WHERE table_name = $1
AND input_type IN ('entity', 'category') AND input_type IN ('entity', 'category')`,
AND company_code = '*'
AND reference_table IS NOT NULL
AND reference_table != ''`,
[tableName] [tableName]
); );
@ -749,16 +745,15 @@ export class EntityJoinService {
[tableName] [tableName]
); );
// 2. table_type_columns 테이블에서 라벨과 input_type 정보 조회 // 2. column_labels 테이블에서 라벨과 input_type 정보 조회
const columnLabels = await query<{ const columnLabels = await query<{
column_name: string; column_name: string;
column_label: string | null; column_label: string | null;
input_type: string | null; input_type: string | null;
}>( }>(
`SELECT column_name, column_label, input_type `SELECT column_name, column_label, input_type
FROM table_type_columns FROM column_labels
WHERE table_name = $1 WHERE table_name = $1`,
AND company_code = '*'`,
[tableName] [tableName]
); );

View File

@ -316,9 +316,9 @@ export class FlowExecutionService {
flowDef.dbConnectionId flowDef.dbConnectionId
); );
// 외부 DB 연결 정보 조회 (flow 전용 테이블 사용) // 외부 DB 연결 정보 조회
const connectionResult = await db.query( const connectionResult = await db.query(
"SELECT * FROM flow_external_db_connection WHERE id = $1", "SELECT * FROM external_db_connection WHERE id = $1",
[flowDef.dbConnectionId] [flowDef.dbConnectionId]
); );

View File

@ -132,7 +132,7 @@ class MasterDetailExcelService {
} }
/** /**
* table_type_columns에서 Entity * column_labels에서 Entity
* *
*/ */
async getEntityRelation( async getEntityRelation(
@ -144,11 +144,10 @@ class MasterDetailExcelService {
const result = await queryOne<any>( const result = await queryOne<any>(
`SELECT column_name, reference_column `SELECT column_name, reference_column
FROM table_type_columns FROM column_labels
WHERE table_name = $1 WHERE table_name = $1
AND input_type = 'entity' AND input_type = 'entity'
AND reference_table = $2 AND reference_table = $2
AND company_code = '*'
LIMIT 1`, LIMIT 1`,
[detailTable, masterTable] [detailTable, masterTable]
); );
@ -177,8 +176,8 @@ class MasterDetailExcelService {
try { try {
const result = await query<any>( const result = await query<any>(
`SELECT column_name, column_label `SELECT column_name, column_label
FROM table_type_columns FROM column_labels
WHERE table_name = $1 AND company_code = '*'`, WHERE table_name = $1`,
[tableName] [tableName]
); );
@ -232,7 +231,7 @@ class MasterDetailExcelService {
detailFkColumn = splitPanel.rightPanel.relation?.foreignKey; detailFkColumn = splitPanel.rightPanel.relation?.foreignKey;
} }
// 3. relation 정보가 없으면 table_type_columns에서 Entity 관계 조회 // 3. relation 정보가 없으면 column_labels에서 Entity 관계 조회
if (!masterKeyColumn || !detailFkColumn) { if (!masterKeyColumn || !detailFkColumn) {
const entityRelation = await this.getEntityRelation(detailTable, masterTable); const entityRelation = await this.getEntityRelation(detailTable, masterTable);
if (entityRelation) { if (entityRelation) {
@ -323,7 +322,7 @@ class MasterDetailExcelService {
const [refTable, displayColumn] = col.name.split("."); const [refTable, displayColumn] = col.name.split(".");
const alias = `ej${aliasIndex++}`; const alias = `ej${aliasIndex++}`;
// table_type_columns에서 FK 컬럼 찾기 // column_labels에서 FK 컬럼 찾기
const fkColumn = await this.findForeignKeyColumn(masterTable, refTable); const fkColumn = await this.findForeignKeyColumn(masterTable, refTable);
if (fkColumn) { if (fkColumn) {
entityJoins.push({ entityJoins.push({
@ -351,7 +350,7 @@ class MasterDetailExcelService {
const [refTable, displayColumn] = col.name.split("."); const [refTable, displayColumn] = col.name.split(".");
const alias = `ej${aliasIndex++}`; const alias = `ej${aliasIndex++}`;
// table_type_columns에서 FK 컬럼 찾기 // column_labels에서 FK 컬럼 찾기
const fkColumn = await this.findForeignKeyColumn(detailTable, refTable); const fkColumn = await this.findForeignKeyColumn(detailTable, refTable);
if (fkColumn) { if (fkColumn) {
entityJoins.push({ entityJoins.push({
@ -456,11 +455,10 @@ class MasterDetailExcelService {
try { try {
const result = await query<{ column_name: string; reference_column: string }>( const result = await query<{ column_name: string; reference_column: string }>(
`SELECT column_name, reference_column `SELECT column_name, reference_column
FROM table_type_columns FROM column_labels
WHERE table_name = $1 WHERE table_name = $1
AND reference_table = $2 AND reference_table = $2
AND input_type = 'entity' AND input_type = 'entity'
AND company_code = '*'
LIMIT 1`, LIMIT 1`,
[sourceTable, referenceTable] [sourceTable, referenceTable]
); );

View File

@ -777,7 +777,7 @@ export class MultiConnectionQueryService {
dataType: column.dataType, dataType: column.dataType,
dbType: column.dataType, // dataType을 dbType으로 사용 dbType: column.dataType, // dataType을 dbType으로 사용
webType: column.webType || "text", // webType 사용, 기본값 text webType: column.webType || "text", // webType 사용, 기본값 text
inputType: column.inputType || "direct", // table_type_columns의 input_type 추가 inputType: column.inputType || "direct", // column_labels의 input_type 추가
codeCategory: column.codeCategory, // 코드 카테고리 정보 추가 codeCategory: column.codeCategory, // 코드 카테고리 정보 추가
isNullable: column.isNullable === "Y", isNullable: column.isNullable === "Y",
isPrimaryKey: column.isPrimaryKey || false, isPrimaryKey: column.isPrimaryKey || false,

View File

@ -477,6 +477,7 @@ export class ReferenceCacheService {
// 일반적인 참조 테이블들 // 일반적인 참조 테이블들
const commonTables = [ const commonTables = [
{ table: "user_info", key: "user_id", display: "user_name" }, { table: "user_info", key: "user_id", display: "user_name" },
{ table: "comm_code", key: "code_id", display: "code_name" },
{ table: "dept_info", key: "dept_code", display: "dept_name" }, { table: "dept_info", key: "dept_code", display: "dept_name" },
{ table: "companies", key: "company_code", display: "company_name" }, { table: "companies", key: "company_code", display: "company_name" },
]; ];

View File

@ -18,7 +18,6 @@ import {
import { generateId } from "../utils/generateId"; import { generateId } from "../utils/generateId";
import logger from "../utils/logger"; import logger from "../utils/logger";
import { reconstructConfig, extractConfigDiff } from "../utils/componentDefaults";
// 화면 복사 요청 인터페이스 // 화면 복사 요청 인터페이스
interface CopyScreenRequest { interface CopyScreenRequest {
@ -1271,14 +1270,14 @@ export class ScreenManagementService {
console.log(`⚠️ [getTableColumns] currency_code 없음`); console.log(`⚠️ [getTableColumns] currency_code 없음`);
} }
// table_type_columns 테이블에서 라벨 정보 조회 (우선순위 2) // column_labels 테이블에서 라벨 정보 조회 (우선순위 2)
const labelInfo = await query<{ const labelInfo = await query<{
column_name: string; column_name: string;
column_label: string | null; column_label: string | null;
}>( }>(
`SELECT column_name, column_label `SELECT column_name, column_label
FROM table_type_columns FROM column_labels
WHERE table_name = $1 AND company_code = '*'`, WHERE table_name = $1`,
[tableName] [tableName]
); );
@ -1332,7 +1331,7 @@ export class ScreenManagementService {
console.log(`🏷️ [getTableColumns] inputType 추가 완료: ${addedTypes.size}`); console.log(`🏷️ [getTableColumns] inputType 추가 완료: ${addedTypes.size}`);
// table_type_columns에서 라벨 추가 // column_labels에서 라벨 추가
labelInfo.forEach((label) => { labelInfo.forEach((label) => {
const col = columnMap.get(label.column_name); const col = columnMap.get(label.column_name);
if (col) { if (col) {
@ -1730,110 +1729,6 @@ export class ScreenManagementService {
}; };
} }
/**
* V1 (component_url + custom_config )
* screen_layouts_v1
*
* 🔒 :
* - component_url: 컴포넌트 (, NOT NULL)
* - custom_config: 회사별 (slot )
* - company_code: 멀티테넌시
*/
async getLayoutV1(
screenId: number,
companyCode: string
): Promise<LayoutData | null> {
console.log(`=== V1 레이아웃 로드 시작 ===`);
console.log(`화면 ID: ${screenId}, 회사: ${companyCode}`);
// 권한 확인 및 테이블명 조회
const screens = await query<{ company_code: string | null; table_name: string | null }>(
`SELECT company_code, table_name FROM screen_definitions WHERE screen_id = $1 LIMIT 1`,
[screenId]
);
if (screens.length === 0) {
return null;
}
const existingScreen = screens[0];
if (companyCode !== "*" && existingScreen.company_code !== companyCode) {
throw new Error("이 화면의 레이아웃을 조회할 권한이 없습니다.");
}
// V1 테이블에서 조회 (company_code 필터 포함 - 멀티테넌시 필수)
const layouts = await query<any>(
`SELECT * FROM screen_layouts_v1
WHERE screen_id = $1
AND (company_code = $2 OR $2 = '*')
ORDER BY display_order ASC NULLS LAST, layout_id ASC`,
[screenId, companyCode]
);
console.log(`V1 DB에서 조회된 레이아웃 수: ${layouts.length}`);
if (layouts.length === 0) {
return {
components: [],
gridSettings: {
columns: 12,
gap: 16,
padding: 16,
snapToGrid: true,
showGrid: true,
},
screenResolution: null,
};
}
const components: ComponentData[] = layouts.map((layout: any) => {
// component_url에서 컴포넌트 타입 추출
// "@/lib/registry/components/split-panel-layout" → "split-panel-layout"
const componentUrl = layout.component_url || "";
const componentType = componentUrl.split("/").pop() || "unknown";
// custom_config가 곧 componentConfig
const componentConfig = layout.custom_config || {};
const component = {
id: layout.component_id,
type: componentType as any,
componentType: componentType,
componentUrl: componentUrl, // URL도 전달
position: {
x: layout.position_x,
y: layout.position_y,
z: 1,
},
size: {
width: layout.width,
height: layout.height
},
parentId: layout.parent_id,
componentConfig,
};
return component;
});
console.log(`=== V1 레이아웃 로드 완료 ===`);
console.log(`반환할 컴포넌트 수: ${components.length}`);
return {
components,
gridSettings: {
columns: 12,
gap: 16,
padding: 16,
snapToGrid: true,
showGrid: true,
},
screenResolution: null,
tableName: existingScreen.table_name,
};
}
/** /**
* ID * ID
* ( webTypeMapping.ts와 ) * ( webTypeMapping.ts와 )
@ -2179,27 +2074,27 @@ export class ScreenManagementService {
const columns = await query<any>( const columns = await query<any>(
`SELECT `SELECT
c.column_name, c.column_name,
COALESCE(ttc.column_label, c.column_name) as column_label, COALESCE(cl.column_label, c.column_name) as column_label,
c.data_type, c.data_type,
COALESCE(ttc.input_type, 'text') as web_type, COALESCE(cl.input_type, 'text') as web_type,
c.is_nullable, c.is_nullable,
c.column_default, c.column_default,
c.character_maximum_length, c.character_maximum_length,
c.numeric_precision, c.numeric_precision,
c.numeric_scale, c.numeric_scale,
ttc.detail_settings, cl.detail_settings,
ttc.code_category, cl.code_category,
ttc.reference_table, cl.reference_table,
ttc.reference_column, cl.reference_column,
ttc.display_column, cl.display_column,
ttc.is_visible, cl.is_visible,
ttc.display_order, cl.display_order,
ttc.description cl.description
FROM information_schema.columns c FROM information_schema.columns c
LEFT JOIN table_type_columns ttc ON c.table_name = ttc.table_name LEFT JOIN column_labels cl ON c.table_name = cl.table_name
AND c.column_name = ttc.column_name AND ttc.company_code = '*' AND c.column_name = cl.column_name
WHERE c.table_name = $1 WHERE c.table_name = $1
ORDER BY COALESCE(ttc.display_order, c.ordinal_position)`, ORDER BY COALESCE(cl.display_order, c.ordinal_position)`,
[tableName] [tableName]
); );
@ -2215,26 +2110,26 @@ export class ScreenManagementService {
webType: WebType, webType: WebType,
additionalSettings?: Partial<ColumnWebTypeSetting> additionalSettings?: Partial<ColumnWebTypeSetting>
): Promise<void> { ): Promise<void> {
// UPSERT를 INSERT ... ON CONFLICT로 변환 (table_type_columns 사용) // UPSERT를 INSERT ... ON CONFLICT로 변환 (input_type 사용)
await query( await query(
`INSERT INTO table_type_columns ( `INSERT INTO column_labels (
table_name, column_name, column_label, input_type, detail_settings, table_name, column_name, column_label, input_type, detail_settings,
code_category, reference_table, reference_column, display_column, code_category, reference_table, reference_column, display_column,
is_visible, display_order, description, is_nullable, company_code, created_date, updated_date is_visible, display_order, description, created_date, updated_date
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, 'Y', '*', $13, $14) ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
ON CONFLICT (table_name, column_name, company_code) ON CONFLICT (table_name, column_name)
DO UPDATE SET DO UPDATE SET
input_type = EXCLUDED.input_type, input_type = $4,
column_label = COALESCE(EXCLUDED.column_label, table_type_columns.column_label), column_label = $3,
detail_settings = COALESCE(EXCLUDED.detail_settings, table_type_columns.detail_settings), detail_settings = $5,
code_category = COALESCE(EXCLUDED.code_category, table_type_columns.code_category), code_category = $6,
reference_table = COALESCE(EXCLUDED.reference_table, table_type_columns.reference_table), reference_table = $7,
reference_column = COALESCE(EXCLUDED.reference_column, table_type_columns.reference_column), reference_column = $8,
display_column = COALESCE(EXCLUDED.display_column, table_type_columns.display_column), display_column = $9,
is_visible = COALESCE(EXCLUDED.is_visible, table_type_columns.is_visible), is_visible = $10,
display_order = COALESCE(EXCLUDED.display_order, table_type_columns.display_order), display_order = $11,
description = COALESCE(EXCLUDED.description, table_type_columns.description), description = $12,
updated_date = EXCLUDED.updated_date`, updated_date = $14`,
[ [
tableName, tableName,
columnName, columnName,
@ -2743,11 +2638,6 @@ export class ScreenManagementService {
* - * -
* - current_sequence는 0 * - current_sequence는 0
*/ */
/**
* (numbering_rules_test )
* - menu_objid
* - table_name + column_name + company_code
*/
private async copyNumberingRulesForScreen( private async copyNumberingRulesForScreen(
ruleIds: Set<string>, ruleIds: Set<string>,
sourceCompanyCode: string, sourceCompanyCode: string,
@ -2762,10 +2652,11 @@ export class ScreenManagementService {
console.log(`🔄 채번 규칙 복사 시작: ${ruleIds.size}개 규칙`); console.log(`🔄 채번 규칙 복사 시작: ${ruleIds.size}개 규칙`);
// 1. 원본 채번 규칙 조회 (numbering_rules_test 테이블) // 1. 원본 채번 규칙 조회 (회사 코드 제한 없이 rule_id로 조회)
// 화면이 다른 회사의 채번 규칙을 참조할 수 있으므로 회사 필터 제거
const ruleIdArray = Array.from(ruleIds); const ruleIdArray = Array.from(ruleIds);
const sourceRulesResult = await client.query( const sourceRulesResult = await client.query(
`SELECT * FROM numbering_rules_test WHERE rule_id = ANY($1)`, `SELECT * FROM numbering_rules WHERE rule_id = ANY($1)`,
[ruleIdArray] [ruleIdArray]
); );
@ -2778,7 +2669,7 @@ export class ScreenManagementService {
// 2. 대상 회사의 기존 채번 규칙 조회 (이름 기준) // 2. 대상 회사의 기존 채번 규칙 조회 (이름 기준)
const existingRulesResult = await client.query( const existingRulesResult = await client.query(
`SELECT rule_id, rule_name FROM numbering_rules_test WHERE company_code = $1`, `SELECT rule_id, rule_name FROM numbering_rules WHERE company_code = $1`,
[targetCompanyCode] [targetCompanyCode]
); );
const existingRulesByName = new Map<string, string>( const existingRulesByName = new Map<string, string>(
@ -2797,13 +2688,68 @@ export class ScreenManagementService {
// 새로 복사 - 새 rule_id 생성 // 새로 복사 - 새 rule_id 생성
const newRuleId = `rule-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; const newRuleId = `rule-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
// numbering_rules_test 복사 (current_sequence = 0으로 초기화) // scope_type이 'menu'인 경우 대상 회사에서 같은 이름의 메뉴 찾기
let newScopeType = rule.scope_type;
let newMenuObjid: string | null = null;
if (rule.scope_type === 'menu' && rule.menu_objid) {
// 원본 menu_objid로 메뉴와 연결된 screen_group 조회
const sourceMenuResult = await client.query(
`SELECT mi.menu_name_kor, sg.group_name
FROM menu_info mi
LEFT JOIN screen_groups sg ON sg.id = mi.screen_group_id
WHERE mi.objid = $1`,
[rule.menu_objid]
);
if (sourceMenuResult.rows.length > 0) {
const { menu_name_kor: menuName, group_name: groupName } = sourceMenuResult.rows[0];
// 방법 1: 그룹 이름으로 대상 회사의 메뉴 찾기 (더 정확)
let targetMenuResult;
if (groupName) {
targetMenuResult = await client.query(
`SELECT mi.objid, mi.menu_name_kor
FROM menu_info mi
JOIN screen_groups sg ON sg.id = mi.screen_group_id
WHERE mi.company_code = $1 AND sg.group_name = $2
LIMIT 1`,
[targetCompanyCode, groupName]
);
}
// 방법 2: 그룹으로 못 찾으면 메뉴 이름으로 찾기
if (!targetMenuResult || targetMenuResult.rows.length === 0) {
targetMenuResult = await client.query(
`SELECT objid, menu_name_kor FROM menu_info
WHERE company_code = $1 AND menu_name_kor = $2
LIMIT 1`,
[targetCompanyCode, menuName]
);
}
if (targetMenuResult.rows.length > 0) {
// 대상 회사에 매칭되는 메뉴가 있으면 연결
newMenuObjid = targetMenuResult.rows[0].objid;
console.log(` 🔗 메뉴 연결: "${menuName}" → "${targetMenuResult.rows[0].menu_name_kor}" (objid: ${newMenuObjid})`);
} else {
// 대상 회사에 메뉴가 없으면 복제하지 않음 (메뉴 동기화 후 다시 시도 필요)
console.log(` ⏭️ 채번규칙 "${rule.rule_name}" 건너뜀: 대상 회사에 "${menuName}" 메뉴 없음`);
continue; // 이 채번규칙은 복제하지 않음
}
} else {
// 원본 메뉴를 찾을 수 없으면 복제하지 않음
console.log(` ⏭️ 채번규칙 "${rule.rule_name}" 건너뜀: 원본 메뉴(${rule.menu_objid})를 찾을 수 없음`);
continue; // 이 채번규칙은 복제하지 않음
}
}
// numbering_rules 복사 (current_sequence = 0으로 초기화)
await client.query( await client.query(
`INSERT INTO numbering_rules_test ( `INSERT INTO numbering_rules (
rule_id, rule_name, description, separator, reset_period, rule_id, rule_name, description, separator, reset_period,
current_sequence, table_name, column_name, company_code, current_sequence, table_name, column_name, company_code,
created_at, updated_at, created_by, last_generated_date, created_at, updated_at, created_by, scope_type, last_generated_date, menu_objid
category_column, category_value_id
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)`, ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)`,
[ [
newRuleId, newRuleId,
@ -2818,21 +2764,21 @@ export class ScreenManagementService {
new Date(), new Date(),
new Date(), new Date(),
rule.created_by, rule.created_by,
newScopeType,
null, // last_generated_date 초기화 null, // last_generated_date 초기화
rule.category_column, newMenuObjid, // 대상 회사의 메뉴 objid (없으면 null)
rule.category_value_id,
] ]
); );
// numbering_rule_parts_test 복사 // numbering_rule_parts 복사
const partsResult = await client.query( const partsResult = await client.query(
`SELECT * FROM numbering_rule_parts_test WHERE rule_id = $1 ORDER BY part_order`, `SELECT * FROM numbering_rule_parts WHERE rule_id = $1 ORDER BY part_order`,
[rule.rule_id] [rule.rule_id]
); );
for (const part of partsResult.rows) { for (const part of partsResult.rows) {
await client.query( await client.query(
`INSERT INTO numbering_rule_parts_test ( `INSERT INTO numbering_rule_parts (
rule_id, part_order, part_type, generation_method, rule_id, part_order, part_type, generation_method,
auto_config, manual_config, company_code, created_at auto_config, manual_config, company_code, created_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
@ -2850,7 +2796,7 @@ export class ScreenManagementService {
} }
ruleIdMap.set(rule.rule_id, newRuleId); ruleIdMap.set(rule.rule_id, newRuleId);
console.log(` 채번 규칙 복사: ${rule.rule_name} (${rule.rule_id}${newRuleId}), 파트 ${partsResult.rows.length}`); console.log(` 채번 규칙 복사: ${rule.rule_name} (${rule.rule_id}${newRuleId}), scope: ${newScopeType}, menu_objid: ${newMenuObjid || 'NULL'}, 파트 ${partsResult.rows.length}`);
} }
} }
@ -2965,11 +2911,10 @@ export class ScreenManagementService {
} }
/** /**
* * screenId/modalScreenId
* - tabs screenId * - tabs screenId
* - conditional-container의 screenId * - conditional-container의 screenId
* - / modalScreenId * - / modalScreenId
* - / targetScreenId ( , )
* @param targetScreenIds ID * @param targetScreenIds ID
* @param screenIdMap ID -> ID * @param screenIdMap ID -> ID
*/ */
@ -2993,7 +2938,7 @@ export class ScreenManagementService {
); );
await transaction(async (client) => { await transaction(async (client) => {
// 대상 화면들의 모든 레이아웃 조회 (screenId, modalScreenId, targetScreenId 참조가 있는 것) // 대상 화면들의 모든 레이아웃 조회 (screenId 또는 modalScreenId 참조가 있는 것)
const placeholders = targetScreenIds.map((_, i) => `$${i + 1}`).join(', '); const placeholders = targetScreenIds.map((_, i) => `$${i + 1}`).join(', ');
const layoutsResult = await client.query( const layoutsResult = await client.query(
`SELECT layout_id, screen_id, properties `SELECT layout_id, screen_id, properties
@ -3002,7 +2947,6 @@ export class ScreenManagementService {
AND ( AND (
properties::text LIKE '%"screenId"%' properties::text LIKE '%"screenId"%'
OR properties::text LIKE '%"modalScreenId"%' OR properties::text LIKE '%"modalScreenId"%'
OR properties::text LIKE '%"targetScreenId"%'
)`, )`,
targetScreenIds targetScreenIds
); );
@ -3066,23 +3010,6 @@ export class ScreenManagementService {
} }
} }
// targetScreenId 업데이트 (버튼 액션에서 사용, 문자열 또는 숫자)
if (key === 'targetScreenId') {
const oldId = typeof value === 'string' ? parseInt(value, 10) : value;
if (!isNaN(oldId)) {
const newId = screenMap.get(oldId);
if (newId) {
// 원래 타입 유지 (문자열이면 문자열, 숫자면 숫자)
obj[key] = typeof value === 'string' ? newId.toString() : newId;
hasChanges = true;
result.details.push(`layout_id=${layout.layout_id}: ${currentPath} ${oldId}${newId}`);
console.log(`🔗 targetScreenId 매핑: ${oldId}${newId} (${currentPath})`);
} else {
console.log(`⚠️ targetScreenId ${oldId} 매핑 없음 (${currentPath}) - screenMap에 해당 키 없음`);
}
}
}
// 배열 처리 // 배열 처리
if (Array.isArray(value)) { if (Array.isArray(value)) {
for (let i = 0; i < value.length; i++) { for (let i = 0; i < value.length; i++) {
@ -3107,7 +3034,7 @@ export class ScreenManagementService {
} }
} }
console.log(`✅ screenId/modalScreenId/targetScreenId 참조 업데이트 완료: ${result.updated}개 레이아웃`); console.log(`✅ screenId/modalScreenId 참조 업데이트 완료: ${result.updated}개 레이아웃`);
}); });
return result; return result;
@ -3884,32 +3811,6 @@ export class ScreenManagementService {
assignment.is_active, assignment.is_active,
] ]
); );
// 🔧 menu_info.menu_url도 새 화면 ID로 업데이트
const menuInfo = await client.query<{ menu_type: string; screen_code: string | null }>(
`SELECT mi.menu_type, sd.screen_code
FROM menu_info mi
LEFT JOIN screen_definitions sd ON sd.screen_id = $1
WHERE mi.objid = $2`,
[newScreenId, newMenuObjid]
);
if (menuInfo.rows.length > 0) {
const isAdminMenu = menuInfo.rows[0].menu_type === "1";
const newMenuUrl = isAdminMenu
? `/screens/${newScreenId}?mode=admin`
: `/screens/${newScreenId}`;
const screenCode = menuInfo.rows[0].screen_code;
await client.query(
`UPDATE menu_info
SET menu_url = $1, screen_code = $2
WHERE objid = $3`,
[newMenuUrl, screenCode, newMenuObjid]
);
logger.debug(`✅ menu_info.menu_url 업데이트: ${newMenuObjid}${newMenuUrl}`);
}
result.copiedCount++; result.copiedCount++;
logger.debug(`✅ 할당 복제: screen ${newScreenId} → menu ${newMenuObjid}`); logger.debug(`✅ 할당 복제: screen ${newScreenId} → menu ${newMenuObjid}`);
} catch (error: any) { } catch (error: any) {
@ -4003,13 +3904,12 @@ export class ScreenManagementService {
} }
/** /**
* (category_values_test ) * +
* - menu_objid
* - table_name + column_name + company_code
*/ */
async copyCategoryMapping( async copyCategoryMapping(
sourceCompanyCode: string, sourceCompanyCode: string,
targetCompanyCode: string targetCompanyCode: string,
menuObjidMap?: Map<string, string>
): Promise<{ copiedMappings: number; copiedValues: number; details: string[] }> { ): Promise<{ copiedMappings: number; copiedValues: number; details: string[] }> {
const result = { const result = {
copiedMappings: 0, copiedMappings: 0,
@ -4018,62 +3918,71 @@ export class ScreenManagementService {
}; };
return transaction(async (client) => { return transaction(async (client) => {
logger.info(`📦 카테고리 값 복제: ${sourceCompanyCode}${targetCompanyCode}`); logger.info(`📦 카테고리 매핑/값 복제: ${sourceCompanyCode}${targetCompanyCode}`);
// 1. 기존 대상 회사 데이터 삭제 // 1. 기존 대상 회사 데이터 삭제
await client.query(`DELETE FROM category_values_test WHERE company_code = $1`, [targetCompanyCode]); await client.query(`DELETE FROM table_column_category_values WHERE company_code = $1`, [targetCompanyCode]);
await client.query(`DELETE FROM category_column_mapping WHERE company_code = $1`, [targetCompanyCode]);
// 2. category_values_test 복제 // 2. menuObjidMap 생성 (없는 경우)
const values = await client.query( if (!menuObjidMap || menuObjidMap.size === 0) {
`SELECT * FROM category_values_test WHERE company_code = $1`, menuObjidMap = new Map();
const groupPairs = await client.query<{ source_objid: string; target_objid: string }>(
`SELECT DISTINCT
sg1.menu_objid::text as source_objid,
sg2.menu_objid::text as target_objid
FROM screen_groups sg1
JOIN screen_groups sg2 ON sg1.group_name = sg2.group_name
WHERE sg1.company_code = $1 AND sg2.company_code = $2
AND sg1.menu_objid IS NOT NULL AND sg2.menu_objid IS NOT NULL`,
[sourceCompanyCode, targetCompanyCode]
);
groupPairs.rows.forEach(p => menuObjidMap!.set(p.source_objid, p.target_objid));
}
// 3. category_column_mapping 복제
const mappings = await client.query(
`SELECT * FROM category_column_mapping WHERE company_code = $1`,
[sourceCompanyCode] [sourceCompanyCode]
); );
// value_id 매핑 (parent_value_id 참조 업데이트용) for (const m of mappings.rows) {
const valueIdMap = new Map<number, number>(); const newMenuObjid = m.menu_objid ? menuObjidMap.get(m.menu_objid.toString()) || m.menu_objid : null;
for (const v of values.rows) {
const insertResult = await client.query(
`INSERT INTO category_values_test
(table_name, column_name, value_code, value_label, value_order,
parent_value_id, depth, path, description, color, icon,
is_active, is_default, company_code, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, 'system')
RETURNING value_id`,
[
v.table_name, v.column_name, v.value_code, v.value_label, v.value_order,
null, // parent_value_id는 나중에 업데이트
v.depth, v.path, v.description, v.color, v.icon,
v.is_active, v.is_default, targetCompanyCode
]
);
valueIdMap.set(v.value_id, insertResult.rows[0].value_id); await client.query(
result.copiedValues++; `INSERT INTO category_column_mapping
} (table_name, logical_column_name, physical_column_name, menu_objid, company_code, description, created_by)
VALUES ($1, $2, $3, $4, $5, $6, 'system')`,
// 3. parent_value_id 업데이트 (새 value_id로 매핑) [m.table_name, m.logical_column_name, m.physical_column_name, newMenuObjid, targetCompanyCode, m.description]
for (const v of values.rows) { );
if (v.parent_value_id) { result.copiedMappings++;
const newParentId = valueIdMap.get(v.parent_value_id);
const newValueId = valueIdMap.get(v.value_id);
if (newParentId && newValueId) {
await client.query(
`UPDATE category_values_test SET parent_value_id = $1 WHERE value_id = $2`,
[newParentId, newValueId]
);
}
}
} }
logger.info(`✅ 카테고리 값 복제 완료: ${result.copiedValues}`); // 4. table_column_category_values 복제
const values = await client.query(
`SELECT * FROM table_column_category_values WHERE company_code = $1`,
[sourceCompanyCode]
);
for (const v of values.rows) {
const newMenuObjid = v.menu_objid ? menuObjidMap.get(v.menu_objid.toString()) || v.menu_objid : null;
await client.query(
`INSERT INTO table_column_category_values
(table_name, column_name, value_code, value_label, value_order, parent_value_id, depth, description, color, icon, is_active, is_default, company_code, menu_objid, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, 'system')`,
[v.table_name, v.column_name, v.value_code, v.value_label, v.value_order, v.parent_value_id, v.depth, v.description, v.color, v.icon, v.is_active, v.is_default, targetCompanyCode, newMenuObjid]
);
result.copiedValues++;
}
logger.info(`✅ 카테고리 매핑/값 복제 완료: 매핑 ${result.copiedMappings}개, 값 ${result.copiedValues}`);
return result; return result;
}); });
} }
/** /**
* *
* - column_labels
*/ */
async copyTableTypeColumns( async copyTableTypeColumns(
sourceCompanyCode: string, sourceCompanyCode: string,
@ -4090,7 +3999,7 @@ export class ScreenManagementService {
// 1. 기존 대상 회사 데이터 삭제 // 1. 기존 대상 회사 데이터 삭제
await client.query(`DELETE FROM table_type_columns WHERE company_code = $1`, [targetCompanyCode]); await client.query(`DELETE FROM table_type_columns WHERE company_code = $1`, [targetCompanyCode]);
// 2. 복제 (column_labels 통합 후 모든 컬럼 포함) // 2. 복제
const columns = await client.query( const columns = await client.query(
`SELECT * FROM table_type_columns WHERE company_code = $1`, `SELECT * FROM table_type_columns WHERE company_code = $1`,
[sourceCompanyCode] [sourceCompanyCode]
@ -4099,28 +4008,9 @@ export class ScreenManagementService {
for (const col of columns.rows) { for (const col of columns.rows) {
await client.query( await client.query(
`INSERT INTO table_type_columns `INSERT INTO table_type_columns
(table_name, column_name, input_type, detail_settings, is_nullable, display_order, (table_name, column_name, input_type, detail_settings, is_nullable, display_order, company_code)
column_label, description, is_visible, code_category, code_value, VALUES ($1, $2, $3, $4, $5, $6, $7)`,
reference_table, reference_column, display_column, company_code, [col.table_name, col.column_name, col.input_type, col.detail_settings, col.is_nullable, col.display_order, targetCompanyCode]
created_date, updated_date)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, NOW(), NOW())`,
[
col.table_name,
col.column_name,
col.input_type,
col.detail_settings,
col.is_nullable,
col.display_order,
col.column_label,
col.description,
col.is_visible,
col.code_category,
col.code_value,
col.reference_table,
col.reference_column,
col.display_column,
targetCompanyCode
]
); );
result.copiedCount++; result.copiedCount++;
} }
@ -4175,112 +4065,6 @@ export class ScreenManagementService {
return result; return result;
}); });
} }
// ========================================
// V2 레이아웃 관리 (1 레코드 방식)
// ========================================
/**
* V2 (1 )
* - screen_layouts_v2 1
* - layout_data JSON에
*/
async getLayoutV2(
screenId: number,
companyCode: string
): Promise<any | null> {
console.log(`=== V2 레이아웃 로드 시작 ===`);
console.log(`화면 ID: ${screenId}, 회사: ${companyCode}`);
// 권한 확인
const screens = await query<{ company_code: string | null; table_name: string | null }>(
`SELECT company_code, table_name FROM screen_definitions WHERE screen_id = $1 LIMIT 1`,
[screenId]
);
if (screens.length === 0) {
return null;
}
const existingScreen = screens[0];
if (companyCode !== "*" && existingScreen.company_code !== companyCode) {
throw new Error("이 화면의 레이아웃을 조회할 권한이 없습니다.");
}
// V2 테이블에서 조회 (회사별 우선, 없으면 공통(*) 조회)
let layout = await queryOne<{ layout_data: any }>(
`SELECT layout_data FROM screen_layouts_v2
WHERE screen_id = $1 AND company_code = $2`,
[screenId, companyCode]
);
// 회사별 레이아웃이 없으면 공통(*) 레이아웃 조회
if (!layout && companyCode !== "*") {
layout = await queryOne<{ layout_data: any }>(
`SELECT layout_data FROM screen_layouts_v2
WHERE screen_id = $1 AND company_code = '*'`,
[screenId]
);
}
if (!layout) {
console.log(`V2 레이아웃 없음: screen_id=${screenId}`);
return null;
}
console.log(`V2 레이아웃 로드 완료: ${layout.layout_data?.components?.length || 0}개 컴포넌트`);
return layout.layout_data;
}
/**
* V2 (1 )
* - screen_layouts_v2 1
* - layout_data JSON에
*/
async saveLayoutV2(
screenId: number,
layoutData: any,
companyCode: string
): Promise<void> {
console.log(`=== V2 레이아웃 저장 시작 ===`);
console.log(`화면 ID: ${screenId}, 회사: ${companyCode}`);
console.log(`컴포넌트 수: ${layoutData.components?.length || 0}`);
// 권한 확인
const screens = await query<{ company_code: string | null }>(
`SELECT company_code FROM screen_definitions WHERE screen_id = $1 LIMIT 1`,
[screenId]
);
if (screens.length === 0) {
throw new Error("화면을 찾을 수 없습니다.");
}
const existingScreen = screens[0];
if (companyCode !== "*" && existingScreen.company_code !== companyCode) {
throw new Error("이 화면의 레이아웃을 저장할 권한이 없습니다.");
}
// 버전 정보 추가
const dataToSave = {
version: "2.0",
...layoutData,
updatedAt: new Date().toISOString()
};
// UPSERT (있으면 업데이트, 없으면 삽입)
await query(
`INSERT INTO screen_layouts_v2 (screen_id, company_code, layout_data, created_at, updated_at)
VALUES ($1, $2, $3, NOW(), NOW())
ON CONFLICT (screen_id, company_code)
DO UPDATE SET layout_data = $3, updated_at = NOW()`,
[screenId, companyCode, JSON.stringify(dataToSave)]
);
console.log(`V2 레이아웃 저장 완료`);
}
} }
// 서비스 인스턴스 export // 서비스 인스턴스 export

View File

@ -619,55 +619,7 @@ class TableCategoryValueService {
} }
/** /**
* ID * ( )
*/
private async collectAllChildValueIds(
valueId: number,
companyCode: string
): Promise<number[]> {
const pool = getPool();
const allChildIds: number[] = [];
// 재귀 CTE를 사용하여 모든 하위 카테고리 수집
let query: string;
let params: any[];
if (companyCode === "*") {
query = `
WITH RECURSIVE category_tree AS (
SELECT value_id FROM table_column_category_values WHERE parent_value_id = $1
UNION ALL
SELECT cv.value_id
FROM table_column_category_values cv
INNER JOIN category_tree ct ON cv.parent_value_id = ct.value_id
)
SELECT value_id FROM category_tree
`;
params = [valueId];
} else {
query = `
WITH RECURSIVE category_tree AS (
SELECT value_id FROM table_column_category_values
WHERE parent_value_id = $1 AND company_code = $2
UNION ALL
SELECT cv.value_id
FROM table_column_category_values cv
INNER JOIN category_tree ct ON cv.parent_value_id = ct.value_id
WHERE cv.company_code = $2
)
SELECT value_id FROM category_tree
`;
params = [valueId, companyCode];
}
const result = await pool.query(query, params);
result.rows.forEach(row => allChildIds.push(row.value_id));
return allChildIds;
}
/**
* ( )
*/ */
async deleteCategoryValue( async deleteCategoryValue(
valueId: number, valueId: number,
@ -677,74 +629,82 @@ class TableCategoryValueService {
const pool = getPool(); const pool = getPool();
try { try {
// 1. 자기 자신 + 모든 하위 카테고리 ID 수집 // 1. 사용 여부 확인
const childValueIds = await this.collectAllChildValueIds(valueId, companyCode); const usage = await this.checkCategoryValueUsage(valueId, companyCode);
const allValueIds = [valueId, ...childValueIds];
logger.info("삭제 대상 카테고리 값 수집 완료", {
valueId,
childCount: childValueIds.length,
totalCount: allValueIds.length,
});
// 2. 모든 대상 항목의 사용 여부 확인 if (usage.isUsed) {
for (const id of allValueIds) { let errorMessage = "이 카테고리 값을 삭제할 수 없습니다.\n";
const usage = await this.checkCategoryValueUsage(id, companyCode); errorMessage += `\n현재 ${usage.totalCount}개의 데이터에서 사용 중입니다.`;
if (usage.isUsed) { if (usage.usedInTables.length > 0) {
// 사용 중인 항목 정보 조회 const menuNames = usage.usedInTables.map((t) => t.menuName).join(", ");
let labelQuery: string; errorMessage += `\n\n다음 메뉴에서 사용 중입니다:\n${menuNames}`;
let labelParams: any[];
if (companyCode === "*") {
labelQuery = `SELECT value_label FROM table_column_category_values WHERE value_id = $1`;
labelParams = [id];
} else {
labelQuery = `SELECT value_label FROM table_column_category_values WHERE value_id = $1 AND company_code = $2`;
labelParams = [id, companyCode];
}
const labelResult = await pool.query(labelQuery, labelParams);
const valueLabel = labelResult.rows[0]?.value_label || `ID:${id}`;
let errorMessage = `카테고리 "${valueLabel}"을(를) 삭제할 수 없습니다.\n`;
errorMessage += `\n현재 ${usage.totalCount}개의 데이터에서 사용 중입니다.`;
if (usage.usedInTables.length > 0) {
const menuNames = usage.usedInTables.map((t) => t.menuName).join(", ");
errorMessage += `\n\n다음 메뉴에서 사용 중입니다:\n${menuNames}`;
}
errorMessage += "\n\n메뉴에서 사용하는 카테고리 항목을 수정한 후 다시 삭제해주세요.";
throw new Error(errorMessage);
} }
errorMessage += "\n\n메뉴에서 사용하는 카테고리 항목을 수정한 후 다시 삭제해주세요.";
throw new Error(errorMessage);
} }
// 3. 하위 카테고리부터 역순으로 삭제 (외래키 제약 회피) // 2. 하위 값 체크 (멀티테넌시 적용)
// 가장 깊은 하위부터 삭제해야 하므로 역순으로 let checkQuery: string;
const reversedIds = [...allValueIds].reverse(); let checkParams: any[];
for (const id of reversedIds) { if (companyCode === "*") {
let deleteQuery: string; // 최고 관리자: 모든 하위 값 체크
let deleteParams: any[]; checkQuery = `
SELECT COUNT(*) as count
if (companyCode === "*") { FROM table_column_category_values
deleteQuery = `DELETE FROM table_column_category_values WHERE value_id = $1`; WHERE parent_value_id = $1
deleteParams = [id]; `;
} else { checkParams = [valueId];
deleteQuery = `DELETE FROM table_column_category_values WHERE value_id = $1 AND company_code = $2`; } else {
deleteParams = [id, companyCode]; // 일반 회사: 자신의 하위 값만 체크
} checkQuery = `
SELECT COUNT(*) as count
await pool.query(deleteQuery, deleteParams); FROM table_column_category_values
WHERE parent_value_id = $1
AND company_code = $2
`;
checkParams = [valueId, companyCode];
}
const checkResult = await pool.query(checkQuery, checkParams);
if (parseInt(checkResult.rows[0].count) > 0) {
throw new Error("하위 카테고리 값이 있어 삭제할 수 없습니다");
}
// 3. 물리적 삭제 (멀티테넌시 적용)
let deleteQuery: string;
let deleteParams: any[];
if (companyCode === "*") {
// 최고 관리자: 모든 카테고리 값 삭제 가능
deleteQuery = `
DELETE FROM table_column_category_values
WHERE value_id = $1
`;
deleteParams = [valueId];
} else {
// 일반 회사: 자신의 카테고리 값만 삭제 가능
deleteQuery = `
DELETE FROM table_column_category_values
WHERE value_id = $1
AND company_code = $2
`;
deleteParams = [valueId, companyCode];
}
const result = await pool.query(deleteQuery, deleteParams);
if (result.rowCount === 0) {
throw new Error("카테고리 값을 찾을 수 없거나 권한이 없습니다");
} }
logger.info("카테고리 값 삭제 완료", { logger.info("카테고리 값 삭제 완료", {
valueId, valueId,
companyCode, companyCode,
deletedCount: allValueIds.length,
deletedChildCount: childValueIds.length,
}); });
} catch (error: any) { } catch (error: any) {
logger.error(`카테고리 값 삭제 실패: ${error.message}`); logger.error(`카테고리 값 삭제 실패: ${error.message}`);

View File

@ -27,14 +27,13 @@ export class TableManagementService {
columnName: string columnName: string
): Promise<{ isCodeType: boolean; codeCategory?: string }> { ): Promise<{ isCodeType: boolean; codeCategory?: string }> {
try { try {
// table_type_columns 테이블에서 해당 컬럼의 input_type이 'code'인지 확인 // column_labels 테이블에서 해당 컬럼의 input_type이 'code'인지 확인
const result = await query( const result = await query(
`SELECT input_type, code_category `SELECT input_type, code_category
FROM table_type_columns FROM column_labels
WHERE table_name = $1 WHERE table_name = $1
AND column_name = $2 AND column_name = $2
AND input_type = 'code' AND input_type = 'code'`,
AND company_code = '*'`,
[tableName, columnName] [tableName, columnName]
); );
@ -185,38 +184,37 @@ export class TableManagementService {
const offset = (page - 1) * size; const offset = (page - 1) * size;
// 🔥 company_code가 있으면 table_type_columns 조인하여 회사별 inputType 가져오기 // 🔥 company_code가 있으면 table_type_columns 조인하여 회사별 inputType 가져오기
// cl: 공통 설정 (company_code = '*'), ttc: 회사별 설정
const rawColumns = companyCode const rawColumns = companyCode
? await query<any>( ? await query<any>(
`SELECT `SELECT
c.column_name as "columnName", c.column_name as "columnName",
COALESCE(ttc.column_label, cl.column_label, c.column_name) as "displayName", COALESCE(cl.column_label, c.column_name) as "displayName",
c.data_type as "dataType", c.data_type as "dataType",
c.data_type as "dbType", c.data_type as "dbType",
COALESCE(ttc.input_type, cl.input_type, 'text') as "webType", COALESCE(cl.input_type, 'text') as "webType",
COALESCE(ttc.input_type, cl.input_type, 'direct') as "inputType", COALESCE(ttc.input_type, cl.input_type, 'direct') as "inputType",
ttc.input_type as "ttc_input_type", ttc.input_type as "ttc_input_type",
cl.input_type as "cl_input_type", cl.input_type as "cl_input_type",
COALESCE(ttc.detail_settings::text, cl.detail_settings::text, '') as "detailSettings", COALESCE(ttc.detail_settings::text, cl.detail_settings, '') as "detailSettings",
COALESCE(ttc.description, cl.description, '') as "description", COALESCE(cl.description, '') as "description",
c.is_nullable as "isNullable", c.is_nullable as "isNullable",
CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END as "isPrimaryKey", CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END as "isPrimaryKey",
c.column_default as "defaultValue", c.column_default as "defaultValue",
c.character_maximum_length as "maxLength", c.character_maximum_length as "maxLength",
c.numeric_precision as "numericPrecision", c.numeric_precision as "numericPrecision",
c.numeric_scale as "numericScale", c.numeric_scale as "numericScale",
COALESCE(ttc.code_category, cl.code_category) as "codeCategory", cl.code_category as "codeCategory",
COALESCE(ttc.code_value, cl.code_value) as "codeValue", cl.code_value as "codeValue",
COALESCE(ttc.reference_table, cl.reference_table) as "referenceTable", cl.reference_table as "referenceTable",
COALESCE(ttc.reference_column, cl.reference_column) as "referenceColumn", cl.reference_column as "referenceColumn",
COALESCE(ttc.display_column, cl.display_column) as "displayColumn", cl.display_column as "displayColumn",
COALESCE(ttc.display_order, cl.display_order) as "displayOrder", cl.display_order as "displayOrder",
COALESCE(ttc.is_visible, cl.is_visible) as "isVisible", cl.is_visible as "isVisible",
dcl.column_label as "displayColumnLabel" dcl.column_label as "displayColumnLabel"
FROM information_schema.columns c FROM information_schema.columns c
LEFT JOIN table_type_columns cl ON c.table_name = cl.table_name AND c.column_name = cl.column_name AND cl.company_code = '*' LEFT JOIN column_labels cl ON c.table_name = cl.table_name AND c.column_name = cl.column_name
LEFT JOIN table_type_columns ttc ON c.table_name = ttc.table_name AND c.column_name = ttc.column_name AND ttc.company_code = $4 LEFT JOIN table_type_columns ttc ON c.table_name = ttc.table_name AND c.column_name = ttc.column_name AND ttc.company_code = $4
LEFT JOIN table_type_columns dcl ON COALESCE(ttc.reference_table, cl.reference_table) = dcl.table_name AND COALESCE(ttc.display_column, cl.display_column) = dcl.column_name AND dcl.company_code = '*' LEFT JOIN column_labels dcl ON cl.reference_table = dcl.table_name AND cl.display_column = dcl.column_name
LEFT JOIN ( LEFT JOIN (
SELECT kcu.column_name, kcu.table_name SELECT kcu.column_name, kcu.table_name
FROM information_schema.table_constraints tc FROM information_schema.table_constraints tc
@ -239,7 +237,7 @@ export class TableManagementService {
c.data_type as "dbType", c.data_type as "dbType",
COALESCE(cl.input_type, 'text') as "webType", COALESCE(cl.input_type, 'text') as "webType",
COALESCE(cl.input_type, 'direct') as "inputType", COALESCE(cl.input_type, 'direct') as "inputType",
COALESCE(cl.detail_settings::text, '') as "detailSettings", COALESCE(cl.detail_settings, '') as "detailSettings",
COALESCE(cl.description, '') as "description", COALESCE(cl.description, '') as "description",
c.is_nullable as "isNullable", c.is_nullable as "isNullable",
CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END as "isPrimaryKey", CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END as "isPrimaryKey",
@ -256,8 +254,8 @@ export class TableManagementService {
cl.is_visible as "isVisible", cl.is_visible as "isVisible",
dcl.column_label as "displayColumnLabel" dcl.column_label as "displayColumnLabel"
FROM information_schema.columns c FROM information_schema.columns c
LEFT JOIN table_type_columns cl ON c.table_name = cl.table_name AND c.column_name = cl.column_name AND cl.company_code = '*' LEFT JOIN column_labels cl ON c.table_name = cl.table_name AND c.column_name = cl.column_name
LEFT JOIN table_type_columns dcl ON cl.reference_table = dcl.table_name AND cl.display_column = dcl.column_name AND dcl.company_code = '*' LEFT JOIN column_labels dcl ON cl.reference_table = dcl.table_name AND cl.display_column = dcl.column_name
LEFT JOIN ( LEFT JOIN (
SELECT kcu.column_name, kcu.table_name SELECT kcu.column_name, kcu.table_name
FROM information_schema.table_constraints tc FROM information_schema.table_constraints tc
@ -334,7 +332,7 @@ export class TableManagementService {
? Number(column.displayOrder) ? Number(column.displayOrder)
: null, : null,
// webType은 사용자가 명시적으로 설정한 값을 그대로 사용 // webType은 사용자가 명시적으로 설정한 값을 그대로 사용
// (자동 추론은 table_type_columns에 없는 경우에만 SQL 쿼리의 COALESCE에서 처리됨) // (자동 추론은 column_labels에 없는 경우에만 SQL 쿼리의 COALESCE에서 처리됨)
webType: column.webType, webType: column.webType,
}; };
@ -459,39 +457,32 @@ export class TableManagementService {
// 테이블이 table_labels에 없으면 자동 추가 // 테이블이 table_labels에 없으면 자동 추가
await this.insertTableIfNotExists(tableName); await this.insertTableIfNotExists(tableName);
// table_type_columns에 모든 설정 저장 (멀티테넌시 지원) // column_labels 업데이트 또는 생성
// detailSettings가 문자열이면 그대로, 객체면 JSON.stringify
let detailSettingsStr = settings.detailSettings;
if (typeof settings.detailSettings === "object" && settings.detailSettings !== null) {
detailSettingsStr = JSON.stringify(settings.detailSettings);
}
await query( await query(
`INSERT INTO table_type_columns ( `INSERT INTO column_labels (
table_name, column_name, column_label, input_type, detail_settings, table_name, column_name, column_label, input_type, detail_settings,
code_category, code_value, reference_table, reference_column, code_category, code_value, reference_table, reference_column,
display_column, display_order, is_visible, is_nullable, display_column, display_order, is_visible, created_date, updated_date
company_code, created_date, updated_date ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, NOW(), NOW())
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, 'Y', $13, NOW(), NOW()) ON CONFLICT (table_name, column_name)
ON CONFLICT (table_name, column_name, company_code)
DO UPDATE SET DO UPDATE SET
column_label = COALESCE(EXCLUDED.column_label, table_type_columns.column_label), column_label = EXCLUDED.column_label,
input_type = COALESCE(EXCLUDED.input_type, table_type_columns.input_type), input_type = EXCLUDED.input_type,
detail_settings = COALESCE(EXCLUDED.detail_settings, table_type_columns.detail_settings), detail_settings = EXCLUDED.detail_settings,
code_category = COALESCE(EXCLUDED.code_category, table_type_columns.code_category), code_category = EXCLUDED.code_category,
code_value = COALESCE(EXCLUDED.code_value, table_type_columns.code_value), code_value = EXCLUDED.code_value,
reference_table = COALESCE(EXCLUDED.reference_table, table_type_columns.reference_table), reference_table = EXCLUDED.reference_table,
reference_column = COALESCE(EXCLUDED.reference_column, table_type_columns.reference_column), reference_column = EXCLUDED.reference_column,
display_column = COALESCE(EXCLUDED.display_column, table_type_columns.display_column), display_column = EXCLUDED.display_column,
display_order = COALESCE(EXCLUDED.display_order, table_type_columns.display_order), display_order = EXCLUDED.display_order,
is_visible = COALESCE(EXCLUDED.is_visible, table_type_columns.is_visible), is_visible = EXCLUDED.is_visible,
updated_date = NOW()`, updated_date = NOW()`,
[ [
tableName, tableName,
columnName, columnName,
settings.columnLabel, settings.columnLabel,
settings.inputType, settings.inputType,
detailSettingsStr, settings.detailSettings,
settings.codeCategory, settings.codeCategory,
settings.codeValue, settings.codeValue,
settings.referenceTable, settings.referenceTable,
@ -499,17 +490,36 @@ export class TableManagementService {
settings.displayColumn, settings.displayColumn,
settings.displayOrder || 0, settings.displayOrder || 0,
settings.isVisible !== undefined ? settings.isVisible : true, settings.isVisible !== undefined ? settings.isVisible : true,
companyCode,
] ]
); );
// 🔥 화면 레이아웃 동기화 (입력 타입 변경 시) // 🔥 table_type_columns도 업데이트 (멀티테넌시 지원)
if (settings.inputType) { if (settings.inputType) {
await this.syncScreenLayoutsInputType( // detailSettings가 문자열이면 파싱, 객체면 그대로 사용
let parsedDetailSettings: Record<string, any> | undefined = undefined;
if (settings.detailSettings) {
if (typeof settings.detailSettings === "string") {
try {
parsedDetailSettings = JSON.parse(settings.detailSettings);
} catch (e) {
logger.warn(
`detailSettings 파싱 실패, 그대로 사용: ${settings.detailSettings}`
);
}
} else if (typeof settings.detailSettings === "object") {
parsedDetailSettings = settings.detailSettings as Record<
string,
any
>;
}
}
await this.updateColumnInputType(
tableName, tableName,
columnName, columnName,
settings.inputType as string, settings.inputType as string,
companyCode companyCode,
parsedDetailSettings
); );
} }
@ -657,8 +667,8 @@ export class TableManagementService {
`SELECT id, table_name, column_name, column_label, input_type, detail_settings, `SELECT id, table_name, column_name, column_label, input_type, detail_settings,
description, display_order, is_visible, code_category, code_value, description, display_order, is_visible, code_category, code_value,
reference_table, reference_column, created_date, updated_date reference_table, reference_column, created_date, updated_date
FROM table_type_columns FROM column_labels
WHERE table_name = $1 AND column_name = $2 AND company_code = '*'`, WHERE table_name = $1 AND column_name = $2`,
[tableName, columnName] [tableName, columnName]
); );
@ -721,13 +731,12 @@ export class TableManagementService {
...detailSettings, ...detailSettings,
}; };
// table_type_columns UPSERT로 업데이트 또는 생성 (company_code = '*' 공통 설정) // column_labels UPSERT로 업데이트 또는 생성 (input_type만 사용)
await query( await query(
`INSERT INTO table_type_columns ( `INSERT INTO column_labels (
table_name, column_name, input_type, detail_settings, is_nullable, table_name, column_name, input_type, detail_settings, created_date, updated_date
company_code, created_date, updated_date ) VALUES ($1, $2, $3, $4, NOW(), NOW())
) VALUES ($1, $2, $3, $4, 'Y', '*', NOW(), NOW()) ON CONFLICT (table_name, column_name)
ON CONFLICT (table_name, column_name, company_code)
DO UPDATE SET DO UPDATE SET
input_type = EXCLUDED.input_type, input_type = EXCLUDED.input_type,
detail_settings = EXCLUDED.detail_settings, detail_settings = EXCLUDED.detail_settings,
@ -1276,8 +1285,8 @@ export class TableManagementService {
try { try {
const fileColumns = await query<{ column_name: string }>( const fileColumns = await query<{ column_name: string }>(
`SELECT column_name `SELECT column_name
FROM table_type_columns FROM column_labels
WHERE table_name = $1 AND input_type = 'file' AND company_code = '*'`, WHERE table_name = $1 AND web_type = 'file'`,
[tableName] [tableName]
); );
@ -1456,31 +1465,6 @@ export class TableManagementService {
const webType = columnInfo.webType; const webType = columnInfo.webType;
// 🔧 다중선택 처리: actualValue가 파이프(|)를 포함하고 날짜 타입이 아닌 경우
if (
typeof actualValue === "string" &&
actualValue.includes("|") &&
webType !== "date" &&
webType !== "datetime"
) {
const multiValues = actualValue
.split("|")
.filter((v: string) => v.trim() !== "");
if (multiValues.length > 0) {
const placeholders = multiValues
.map((_: string, idx: number) => `$${paramIndex + idx}`)
.join(", ");
logger.info(
`🔍 다중선택 필터 적용 (객체): ${columnName} IN (${multiValues.join(", ")})`
);
return {
whereClause: `${columnName}::text IN (${placeholders})`,
values: multiValues,
paramCount: multiValues.length,
};
}
}
// 웹타입별 검색 조건 구성 // 웹타입별 검색 조건 구성
switch (webType) { switch (webType) {
case "date": case "date":
@ -1961,15 +1945,16 @@ export class TableManagementService {
} | null> { } | null> {
try { try {
const result = await queryOne<{ const result = await queryOne<{
web_type: string | null;
input_type: string | null; input_type: string | null;
code_category: string | null; code_category: string | null;
reference_table: string | null; reference_table: string | null;
reference_column: string | null; reference_column: string | null;
display_column: string | null; display_column: string | null;
}>( }>(
`SELECT input_type, code_category, reference_table, reference_column, display_column `SELECT web_type, input_type, code_category, reference_table, reference_column, display_column
FROM table_type_columns FROM column_labels
WHERE table_name = $1 AND column_name = $2 AND company_code = '*' WHERE table_name = $1 AND column_name = $2
LIMIT 1`, LIMIT 1`,
[tableName, columnName] [tableName, columnName]
); );
@ -1978,6 +1963,7 @@ export class TableManagementService {
`🔍 [getColumnWebTypeInfo] ${tableName}.${columnName} 조회 결과:`, `🔍 [getColumnWebTypeInfo] ${tableName}.${columnName} 조회 결과:`,
{ {
found: !!result, found: !!result,
web_type: result?.web_type,
input_type: result?.input_type, input_type: result?.input_type,
} }
); );
@ -1989,8 +1975,11 @@ export class TableManagementService {
return null; return null;
} }
// web_type이 없으면 input_type을 사용 (레거시 호환)
const webType = result.web_type || result.input_type || "";
const columnInfo = { const columnInfo = {
webType: result.input_type || "", webType: webType,
inputType: result.input_type || "", inputType: result.input_type || "",
codeCategory: result.code_category || undefined, codeCategory: result.code_category || undefined,
referenceTable: result.reference_table || undefined, referenceTable: result.reference_table || undefined,
@ -3587,7 +3576,7 @@ export class TableManagementService {
continue; continue;
} }
// 🔍 table_type_columns에서 해당 엔티티 설정 찾기 // 🔍 column_labels에서 해당 엔티티 설정 찾기
// 예: item_info 테이블을 참조하는 컬럼 찾기 (item_code → item_info) // 예: item_info 테이블을 참조하는 컬럼 찾기 (item_code → item_info)
const entityColumnResult = await query<{ const entityColumnResult = await query<{
column_name: string; column_name: string;
@ -3595,11 +3584,10 @@ export class TableManagementService {
reference_column: string; reference_column: string;
}>( }>(
`SELECT column_name, reference_table, reference_column `SELECT column_name, reference_table, reference_column
FROM table_type_columns FROM column_labels
WHERE table_name = $1 WHERE table_name = $1
AND input_type = 'entity' AND input_type = 'entity'
AND reference_table = $2 AND reference_table = $2
AND company_code = '*'
LIMIT 1`, LIMIT 1`,
[tableName, refTable] [tableName, refTable]
); );
@ -3732,23 +3720,23 @@ export class TableManagementService {
logger.info(`컬럼 라벨 업데이트: ${tableName}.${columnName}`); logger.info(`컬럼 라벨 업데이트: ${tableName}.${columnName}`);
await query( await query(
`INSERT INTO table_type_columns ( `INSERT INTO column_labels (
table_name, column_name, column_label, input_type, detail_settings, table_name, column_name, column_label, web_type, detail_settings,
description, display_order, is_visible, code_category, code_value, description, display_order, is_visible, code_category, code_value,
reference_table, reference_column, is_nullable, company_code, created_date, updated_date reference_table, reference_column, created_date, updated_date
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, 'Y', '*', NOW(), NOW()) ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, NOW(), NOW())
ON CONFLICT (table_name, column_name, company_code) ON CONFLICT (table_name, column_name)
DO UPDATE SET DO UPDATE SET
column_label = COALESCE(EXCLUDED.column_label, table_type_columns.column_label), column_label = EXCLUDED.column_label,
input_type = COALESCE(EXCLUDED.input_type, table_type_columns.input_type), web_type = EXCLUDED.web_type,
detail_settings = COALESCE(EXCLUDED.detail_settings, table_type_columns.detail_settings), detail_settings = EXCLUDED.detail_settings,
description = COALESCE(EXCLUDED.description, table_type_columns.description), description = EXCLUDED.description,
display_order = COALESCE(EXCLUDED.display_order, table_type_columns.display_order), display_order = EXCLUDED.display_order,
is_visible = COALESCE(EXCLUDED.is_visible, table_type_columns.is_visible), is_visible = EXCLUDED.is_visible,
code_category = COALESCE(EXCLUDED.code_category, table_type_columns.code_category), code_category = EXCLUDED.code_category,
code_value = COALESCE(EXCLUDED.code_value, table_type_columns.code_value), code_value = EXCLUDED.code_value,
reference_table = COALESCE(EXCLUDED.reference_table, table_type_columns.reference_table), reference_table = EXCLUDED.reference_table,
reference_column = COALESCE(EXCLUDED.reference_column, table_type_columns.reference_column), reference_column = EXCLUDED.reference_column,
updated_date = NOW()`, updated_date = NOW()`,
[ [
tableName, tableName,
@ -4127,7 +4115,7 @@ export class TableManagementService {
const rawInputTypes = await query<any>( const rawInputTypes = await query<any>(
`SELECT DISTINCT ON (ttc.column_name) `SELECT DISTINCT ON (ttc.column_name)
ttc.column_name as "columnName", ttc.column_name as "columnName",
COALESCE(ttc.column_label, ttc.column_name) as "displayName", COALESCE(cl.column_label, ttc.column_name) as "displayName",
ttc.input_type as "inputType", ttc.input_type as "inputType",
CASE CASE
WHEN ttc.detail_settings IS NULL OR ttc.detail_settings = '' THEN '{}'::jsonb WHEN ttc.detail_settings IS NULL OR ttc.detail_settings = '' THEN '{}'::jsonb
@ -4138,6 +4126,8 @@ export class TableManagementService {
ic.data_type as "dataType", ic.data_type as "dataType",
ttc.company_code as "companyCode" ttc.company_code as "companyCode"
FROM table_type_columns ttc FROM table_type_columns ttc
LEFT JOIN column_labels cl
ON ttc.table_name = cl.table_name AND ttc.column_name = cl.column_name
LEFT JOIN information_schema.columns ic LEFT JOIN information_schema.columns ic
ON ttc.table_name = ic.table_name AND ttc.column_name = ic.column_name ON ttc.table_name = ic.table_name AND ttc.column_name = ic.column_name
WHERE ttc.table_name = $1 WHERE ttc.table_name = $1
@ -4777,7 +4767,7 @@ export class TableManagementService {
/** /**
* *
* table_type_columns에서 . * column_labels에서 .
* *
* @param leftTable * @param leftTable
* @param rightTable * @param rightTable
@ -4817,13 +4807,12 @@ export class TableManagementService {
display_column: string | null; display_column: string | null;
}>( }>(
`SELECT column_name, reference_column, input_type, display_column `SELECT column_name, reference_column, input_type, display_column
FROM table_type_columns FROM column_labels
WHERE table_name = $1 WHERE table_name = $1
AND input_type IN ('entity', 'category') AND input_type IN ('entity', 'category')
AND reference_table = $2 AND reference_table = $2
AND reference_column IS NOT NULL AND reference_column IS NOT NULL
AND reference_column != '' AND reference_column != ''`,
AND company_code = '*'`,
[rightTable, leftTable] [rightTable, leftTable]
); );
@ -4846,13 +4835,12 @@ export class TableManagementService {
display_column: string | null; display_column: string | null;
}>( }>(
`SELECT column_name, reference_column, input_type, display_column `SELECT column_name, reference_column, input_type, display_column
FROM table_type_columns FROM column_labels
WHERE table_name = $1 WHERE table_name = $1
AND input_type IN ('entity', 'category') AND input_type IN ('entity', 'category')
AND reference_table = $2 AND reference_table = $2
AND reference_column IS NOT NULL AND reference_column IS NOT NULL
AND reference_column != '' AND reference_column != ''`,
AND company_code = '*'`,
[leftTable, rightTable] [leftTable, rightTable]
); );

View File

@ -1,263 +0,0 @@
/**
*
*
* screen_layouts_v2 config_overrides를
* componentConfig를 .
*/
// 컴포넌트별 기본값 맵
export const componentDefaults: Record<string, any> = {
"button-primary": {
type: "button-primary",
text: "저장",
actionType: "button",
variant: "primary",
webType: "button",
},
"v2-button-primary": {
type: "v2-button-primary",
text: "저장",
actionType: "button",
variant: "primary",
webType: "button",
},
"text-input": {
type: "text-input",
webType: "text",
format: "none",
multiline: false,
placeholder: "텍스트를 입력하세요",
},
"number-input": {
type: "number-input",
webType: "number",
placeholder: "숫자를 입력하세요",
},
"date-input": {
type: "date-input",
webType: "date",
format: "YYYY-MM-DD",
showTime: false,
placeholder: "날짜를 선택하세요",
},
"select-basic": {
type: "select-basic",
webType: "code",
placeholder: "선택하세요",
options: [],
},
"file-upload": {
type: "file-upload",
webType: "file",
placeholder: "입력하세요",
},
"table-list": {
type: "table-list",
webType: "table",
displayMode: "table",
showHeader: true,
showFooter: true,
autoLoad: true,
autoWidth: true,
stickyHeader: false,
height: "auto",
columns: [],
pagination: {
enabled: true,
pageSize: 20,
showSizeSelector: true,
showPageInfo: true,
pageSizeOptions: [10, 20, 50, 100],
},
checkbox: {
enabled: true,
multiple: true,
position: "left",
selectAll: true,
},
horizontalScroll: {
enabled: false,
},
filter: {
enabled: false,
filters: [],
},
actions: {
showActions: false,
actions: [],
bulkActions: false,
bulkActionList: [],
},
tableStyle: {
theme: "default",
headerStyle: "default",
rowHeight: "normal",
alternateRows: false,
hoverEffect: true,
borderStyle: "light",
},
},
"v2-table-list": {
type: "v2-table-list",
webType: "table",
displayMode: "table",
showHeader: true,
showFooter: true,
autoLoad: true,
autoWidth: true,
stickyHeader: false,
height: "auto",
columns: [],
pagination: {
enabled: true,
pageSize: 20,
showSizeSelector: true,
showPageInfo: true,
pageSizeOptions: [10, 20, 50, 100],
},
checkbox: {
enabled: true,
multiple: true,
position: "left",
selectAll: true,
},
horizontalScroll: { enabled: false },
filter: { enabled: false, filters: [] },
actions: { showActions: false, actions: [], bulkActions: false, bulkActionList: [] },
tableStyle: { theme: "default", headerStyle: "default", rowHeight: "normal", alternateRows: false, hoverEffect: true, borderStyle: "light" },
},
"table-search-widget": { type: "table-search-widget", webType: "custom" },
"split-panel-layout": { type: "split-panel-layout", webType: "text", autoLoad: true, resizable: true, splitRatio: 30 },
"v2-split-panel-layout": { type: "v2-split-panel-layout", webType: "custom" },
"tabs-widget": { type: "tabs-widget", webType: "text", tabs: [] },
"v2-tabs-widget": { type: "v2-tabs-widget", webType: "custom", tabs: [] },
"flow-widget": { type: "flow-widget", webType: "text", displayMode: "horizontal", allowDataMove: false, showStepCount: true },
"entity-search-input": { type: "entity-search-input", webType: "entity" },
"autocomplete-search-input": { type: "autocomplete-search-input", webType: "entity" },
"unified-list": { type: "unified-list", webType: "table" },
"modal-repeater-table": { type: "modal-repeater-table", webType: "table", columns: [], multiSelect: true },
"category-manager": { type: "category-manager", webType: "custom" },
"numbering-rule": { type: "numbering-rule", webType: "text" },
"conditional-container": { type: "conditional-container", webType: "custom" },
"selected-items-detail-input": { type: "selected-items-detail-input", webType: "custom" },
"text-display": { type: "text-display", webType: "text" },
"image-widget": { type: "image-widget", webType: "image" },
"textarea-basic": { type: "textarea-basic", webType: "textarea", placeholder: "내용을 입력하세요" },
"checkbox-basic": { type: "checkbox-basic", webType: "checkbox" },
"radio-basic": { type: "radio-basic", webType: "radio" },
"divider-line": { type: "divider-line", webType: "custom" },
"section-paper": { type: "section-paper", webType: "custom" },
"section-card": { type: "section-card", webType: "custom" },
"card-display": { type: "card-display", webType: "custom" },
"pivot-grid": { type: "pivot-grid", webType: "table" },
"rack-structure": { type: "rack-structure", webType: "custom" },
"v2-rack-structure": { type: "v2-rack-structure", webType: "custom" },
"location-swap-selector": { type: "location-swap-selector", webType: "custom" },
"screen-split-panel": { type: "screen-split-panel", webType: "custom" },
"universal-form-modal": { type: "universal-form-modal", webType: "custom" },
"repeater-field-group": { type: "repeater-field-group", webType: "custom" },
"repeat-screen-modal": { type: "repeat-screen-modal", webType: "custom" },
"related-data-buttons": { type: "related-data-buttons", webType: "custom" },
"split-panel-layout2": { type: "split-panel-layout2", webType: "custom" },
"unified-input": { type: "unified-input", webType: "text" },
"unified-select": { type: "unified-select", webType: "select" },
"unified-date": { type: "unified-date", webType: "date" },
"unified-repeater": { type: "unified-repeater", webType: "custom" },
"v2-repeat-container": { type: "v2-repeat-container", webType: "custom" },
};
/**
*
*/
export function getComponentDefaults(componentType: string): any {
return componentDefaults[componentType] || {};
}
/**
* 복원: 기본값 + overrides
*
* @param componentType
* @param overrides (config_overrides)
* @returns
*/
export function reconstructConfig(componentType: string, overrides: any): any {
const defaults = getComponentDefaults(componentType);
if (!overrides || Object.keys(overrides).length === 0) {
return { ...defaults };
}
// _originalKeys가 있으면 해당 키만 복원
const originalKeys = overrides._originalKeys;
if (originalKeys && Array.isArray(originalKeys)) {
const result: any = {};
for (const key of originalKeys) {
if (key === "_originalKeys") continue;
if (Object.prototype.hasOwnProperty.call(overrides, key)) {
result[key] = overrides[key];
} else if (Object.prototype.hasOwnProperty.call(defaults, key)) {
result[key] = defaults[key];
}
}
return result;
}
// _originalKeys가 없으면 단순 병합
return { ...defaults, ...overrides };
}
/**
*
*/
export function isDeepEqual(a: any, b: any): boolean {
if (a === b) return true;
if (a == null || b == null) return a === b;
if (typeof a !== typeof b) return false;
if (typeof a !== "object") return a === b;
if (Array.isArray(a) !== Array.isArray(b)) return false;
if (Array.isArray(a)) {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (!isDeepEqual(a[i], b[i])) return false;
}
return true;
}
const keysA = Object.keys(a);
const keysB = Object.keys(b);
if (keysA.length !== keysB.length) return false;
for (const key of keysA) {
if (!keysB.includes(key)) return false;
if (!isDeepEqual(a[key], b[key])) return false;
}
return true;
}
/**
* 추출: 현재
*/
export function extractConfigDiff(componentType: string, currentConfig: any): any {
const defaults = getComponentDefaults(componentType);
if (!currentConfig) return {};
const diff: any = {
_originalKeys: Object.keys(currentConfig),
};
for (const key of Object.keys(currentConfig)) {
const defaultVal = defaults[key];
const currentVal = currentConfig[key];
if (!isDeepEqual(defaultVal, currentVal)) {
diff[key] = currentVal;
}
}
return diff;
}

View File

@ -1,685 +0,0 @@
# CategoryTreeController 로직 분석 보고서
> 분석일: 2026-01-26 | 대상 파일: `backend-node/src/controllers/categoryTreeController.ts`
> 검증일: 2026-01-26 | TypeScript 컴파일 검증 완료
---
## 0. 검증 결과 요약
### TypeScript 컴파일 에러 (실제 확인됨)
```bash
$ tsc --noEmit src/controllers/categoryTreeController.ts
src/controllers/categoryTreeController.ts(139,15): error TS2339: Property 'targetCompanyCode' does not exist on type 'CreateCategoryValueInput'.
src/controllers/categoryTreeController.ts(140,27): error TS2339: Property 'targetCompanyCode' does not exist on type 'CreateCategoryValueInput'.
src/controllers/categoryTreeController.ts(143,34): error TS2339: Property 'targetCompanyCode' does not exist on type 'CreateCategoryValueInput'.
```
**결론**: `targetCompanyCode` 타입 정의 누락 문제가 **실제로 존재함**
---
## 1. 시스템 개요
### 1.1 아키텍처 다이어그램
```mermaid
flowchart TB
subgraph Frontend["프론트엔드"]
UI[카테고리 관리 UI]
end
subgraph Backend["백엔드"]
subgraph Controllers["컨트롤러"]
CTC[categoryTreeController.ts]
end
subgraph Services["서비스"]
CTS[categoryTreeService.ts]
TCVS[tableCategoryValueService.ts]
end
subgraph Database["데이터베이스"]
CVT[(category_values_test)]
TCCV[(table_column_category_values)]
TTC[(table_type_columns)]
end
end
UI --> |"/api/category-tree/*"| CTC
CTC --> CTS
CTS --> CVT
TCVS --> TCCV
TCVS --> TTC
style CTC fill:#ff6b6b,stroke:#c92a2a
style CVT fill:#4ecdc4,stroke:#087f5b
style TCCV fill:#4ecdc4,stroke:#087f5b
```
### 1.2 관련 파일 목록
| 파일 | 역할 | 사용 테이블 |
|------|------|-------------|
| `categoryTreeController.ts` | 카테고리 트리 API 라우트 | - |
| `categoryTreeService.ts` | 카테고리 트리 비즈니스 로직 | `category_values_test` |
| `tableCategoryValueService.ts` | 테이블별 카테고리 값 관리 | `table_column_category_values` |
| `categoryTreeRoutes.ts` | 라우트 re-export | - |
---
## 2. 발견된 문제점 요약
```mermaid
pie title 문제점 심각도 분류
"🔴 Critical (즉시 수정)" : 3
"🟠 Major (수정 권장)" : 2
"🟡 Minor (검토 필요)" : 2
```
| 심각도 | 문제 | 영향도 | 검증 |
|--------|------|--------|------|
| 🔴 Critical | 라우트 순서 충돌 | GET 라우트 2개 호출 불가 | 이론적 분석 |
| 🔴 Critical | 타입 정의 불일치 | TypeScript 컴파일 에러 | ✅ tsc 검증됨 |
| 🔴 Critical | 멀티테넌시 규칙 위반 | **보안 문제** - 데이터 노출 | .cursorrules 규칙 확인 |
| 🟠 Major | 하위 항목 삭제 미구현 | 데이터 정합성 | 주석 vs 구현 비교 |
| 🟠 Major | 카테고리 시스템 이원화 | 유지보수 복잡도 | 코드 분석 |
| 🟡 Minor | 인덱스 비효율 쿼리 | 성능 저하 | 쿼리 패턴 분석 |
| 🟡 Minor | PUT/DELETE 오버라이드 누락 | 기능 제한 | 의도적 설계 가능 |
---
## 3. 🔴 Critical: 라우트 순서 충돌
### 3.1 문제 설명
Express 라우터는 **정의 순서대로** 매칭합니다. 현재 라우트 순서에서 일부 GET 라우트가 절대 호출되지 않습니다.
### 3.2 현재 라우트 순서 (문제)
```mermaid
flowchart LR
subgraph Order["현재 정의 순서"]
R1["Line 24<br/>GET /test/all-category-keys"]
R2["Line 48<br/>GET /test/:tableName/:columnName<br/>⚠️ 너무 일찍 정의"]
R3["Line 73<br/>GET /test/:tableName/:columnName/flat"]
R4["Line 98<br/>GET /test/value/:valueId<br/>❌ 가려짐"]
R5["Line 130<br/>POST /test/value"]
R6["Line 174<br/>PUT /test/value/:valueId"]
R7["Line 208<br/>DELETE /test/value/:valueId"]
R8["Line 240<br/>GET /test/columns/:tableName<br/>❌ 가려짐"]
end
R1 --> R2 --> R3 --> R4 --> R5 --> R6 --> R7 --> R8
style R2 fill:#fff3bf,stroke:#f59f00
style R4 fill:#ffe3e3,stroke:#c92a2a
style R8 fill:#ffe3e3,stroke:#c92a2a
```
### 3.3 요청 매칭 시뮬레이션
```mermaid
sequenceDiagram
participant Client as 클라이언트
participant Express as Express Router
participant R2 as Line 48<br/>/:tableName/:columnName
participant R4 as Line 98<br/>/value/:valueId
participant R8 as Line 240<br/>/columns/:tableName
Note over Client,Express: 요청: GET /test/value/123
Client->>Express: GET /test/value/123
Express->>R2: 패턴 매칭 시도
Note over R2: tableName="value"<br/>columnName="123"<br/>✅ 매칭됨!
R2-->>Express: 처리 완료
Note over R4: ❌ 검사되지 않음
Note over Client,Express: 요청: GET /test/columns/users
Client->>Express: GET /test/columns/users
Express->>R2: 패턴 매칭 시도
Note over R2: tableName="columns"<br/>columnName="users"<br/>✅ 매칭됨!
R2-->>Express: 처리 완료
Note over R8: ❌ 검사되지 않음
```
### 3.4 영향받는 라우트
| 라인 | 경로 | HTTP | 상태 | 원인 |
|------|------|------|------|------|
| 98 | `/test/value/:valueId` | GET | ❌ 호출 불가 | Line 48에 의해 가려짐 |
| 240 | `/test/columns/:tableName` | GET | ❌ 호출 불가 | Line 48에 의해 가려짐 |
### 3.5 PUT/DELETE는 왜 문제없는가?
```mermaid
flowchart TB
subgraph Methods["HTTP 메서드별 라우트 분리"]
subgraph GET["GET 메서드"]
G1["Line 24: /test/all-category-keys"]
G2["Line 48: /test/:tableName/:columnName ⚠️"]
G3["Line 73: /test/:tableName/:columnName/flat"]
G4["Line 98: /test/value/:valueId ❌"]
G5["Line 240: /test/columns/:tableName ❌"]
end
subgraph POST["POST 메서드"]
P1["Line 130: /test/value"]
end
subgraph PUT["PUT 메서드"]
U1["Line 174: /test/value/:valueId ✅"]
end
subgraph DELETE["DELETE 메서드"]
D1["Line 208: /test/value/:valueId ✅"]
end
end
Note1[Express는 같은 HTTP 메서드 내에서만<br/>순서대로 매칭함]
style G2 fill:#fff3bf
style G4 fill:#ffe3e3
style G5 fill:#ffe3e3
style U1 fill:#d3f9d8
style D1 fill:#d3f9d8
```
**결론**: PUT `/test/value/:valueId`와 DELETE `/test/value/:valueId`는 GET 라우트와 **HTTP 메서드가 다르므로** 충돌하지 않습니다.
### 3.6 수정 방안
```typescript
// ✅ 올바른 순서 (더 구체적인 경로 먼저)
// 1. 리터럴 경로 (가장 먼저)
router.get("/test/all-category-keys", ...);
// 2. 부분 리터럴 경로 (리터럴 + 파라미터)
router.get("/test/value/:valueId", ...); // "value"가 고정
router.get("/test/columns/:tableName", ...); // "columns"가 고정
// 3. 더 긴 동적 경로
router.get("/test/:tableName/:columnName/flat", ...); // 4세그먼트
// 4. 가장 일반적인 동적 경로 (마지막에)
router.get("/test/:tableName/:columnName", ...); // 3세그먼트
```
---
## 4. 🔴 Critical: 타입 정의 불일치
### 4.1 문제 설명
컨트롤러에서 `input.targetCompanyCode`를 사용하지만, 인터페이스에 해당 필드가 없습니다.
### 4.2 코드 비교
```mermaid
flowchart LR
subgraph Interface["CreateCategoryValueInput 인터페이스"]
I1[tableName: string]
I2[columnName: string]
I3[valueCode: string]
I4[valueLabel: string]
I5[valueOrder?: number]
I6[parentValueId?: number]
I7[description?: string]
I8[color?: string]
I9[icon?: string]
I10[isActive?: boolean]
I11[isDefault?: boolean]
Missing["❌ targetCompanyCode 없음"]
end
subgraph Controller["컨트롤러 (Line 139)"]
C1["input.targetCompanyCode 사용"]
end
Controller -.-> |"타입 불일치"| Missing
style Missing fill:#ffe3e3,stroke:#c92a2a
```
### 4.3 문제 코드
**인터페이스 정의 (`categoryTreeService.ts` Line 34-46):**
```typescript
export interface CreateCategoryValueInput {
tableName: string;
columnName: string;
valueCode: string;
valueLabel: string;
valueOrder?: number;
parentValueId?: number | null;
description?: string;
color?: string;
icon?: string;
isActive?: boolean;
isDefault?: boolean;
// ❌ targetCompanyCode 필드 없음!
}
```
**컨트롤러 사용 (`categoryTreeController.ts` Line 136-145):**
```typescript
// 🔧 최고 관리자가 특정 회사를 선택한 경우, targetCompanyCode 우선 사용
let companyCode = userCompanyCode;
if (input.targetCompanyCode && userCompanyCode === "*") { // ⚠️ 타입 에러 가능
companyCode = input.targetCompanyCode;
logger.info("🔓 최고 관리자 회사 코드 오버라이드", {
originalCompanyCode: userCompanyCode,
targetCompanyCode: input.targetCompanyCode,
});
}
```
### 4.4 영향
1. TypeScript 컴파일 시 에러 또는 경고 발생 가능
2. 런타임에 `input.targetCompanyCode`가 항상 `undefined`
3. 최고 관리자의 회사 오버라이드 기능이 작동하지 않음
### 4.5 수정 방안
```typescript
// categoryTreeService.ts - 인터페이스 수정
export interface CreateCategoryValueInput {
tableName: string;
columnName: string;
valueCode: string;
valueLabel: string;
valueOrder?: number;
parentValueId?: number | null;
description?: string;
color?: string;
icon?: string;
isActive?: boolean;
isDefault?: boolean;
targetCompanyCode?: string; // ✅ 추가
}
```
---
## 5. 🔴 Critical: 멀티테넌시 규칙 위반 (심각도 상향)
### 5.1 규칙 위반 설명
`.cursorrules` 파일에 명시된 프로젝트 규칙:
> **중요**: `company_code = "*"`는 **최고 관리자 전용 데이터**를 의미합니다.
> - ❌ 잘못된 이해: `company_code = "*"` = 모든 회사가 공유하는 공통 데이터
> - ✅ 올바른 이해: `company_code = "*"` = 최고 관리자만 관리하는 전용 데이터
>
> **핵심**: 일반 회사 사용자는 `company_code = "*"` 데이터를 볼 수 없습니다!
**현재 상태**: 서비스 코드에서 일반 회사도 `company_code = '*'` 데이터를 조회할 수 있음 → **보안 위반**
### 5.2 문제 쿼리 패턴
```mermaid
flowchart TB
subgraph Current["현재 구현 (문제)"]
Q1["WHERE (company_code = $1 OR company_code = '*')"]
subgraph Result1["일반 회사 'COMPANY_A' 조회 시"]
R1A["✅ COMPANY_A 데이터"]
R1B["⚠️ * 데이터도 조회됨 (규칙 위반)"]
end
end
subgraph Expected["올바른 구현"]
Q2["if (companyCode === '*')<br/> 전체 조회<br/>else<br/> WHERE company_code = $1"]
subgraph Result2["일반 회사 'COMPANY_A' 조회 시"]
R2A["✅ COMPANY_A 데이터만"]
end
end
style R1B fill:#ffe3e3,stroke:#c92a2a
style R2A fill:#d3f9d8,stroke:#087f5b
```
### 5.3 영향받는 함수 목록
| 서비스 | 함수 | 라인 | 문제 쿼리 |
|--------|------|------|-----------|
| `categoryTreeService.ts` | `getCategoryTree` | 93 | `WHERE (company_code = $1 OR company_code = '*')` |
| `categoryTreeService.ts` | `getCategoryList` | 146 | `WHERE (company_code = $1 OR company_code = '*')` |
| `categoryTreeService.ts` | `getCategoryValue` | 188 | `WHERE (company_code = $1 OR company_code = '*')` |
| `categoryTreeService.ts` | `updateCategoryValue` | 352 | `WHERE (company_code = $1 OR company_code = '*')` |
| `categoryTreeService.ts` | `deleteCategoryValue` | 415 | `WHERE (company_code = $1 OR company_code = '*')` |
| `categoryTreeService.ts` | `updateChildrenPaths` | 443 | `WHERE (company_code = $1 OR company_code = '*')` |
| `categoryTreeService.ts` | `getCategoryColumns` | 498 | `WHERE (company_code = $2 OR company_code = '*')` |
| `categoryTreeService.ts` | `getAllCategoryKeys` | 530 | `WHERE cv.company_code = $1 OR cv.company_code = '*'` |
### 5.4 수정 방안
```typescript
// ✅ 올바른 멀티테넌시 패턴 (tableCategoryValueService.ts 참고)
async getCategoryTree(companyCode: string, tableName: string, columnName: string) {
let query: string;
let params: any[];
if (companyCode === "*") {
// 최고 관리자: 모든 데이터 조회
query = `
SELECT * FROM category_values_test
WHERE table_name = $1 AND column_name = $2
ORDER BY depth ASC, value_order ASC
`;
params = [tableName, columnName];
} else {
// 일반 회사: 자신의 데이터만 조회 (company_code = '*' 제외)
query = `
SELECT * FROM category_values_test
WHERE table_name = $1 AND column_name = $2
AND company_code = $3
ORDER BY depth ASC, value_order ASC
`;
params = [tableName, columnName, companyCode];
}
return await pool.query(query, params);
}
```
---
## 6. 🟠 Major: 하위 항목 삭제 미구현
### 6.1 문제 설명
주석에는 "하위 항목도 함께 삭제"라고 되어 있지만, 실제 구현에서는 단일 레코드만 삭제합니다.
### 6.2 코드 분석
```mermaid
flowchart TB
subgraph Comment["주석 (Line 407)"]
C1["카테고리 값 삭제 (하위 항목도 함께 삭제)"]
end
subgraph Implementation["실제 구현 (Line 413-416)"]
I1["DELETE FROM category_values_test<br/>WHERE ... AND value_id = $2"]
I2["단일 레코드만 삭제"]
end
Comment -.-> |"불일치"| Implementation
style Comment fill:#e7f5ff,stroke:#1971c2
style Implementation fill:#ffe3e3,stroke:#c92a2a
```
### 6.3 예상 문제 시나리오
```mermaid
flowchart TB
subgraph Before["삭제 전"]
P["대분류 (value_id=1)"]
C1["중분류 A (parent_value_id=1)"]
C2["중분류 B (parent_value_id=1)"]
C3["소분류 X (parent_value_id=C1)"]
P --> C1
P --> C2
C1 --> C3
end
subgraph After["'대분류' 삭제 후"]
C1o["중분류 A ⚠️ 고아"]
C2o["중분류 B ⚠️ 고아"]
C3o["소분류 X ⚠️ 고아"]
Orphan["parent_value_id가 존재하지 않는<br/>부모를 가리킴"]
end
Before --> |"DELETE"| After
style C1o fill:#ffe3e3
style C2o fill:#ffe3e3
style C3o fill:#ffe3e3
```
### 6.4 수정 방안
```typescript
async deleteCategoryValue(companyCode: string, valueId: number): Promise<boolean> {
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
// 1. 재귀적으로 모든 하위 항목 ID 조회
const descendantsQuery = `
WITH RECURSIVE descendants AS (
SELECT value_id FROM category_values_test
WHERE value_id = $1 AND (company_code = $2 OR company_code = '*')
UNION ALL
SELECT c.value_id FROM category_values_test c
JOIN descendants d ON c.parent_value_id = d.value_id
WHERE c.company_code = $2 OR c.company_code = '*'
)
SELECT value_id FROM descendants
`;
const descendants = await client.query(descendantsQuery, [valueId, companyCode]);
const idsToDelete = descendants.rows.map(r => r.value_id);
// 2. 하위 항목 포함 일괄 삭제
if (idsToDelete.length > 0) {
await client.query(
`DELETE FROM category_values_test WHERE value_id = ANY($1::int[])`,
[idsToDelete]
);
}
await client.query("COMMIT");
logger.info("카테고리 값 및 하위 항목 삭제 완료", {
valueId,
totalDeleted: idsToDelete.length
});
return true;
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
}
```
---
## 7. 🟠 Major: 카테고리 시스템 이원화
### 7.1 문제 설명
동일한 목적의 두 개의 카테고리 시스템이 존재합니다.
### 7.2 시스템 비교
```mermaid
flowchart TB
subgraph System1["시스템 1: categoryTreeService"]
S1C[categoryTreeController.ts]
S1S[categoryTreeService.ts]
S1T[(category_values_test)]
S1C --> S1S --> S1T
end
subgraph System2["시스템 2: tableCategoryValueService"]
S2S[tableCategoryValueService.ts]
S2T[(table_column_category_values)]
S2S --> S2T
end
subgraph Usage["사용처"]
U1[NumberingRuleDesigner.tsx]
U2[UnifiedSelect.tsx]
U3[screenManagementService.ts]
end
U1 --> S1T
U2 --> S1T
U3 --> S1T
style S1T fill:#4ecdc4,stroke:#087f5b
style S2T fill:#4ecdc4,stroke:#087f5b
```
### 7.3 테이블 비교
| 속성 | `category_values_test` | `table_column_category_values` |
|------|------------------------|-------------------------------|
| **서비스** | categoryTreeService | tableCategoryValueService |
| **menu_objid** | ❌ 없음 | ✅ 있음 |
| **계층 구조** | ✅ 지원 (최대 3단계) | ✅ 지원 |
| **path 컬럼** | ✅ 있음 | ❌ 없음 |
| **사용 빈도** | 높음 (108건) | 낮음 (0건 추정) |
| **명칭** | "테스트" | "정식" |
### 7.4 권장 사항
```mermaid
flowchart LR
subgraph Current["현재 상태"]
C1[category_values_test<br/>실제 사용 중]
C2[table_column_category_values<br/>거의 미사용]
end
subgraph Recommended["권장 조치"]
R1["1. 테이블명 정리:<br/>_test 접미사 제거"]
R2["2. 서비스 통합:<br/>하나의 서비스로"]
R3["3. 미사용 테이블 정리"]
end
Current --> Recommended
```
---
## 8. 🟡 Minor: 인덱스 비효율 쿼리
### 8.1 문제 쿼리
```sql
WHERE (company_code = $1 OR company_code = '*')
```
### 8.2 문제점
- `OR` 조건은 인덱스 최적화를 방해
- Full Table Scan 발생 가능
### 8.3 수정 방안
```sql
-- 옵션 1: UNION 사용 (권장)
SELECT * FROM category_values_test WHERE company_code = $1
UNION ALL
SELECT * FROM category_values_test WHERE company_code = '*'
-- 옵션 2: IN 연산자 사용
WHERE company_code IN ($1, '*')
-- 옵션 3: 조건별 분기 (가장 권장)
-- 최고 관리자와 일반 사용자 쿼리 분리 (멀티테넌시 규칙 준수와 함께)
```
---
## 9. 🟡 Minor: PUT/DELETE 오버라이드 누락
### 9.1 문제 설명
POST에서만 `targetCompanyCode` 오버라이드 로직이 있고, PUT/DELETE에는 없습니다.
### 9.2 비교 표
| 메서드 | 라인 | targetCompanyCode 처리 |
|--------|------|------------------------|
| POST `/test/value` | 136-145 | ✅ 있음 |
| PUT `/test/value/:valueId` | 174-201 | ❌ 없음 |
| DELETE `/test/value/:valueId` | 208-233 | ❌ 없음 |
### 9.3 영향
- 최고 관리자가 다른 회사의 카테고리 값을 수정/삭제할 때 제한될 수 있음
- 단, **의도적 설계**일 수 있음 (생성만 회사 지정, 수정/삭제는 기존 레코드의 company_code 사용)
### 9.4 권장 사항
기능 요구사항 확인 후 결정:
1. **의도적이라면**: 주석으로 의도 명시
2. **누락이라면**: POST와 동일한 로직 추가
---
## 10. 수정 계획
### 10.1 우선순위별 수정 항목
```mermaid
gantt
title 수정 우선순위
dateFormat YYYY-MM-DD
section 🔴 Critical
라우트 순서 수정 :crit, a1, 2026-01-26, 1d
타입 정의 수정 :crit, a2, 2026-01-26, 1d
멀티테넌시 규칙 준수 :crit, a3, 2026-01-26, 1d
section 🟠 Major
하위 항목 삭제 구현 :b1, 2026-01-27, 2d
section 🟡 Minor
쿼리 최적화 :c1, 2026-01-29, 1d
PUT/DELETE 검토 :c2, 2026-01-29, 1d
```
### 10.2 수정 체크리스트
#### 🔴 Critical (즉시 수정)
- [ ] **라우트 순서 수정** (Line 48, 98, 240)
- `/test/value/:valueId``/test/:tableName/:columnName` 앞으로 이동
- `/test/columns/:tableName``/test/:tableName/:columnName` 앞으로 이동
- [ ] **타입 정의 수정** (categoryTreeService.ts Line 34-46)
- `CreateCategoryValueInput``targetCompanyCode?: string` 추가
- TypeScript 컴파일 에러 해결
- [ ] **멀티테넌시 규칙 준수** (categoryTreeService.ts 모든 쿼리)
- `WHERE (company_code = $1 OR company_code = '*')` 패턴 제거
- 최고 관리자 분기와 일반 사용자 분기 분리
- 일반 사용자는 `company_code = '*'` 데이터 조회 불가
- **영향받는 함수**: getCategoryTree, getCategoryList, getCategoryValue, updateCategoryValue, deleteCategoryValue, updateChildrenPaths, getCategoryColumns, getAllCategoryKeys
#### 🟠 Major (수정 권장)
- [ ] **하위 항목 삭제 구현** (deleteCategoryValue 함수)
- 재귀적 하위 항목 조회 및 삭제 로직 추가
- 또는 주석 수정 (실제 동작과 일치하도록)
#### 🟡 Minor (검토 필요)
- [ ] **PUT/DELETE 오버라이드 검토**
- 필요 시 POST와 동일한 로직 추가
- 불필요 시 의도 주석 추가
---
## 11. 참고 자료
- 멀티테넌시 가이드: `.cursor/rules/multi-tenancy-guide.mdc`
- DB 비효율성 분석: `docs/DB_INEFFICIENCY_ANALYSIS.md`
- 보안 가이드: `.cursor/rules/security-guide.mdc`

View File

@ -1,107 +0,0 @@
# column_labels → table_type_columns 마이그레이션 완료
**작업일**: 2026-01-26
---
## 개요
`column_labels` 테이블의 데이터를 `table_type_columns`로 통합하여 멀티테넌시를 지원하고 데이터 중복을 제거함.
---
## 변경 사항
### 1. 스키마 확장
`table_type_columns`에 누락된 컬럼 추가:
| 컬럼명 | 타입 | 설명 |
|--------|------|------|
| column_label | VARCHAR(200) | 컬럼 라벨 |
| reference_table | VARCHAR(100) | 참조 테이블 |
| reference_column | VARCHAR(100) | 참조 컬럼 |
| display_column | VARCHAR(100) | 표시 컬럼 |
| code_category | VARCHAR(100) | 코드 카테고리 |
| code_value | VARCHAR(100) | 코드 값 |
| description | TEXT | 설명 |
| is_visible | BOOLEAN | 표시 여부 |
| web_type | VARCHAR(50) | 웹 타입 (레거시) |
### 2. 데이터 마이그레이션
```
column_labels (company_code 없음)
table_type_columns (company_code = '*')
```
**통합 기준**:
- `column_labels` 데이터 → `company_code = '*'` (공통 설정)
- 기존 회사별 설정 → **유지**
- 회사별 빈 값 → 공통(*)에서 복사 (COALESCE)
### 3. 코드 수정
**12개 파일** 수정:
| 파일 | 주요 변경 |
|------|----------|
| tableManagementService.ts | SELECT/INSERT → table_type_columns |
| screenManagementService.ts | JOIN column_labels → table_type_columns |
| entityJoinService.ts | 엔티티 조인 쿼리 변경 |
| ddlExecutionService.ts | DDL 시 column_labels 제거 |
| screenGroupController.ts | 화면 그룹 쿼리 변경 |
| tableManagementController.ts | 컬럼 관리 쿼리 변경 |
| adminController.ts | 스키마 조회 변경 |
| flowController.ts | 플로우 컬럼 조회 변경 |
| entityReferenceController.ts | 엔티티 참조 변경 |
| masterDetailExcelService.ts | 엑셀 처리 변경 |
| categoryTreeService.ts | 카테고리 트리 변경 |
| dataService.ts | 데이터 서비스 변경 |
---
## 백업
```
column_labels_backup_20260126 -- 원본 백업
table_type_columns_backup_20260126 -- 마이그레이션 전 백업
```
---
## 남은 작업
- [ ] 기능 테스트 (엔티티 조인, 화면 설정, 컬럼 라벨)
- [ ] 1-2주 모니터링
- [ ] `column_labels` 테이블 삭제
- [ ] `ddl.ts`에서 systemTables 배열 정리
---
## 롤백 방법
문제 발생 시:
```sql
-- 1. 백업에서 복원
DROP TABLE IF EXISTS column_labels;
CREATE TABLE column_labels AS SELECT * FROM column_labels_backup_20260126;
-- 2. table_type_columns 복원
DROP TABLE IF EXISTS table_type_columns;
CREATE TABLE table_type_columns AS SELECT * FROM table_type_columns_backup_20260126;
```
+ Git에서 코드 롤백 필요
---
## 결과
| 항목 | Before | After |
|------|--------|-------|
| 테이블 수 | 2개 | 1개 |
| 멀티테넌시 | 부분 지원 | 완전 지원 |
| 데이터 중복 | 있음 | 없음 |

View File

@ -1,561 +0,0 @@
# 컴포넌트 JSON 관리 시스템 분석 보고서
## 1. 개요
WACE 솔루션의 화면 컴포넌트는 **JSONB 형식**으로 데이터베이스에 저장되어 관리됩니다.
이 방식은 스키마 변경 없이 유연하게 컴포넌트 설정을 확장할 수 있는 장점이 있습니다.
---
## 2. 데이터베이스 구조
### 2.1 핵심 테이블: `screen_layouts`
```sql
CREATE TABLE screen_layouts (
layout_id SERIAL PRIMARY KEY,
screen_id INTEGER REFERENCES screen_definitions(screen_id),
component_type VARCHAR(50) NOT NULL, -- 'container', 'row', 'column', 'widget', 'component'
component_id VARCHAR(100) UNIQUE NOT NULL,
parent_id VARCHAR(100), -- 부모 컴포넌트 ID
position_x INTEGER NOT NULL, -- X 좌표 (그리드)
position_y INTEGER NOT NULL, -- Y 좌표 (그리드)
width INTEGER NOT NULL, -- 너비 (그리드 컬럼 수: 1-12)
height INTEGER NOT NULL, -- 높이 (픽셀)
properties JSONB, -- ⭐ 컴포넌트별 속성 (핵심 JSON 필드)
display_order INTEGER DEFAULT 0,
layout_type VARCHAR(50),
layout_config JSONB,
zones_config JSONB,
zone_id VARCHAR(100)
);
```
### 2.2 화면 정의: `screen_definitions`
```sql
CREATE TABLE screen_definitions (
screen_id SERIAL PRIMARY KEY,
screen_name VARCHAR(100) NOT NULL,
screen_code VARCHAR(50) UNIQUE NOT NULL,
table_name VARCHAR(100) NOT NULL,
company_code VARCHAR(50) NOT NULL,
description TEXT,
is_active CHAR(1) DEFAULT 'Y',
data_source_type VARCHAR(20), -- 'database' | 'restapi'
rest_api_endpoint VARCHAR(500),
rest_api_json_path VARCHAR(100)
);
```
---
## 3. JSON 구조 상세 분석
### 3.1 `properties` 필드의 최상위 구조
```typescript
interface ComponentProperties {
// 기본 식별 정보
id: string;
type: "widget" | "container" | "row" | "column" | "component";
// 위치 및 크기
position: { x: number; y: number; z?: number };
size: { width: number; height: number };
parentId?: string;
// 표시 정보
label?: string;
title?: string;
required?: boolean;
readonly?: boolean;
// 🆕 새 컴포넌트 시스템
componentType?: string; // 예: "v2-table-list", "v2-button-primary"
componentConfig?: any; // 컴포넌트별 상세 설정
// 레거시 위젯 시스템
widgetType?: string; // 예: "text-input", "select-basic"
webTypeConfig?: WebTypeConfig;
// 테이블/컬럼 정보
tableName?: string;
columnName?: string;
// 스타일
style?: ComponentStyle;
className?: string;
// 반응형 설정
responsiveConfig?: ResponsiveComponentConfig;
// 조건부 표시
conditional?: {
enabled: boolean;
field: string;
operator: "=" | "!=" | ">" | "<" | "in" | "notIn";
value: unknown;
action: "show" | "hide" | "enable" | "disable";
};
// 자동 입력
autoFill?: {
enabled: boolean;
sourceTable: string;
filterColumn: string;
userField: "companyCode" | "userId" | "deptCode";
displayColumn: string;
};
}
```
### 3.2 컴포넌트별 `componentConfig` 구조
#### 테이블 리스트 (`v2-table-list`)
```typescript
{
componentConfig: {
tableName: "user_info",
selectedTable: "user_info",
displayMode: "table" | "card",
columns: [
{
columnName: "user_id",
displayName: "사용자 ID",
visible: true,
sortable: true,
searchable: true,
width: 150,
align: "left",
format: "text",
order: 0,
editable: true,
hidden: false,
fixed: "left" | "right" | false,
autoGeneration: {
type: "uuid" | "numbering_rule",
enabled: false,
options: { numberingRuleId: "rule-123" }
}
}
],
pagination: {
enabled: true,
pageSize: 20,
showSizeSelector: true,
pageSizeOptions: [10, 20, 50, 100]
},
toolbar: {
showEditMode: true,
showExcel: true,
showRefresh: true
},
checkbox: {
enabled: true,
multiple: true,
position: "left"
},
filter: {
enabled: true,
filters: []
}
}
}
```
#### 버튼 (`v2-button-primary`)
```typescript
{
componentConfig: {
action: {
type: "save" | "delete" | "navigate" | "popup" | "excel" | "quickInsert",
// 화면 이동용
targetScreenId?: number,
targetScreenCode?: string,
navigateUrl?: string,
// 채번 규칙 연동
numberingRuleId?: string,
excelNumberingRuleId?: string,
// 엑셀 업로드 후 플로우 실행
excelAfterUploadFlows?: Array<{ flowId: number }>,
// 데이터 전송 설정
dataTransfer?: {
targetTable: string,
columnMappings: [
{ sourceColumn: string, targetColumn: string }
]
}
}
}
}
```
#### 분할 패널 레이아웃 (`v2-split-panel-layout`)
```typescript
{
componentConfig: {
leftPanel: {
tableName: "order_list",
displayMode: "table" | "card",
columns: [...],
addConfig: {
targetTable: "order_detail",
columnMappings: [...]
}
},
rightPanel: {
tableName: "order_detail",
displayMode: "table",
columns: [...]
},
dataTransfer: {
enabled: true,
buttonConfig: {
label: "선택 항목 추가",
position: "center"
}
}
}
}
```
#### 플로우 위젯 (`flow-widget`)
```typescript
{
webTypeConfig: {
dataflowConfig: {
flowConfig: {
flowId: 29
},
selectedDiagramId: 1,
flowControls: [
{ flowId: 30 },
{ flowId: 31 }
]
}
}
}
```
#### 탭 위젯 (`v2-tabs-widget`)
```typescript
{
componentConfig: {
tabs: [
{
id: "tab-1",
label: "기본 정보",
screenId: 45,
order: 0,
disabled: false
},
{
id: "tab-2",
label: "상세 정보",
screenId: 46,
order: 1
}
],
defaultTab: "tab-1",
orientation: "horizontal",
variant: "default"
}
}
```
### 3.3 메타데이터 저장 (`_metadata` 타입)
화면 전체 설정은 `component_type = "_metadata"`인 별도 레코드로 저장:
```typescript
{
properties: {
gridSettings: {
columns: 12,
gap: 16,
padding: 16,
snapToGrid: true,
showGrid: true
},
screenResolution: {
width: 1920,
height: 1080,
name: "Full HD",
category: "desktop"
}
}
}
```
---
## 4. 프론트엔드 레지스트리 구조
### 4.1 디렉토리 구조
```
frontend/lib/registry/
├── init.ts # 레지스트리 초기화
├── ComponentRegistry.ts # 컴포넌트 등록 시스템
├── WebTypeRegistry.ts # 웹타입 레지스트리
└── components/ # 컴포넌트별 폴더
├── v2-table-list/
│ ├── index.ts # 컴포넌트 등록
│ ├── types.ts # 타입 정의
│ ├── TableListComponent.tsx
│ ├── TableListRenderer.tsx
│ └── TableListConfigPanel.tsx
├── v2-button-primary/
├── v2-split-panel-layout/
├── text-input/
├── select-basic/
└── ... (70+ 컴포넌트)
```
### 4.2 컴포넌트 등록 패턴
```typescript
// frontend/lib/registry/components/v2-table-list/index.ts
import { ComponentRegistry } from "@/lib/registry/ComponentRegistry";
ComponentRegistry.register({
id: "v2-table-list",
name: "테이블 리스트",
category: "display",
component: TableListComponent,
renderer: TableListRenderer,
configPanel: TableListConfigPanel,
defaultConfig: {
tableName: "",
columns: [],
pagination: { enabled: true, pageSize: 20 }
}
});
```
### 4.3 현재 등록된 주요 컴포넌트 (70+ 개)
| 카테고리 | 컴포넌트 |
|---------|---------|
| **입력** | text-input, number-input, date-input, select-basic, checkbox-basic, radio-basic, textarea-basic, slider-basic, toggle-switch |
| **표시** | v2-table-list, v2-card-display, v2-text-display, image-display |
| **레이아웃** | v2-split-panel-layout, v2-section-card, v2-section-paper, accordion-basic, conditional-container |
| **버튼** | v2-button-primary, related-data-buttons |
| **고급** | flow-widget, v2-tabs-widget, v2-pivot-grid, v2-category-manager, v2-aggregation-widget |
| **파일** | file-upload |
| **반복** | repeat-container, repeater-field-group, simple-repeater-table, modal-repeater-table |
| **검색** | entity-search-input, autocomplete-search-input, table-search-widget |
| **특수** | numbering-rule, mail-recipient-selector, rack-structure, map |
---
## 5. 백엔드 서비스 로직
### 5.1 레이아웃 저장 (`saveLayout`)
```typescript
// backend-node/src/services/screenManagementService.ts
async saveLayout(screenId: number, layoutData: LayoutData, companyCode: string) {
// 1. 기존 레이아웃 삭제
await query(`DELETE FROM screen_layouts WHERE screen_id = $1`, [screenId]);
// 2. 메타데이터 저장
if (layoutData.gridSettings || layoutData.screenResolution) {
const metadata = {
gridSettings: layoutData.gridSettings,
screenResolution: layoutData.screenResolution
};
await query(`
INSERT INTO screen_layouts (
screen_id, component_type, component_id, properties, display_order
) VALUES ($1, '_metadata', $2, $3, -1)
`, [screenId, `_metadata_${screenId}`, JSON.stringify(metadata)]);
}
// 3. 컴포넌트 저장
for (const component of layoutData.components) {
const properties = {
...componentData,
position: { x, y, z },
size: { width, height }
};
await query(`
INSERT INTO screen_layouts (...) VALUES (...)
`, [screenId, componentType, componentId, ..., JSON.stringify(properties)]);
}
}
```
### 5.2 레이아웃 조회 (`getLayout`)
```typescript
async getLayout(screenId: number, companyCode: string): Promise<LayoutData | null> {
// 레이아웃 조회
const layouts = await query(`
SELECT * FROM screen_layouts WHERE screen_id = $1
ORDER BY display_order ASC
`, [screenId]);
// 메타데이터와 컴포넌트 분리
const metadataLayout = layouts.find(l => l.component_type === "_metadata");
const componentLayouts = layouts.filter(l => l.component_type !== "_metadata");
// 컴포넌트 변환 (JSONB → TypeScript 객체)
const components = componentLayouts.map(layout => {
const properties = layout.properties as any; // ⭐ JSONB 자동 파싱
return {
id: layout.component_id,
type: layout.component_type,
position: { x: layout.position_x, y: layout.position_y },
size: { width: layout.width, height: layout.height },
...properties // 모든 properties 확장
};
});
return { components, gridSettings, screenResolution };
}
```
### 5.3 ID 참조 업데이트 (화면 복사 시)
화면 복사 시 JSON 내부의 ID 참조를 새 ID로 업데이트:
```typescript
// 채번 규칙 ID 업데이트
updateNumberingRuleIdsInProperties(properties, ruleIdMap) {
// componentConfig.autoGeneration.options.numberingRuleId
// componentConfig.action.numberingRuleId
// componentConfig.action.excelNumberingRuleId
}
// 화면 ID 업데이트
updateTabScreenIdsInProperties(properties, screenIdMap) {
// componentConfig.tabs[].screenId
}
// 플로우 ID 업데이트
updateFlowIdsInProperties(properties, flowIdMap) {
// webTypeConfig.dataflowConfig.flowConfig.flowId
// webTypeConfig.dataflowConfig.flowControls[].flowId
}
```
---
## 6. 장단점 분석
### 6.1 장점
| 장점 | 설명 |
|-----|-----|
| **유연성** | 스키마 변경 없이 새 컴포넌트 설정 추가 가능 |
| **확장성** | 새 컴포넌트 타입 추가 시 DB 마이그레이션 불필요 |
| **버전 호환성** | 이전 버전 컴포넌트도 그대로 동작 |
| **빠른 개발** | 프론트엔드에서 설정 구조 변경 후 바로 저장 가능 |
| **복잡한 구조** | 중첩된 설정 (예: columns 배열) 저장 용이 |
### 6.2 단점
| 단점 | 설명 |
|-----|-----|
| **타입 안정성** | 런타임에만 타입 검증 가능 |
| **쿼리 복잡도** | JSONB 내부 필드 검색/수정 어려움 |
| **인덱싱 한계** | 전체 JSON 검색 시 성능 저하 |
| **마이그레이션** | JSON 구조 변경 시 데이터 마이그레이션 필요 |
| **디버깅** | JSON 구조 파악 어려움 |
---
## 7. 현재 구조의 특징
### 7.1 레거시 + 신규 컴포넌트 공존
```typescript
// 레거시 방식 (widgetType + webTypeConfig)
{
type: "widget",
widgetType: "text",
webTypeConfig: { ... }
}
// 신규 방식 (componentType + componentConfig)
{
type: "component",
componentType: "v2-table-list",
componentConfig: { ... }
}
```
### 7.2 계층 구조
```
screen_layouts
├── _metadata (격자 설정, 해상도)
├── container (최상위 컨테이너)
│ ├── row (행)
│ │ ├── column (열)
│ │ │ └── widget/component (실제 컴포넌트)
│ │ └── column
│ └── row
└── component (독립 컴포넌트)
```
### 7.3 ID 참조 관계
```
properties.componentConfig
├── action.targetScreenId → screen_definitions.screen_id
├── action.numberingRuleId → numbering_rule.rule_id
├── action.excelAfterUploadFlows[].flowId → flow_definitions.flow_id
├── tabs[].screenId → screen_definitions.screen_id
└── webTypeConfig.dataflowConfig.flowConfig.flowId → flow_definitions.flow_id
```
---
## 8. 개선 권장사항
### 8.1 단기 개선
1. **타입 문서화**: 각 컴포넌트의 `componentConfig` 타입을 TypeScript 인터페이스로 명확히 정의
2. **검증 레이어**: 저장 전 JSON 스키마 검증 추가
3. **마이그레이션 도구**: JSON 구조 변경 시 자동 마이그레이션 스크립트
### 8.2 장기 개선
1. **버전 관리**: `properties` 내에 `version` 필드 추가
2. **인덱스 최적화**: 자주 검색되는 JSONB 필드에 GIN 인덱스 추가
3. **로깅 강화**: 컴포넌트 설정 변경 이력 추적
---
## 9. 결론
현재 시스템은 **JSONB를 활용한 유연한 컴포넌트 설정 관리** 방식을 채택하고 있습니다.
- **70개 이상의 컴포넌트**가 등록되어 있으며
- **`screen_layouts.properties`** 필드에 모든 컴포넌트 설정이 저장됩니다
- 레거시(`widgetType`)와 신규(`componentType`) 컴포넌트가 공존하며
- 화면 복사 시 JSON 내부의 ID 참조가 자동 업데이트됩니다
이 구조는 **빠른 기능 확장**에 적합하지만, **타입 안정성**과 **쿼리 성능** 측면에서 추가 개선이 필요합니다.

View File

@ -1,433 +0,0 @@
# 컴포넌트 레이아웃 V2 아키텍처
> 최종 업데이트: 2026-01-27
## 1. 개요
### 1.1 목표
- **핵심 목표**: 컴포넌트 코드 수정 시 모든 화면에 자동 반영
- **문제 해결**: 기존 JSON "박제" 방식으로 인한 코드 수정 미반영 문제
- **방식**: 1 레코드 방식 (화면당 1개 레코드, JSON에 모든 컴포넌트 포함)
### 1.2 핵심 원칙
```
저장: component_url + overrides (차이값만)
로드: 코드 기본값 + overrides 병합 (Zod)
```
**이전 방식 (문제점)**:
```json
// 전체 설정 박제 → 코드 수정해도 반영 안 됨
{
"componentType": "table-list",
"componentConfig": {
"columns": [...],
"pagination": true,
"pageSize": 20,
// ... 수백 줄의 설정
}
}
```
**V2 방식 (해결)**:
```json
// url로 코드 참조 + 차이값만 저장
{
"url": "@/lib/registry/components/table-list",
"overrides": {
"tableName": "user_info",
"columns": ["id", "name"]
}
}
```
---
## 2. 데이터베이스 구조
### 2.1 테이블 정의
```sql
CREATE TABLE screen_layouts_v2 (
layout_id SERIAL PRIMARY KEY,
screen_id INTEGER NOT NULL,
company_code VARCHAR(20) NOT NULL,
layout_data JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(screen_id, company_code)
);
-- 인덱스
CREATE INDEX idx_v2_screen_id ON screen_layouts_v2(screen_id);
CREATE INDEX idx_v2_company_code ON screen_layouts_v2(company_code);
CREATE INDEX idx_v2_screen_company ON screen_layouts_v2(screen_id, company_code);
```
### 2.2 layout_data 구조
```json
{
"version": "2.0",
"components": [
{
"id": "comp_xxx",
"url": "@/lib/registry/components/table-list",
"position": { "x": 0, "y": 0 },
"size": { "width": 100, "height": 50 },
"displayOrder": 0,
"overrides": {
"tableName": "user_info",
"columns": ["id", "name", "email"]
}
},
{
"id": "comp_yyy",
"url": "@/lib/registry/components/button-primary",
"position": { "x": 0, "y": 60 },
"size": { "width": 20, "height": 5 },
"displayOrder": 1,
"overrides": {
"label": "저장",
"variant": "default"
}
}
],
"updatedAt": "2026-01-27T12:00:00Z"
}
```
### 2.3 필드 설명
| 필드 | 타입 | 설명 |
|-----|-----|-----|
| `id` | string | 컴포넌트 고유 ID |
| `url` | string | 컴포넌트 코드 경로 (필수) |
| `position` | object | 캔버스 내 위치 {x, y} |
| `size` | object | 크기 {width, height} |
| `displayOrder` | number | 렌더링 순서 |
| `overrides` | object | 기본값과 다른 설정만 (차이값) |
---
## 3. API 정의
### 3.1 레이아웃 조회
```
GET /api/screen-management/screens/:screenId/layout-v2
```
**응답**:
```json
{
"success": true,
"data": {
"version": "2.0",
"components": [...]
}
}
```
**로직**:
1. 회사별 레이아웃 먼저 조회
2. 없으면 공통(*) 레이아웃 조회
3. 없으면 null 반환
### 3.2 레이아웃 저장
```
POST /api/screen-management/screens/:screenId/layout-v2
```
**요청**:
```json
{
"components": [
{
"id": "comp_xxx",
"url": "@/lib/registry/components/table-list",
"position": { "x": 0, "y": 0 },
"size": { "width": 100, "height": 50 },
"overrides": { ... }
}
]
}
```
**로직**:
1. 권한 확인
2. 버전 정보 추가
3. UPSERT (있으면 업데이트, 없으면 삽입)
---
## 4. 컴포넌트 URL 규칙
### 4.1 URL 형식
```
@/lib/registry/components/{component-name}
```
### 4.2 현재 등록된 컴포넌트
| URL | 설명 |
|-----|-----|
| `@/lib/registry/components/table-list` | 테이블 리스트 |
| `@/lib/registry/components/button-primary` | 기본 버튼 |
| `@/lib/registry/components/text-input` | 텍스트 입력 |
| `@/lib/registry/components/select-basic` | 기본 셀렉트 |
| `@/lib/registry/components/date-input` | 날짜 입력 |
| `@/lib/registry/components/split-panel-layout` | 분할 패널 |
| `@/lib/registry/components/tabs-widget` | 탭 위젯 |
| `@/lib/registry/components/card-display` | 카드 디스플레이 |
| `@/lib/registry/components/flow-widget` | 플로우 위젯 |
| `@/lib/registry/components/category-management` | 카테고리 관리 |
| `@/lib/registry/components/pivot-table` | 피벗 테이블 |
| `@/lib/registry/components/unified-grid` | 통합 그리드 |
---
## 5. Zod 스키마 관리
### 5.1 목적
- 런타임 타입 검증
- 기본값 자동 적용
- overrides 유효성 검사
### 5.2 구조
```typescript
// frontend/lib/schemas/componentConfig.ts
import { z } from "zod";
// 공통 스키마
export const baseComponentSchema = z.object({
id: z.string(),
url: z.string(),
position: z.object({
x: z.number().default(0),
y: z.number().default(0),
}),
size: z.object({
width: z.number().default(100),
height: z.number().default(100),
}),
displayOrder: z.number().default(0),
overrides: z.record(z.any()).default({}),
});
// 컴포넌트별 overrides 스키마
export const tableListOverridesSchema = z.object({
tableName: z.string().optional(),
columns: z.array(z.string()).optional(),
pagination: z.boolean().default(true),
pageSize: z.number().default(20),
});
export const buttonOverridesSchema = z.object({
label: z.string().default("버튼"),
variant: z.enum(["default", "destructive", "outline", "ghost"]).default("default"),
icon: z.string().optional(),
});
```
### 5.3 사용 방법
```typescript
// 로드 시: 코드 기본값 + overrides 병합
function loadComponent(component: any) {
const schema = getSchemaByUrl(component.url);
const defaults = schema.parse({});
const merged = deepMerge(defaults, component.overrides);
return merged;
}
// 저장 시: 기본값과 다른 부분만 추출
function saveComponent(component: any, config: any) {
const schema = getSchemaByUrl(component.url);
const defaults = schema.parse({});
const overrides = extractDiff(defaults, config);
return { ...component, overrides };
}
```
---
## 6. 마이그레이션 현황
### 6.1 완료된 작업
| 작업 | 상태 | 날짜 |
|-----|-----|-----|
| screen_layouts_v2 테이블 생성 | ✅ 완료 | 2026-01-27 |
| 기존 데이터 마이그레이션 | ✅ 완료 | 2026-01-27 |
| 백엔드 API 추가 (getLayoutV2, saveLayoutV2) | ✅ 완료 | 2026-01-27 |
| 프론트엔드 API 클라이언트 추가 | ✅ 완료 | 2026-01-27 |
| Zod 스키마 V2 확장 | ✅ 완료 | 2026-01-27 |
| V2 변환 유틸리티 (layoutV2Converter.ts) | ✅ 완료 | 2026-01-27 |
| ScreenDesigner V2 API 연동 | ✅ 완료 | 2026-01-27 |
### 6.2 마이그레이션 통계
```
마이그레이션 대상 화면: 1,347개
성공: 1,347개 (100%)
실패: 0개
컴포넌트 많은 화면 TOP 5:
- screen 74: 25개 컴포넌트
- screen 1204: 18개 컴포넌트
- screen 1242: 18개 컴포넌트
- screen 119: 18개 컴포넌트
- screen 1255: 18개 컴포넌트
```
---
## 7. 남은 작업
### 7.1 필수 작업
| 작업 | 우선순위 | 예상 공수 | 상태 |
|-----|---------|---------|------|
| 프론트엔드 디자이너 V2 API 연동 | 높음 | 3일 | ✅ 완료 |
| Zod 스키마 컴포넌트별 정의 | 높음 | 2일 | ✅ 완료 |
| V2 변환 유틸리티 | 높음 | 1일 | ✅ 완료 |
| 테스트 및 검증 | 중간 | 2일 | 🔄 진행 필요 |
### 7.2 선택 작업
| 작업 | 우선순위 | 예상 공수 |
|-----|---------|---------|
| 기존 API (layout, layout-v1) 제거 | 낮음 | 1일 |
| 기존 테이블 (screen_layouts, screen_layouts_v1) 정리 | 낮음 | 1일 |
| 마이그레이션 검증 도구 | 낮음 | 1일 |
| 컴포넌트별 기본값 레지스트리 확장 | 낮음 | 2일 |
---
## 8. 개발 가이드
### 8.1 새 컴포넌트 추가 시
1. **컴포넌트 코드 생성**
```
frontend/lib/registry/components/{component-name}/
├── index.ts
├── {ComponentName}Renderer.tsx
└── types.ts
```
2. **Zod 스키마 정의**
```typescript
// frontend/lib/schemas/components/{component-name}.ts
export const {componentName}OverridesSchema = z.object({
// 컴포넌트 고유 설정
});
```
3. **레지스트리 등록**
```typescript
// frontend/lib/registry/components/index.ts
export { default as {ComponentName} } from "./{component-name}";
```
### 8.2 화면 저장 시
```typescript
// 디자이너에서 저장 시
async function handleSave() {
const layoutData = {
components: components.map(comp => ({
id: comp.id,
url: comp.url,
position: comp.position,
size: comp.size,
displayOrder: comp.displayOrder,
overrides: extractOverrides(comp.url, comp.config) // 차이값만 추출
}))
};
await screenApi.saveLayoutV2(screenId, layoutData);
}
```
### 8.3 화면 로드 시
```typescript
// 화면 렌더러에서 로드 시
async function loadScreen(screenId: number) {
const layoutData = await screenApi.getLayoutV2(screenId);
const components = layoutData.components.map(comp => {
const defaults = getDefaultsByUrl(comp.url); // Zod 기본값
const mergedConfig = deepMerge(defaults, comp.overrides);
return {
...comp,
config: mergedConfig
};
});
return components;
}
```
---
## 9. 비교: 기존 vs V2
| 항목 | 기존 (다중 레코드) | V2 (1 레코드) |
|-----|------------------|--------------|
| 레코드 수 | 화면당 N개 (컴포넌트 수) | 화면당 1개 |
| 저장 방식 | 전체 설정 박제 | url + overrides |
| 코드 수정 반영 | ❌ 안 됨 | ✅ 자동 반영 |
| 중복 데이터 | 있음 (DB 컬럼 + JSON) | 없음 |
| 공사량 | - | 테이블 변경 필요 |
---
## 10. 관련 파일
### 10.1 백엔드
- `backend-node/src/services/screenManagementService.ts` - getLayoutV2, saveLayoutV2
- `backend-node/src/controllers/screenManagementController.ts` - API 엔드포인트
- `backend-node/src/routes/screenManagementRoutes.ts` - 라우트 정의
### 10.2 프론트엔드
- `frontend/lib/api/screen.ts` - getLayoutV2, saveLayoutV2 클라이언트
- `frontend/lib/schemas/componentConfig.ts` - Zod 스키마 및 V2 유틸리티
- `frontend/lib/utils/layoutV2Converter.ts` - V2 ↔ Legacy 변환 유틸리티
- `frontend/components/screen/ScreenDesigner.tsx` - V2 API 연동 (USE_V2_API 플래그)
- `frontend/lib/registry/components/` - 컴포넌트 레지스트리
### 10.3 데이터베이스
- `screen_layouts_v2` - V2 레이아웃 테이블
---
## 11. FAQ
### Q1: 기존 화면은 어떻게 되나요?
기존 화면은 마이그레이션되어 `screen_layouts_v2`에 저장됩니다. 디자이너가 V2 API를 사용하도록 수정되면 자동으로 새 구조를 사용합니다.
### Q2: 컴포넌트 코드를 수정하면 정말 전체 반영되나요?
네. `overrides`에는 차이값만 저장되고, 로드 시 코드의 기본값과 병합됩니다. 기본값을 수정하면 모든 화면에 반영됩니다.
### Q3: 회사별 설정은 어떻게 관리하나요?
`company_code` 컬럼으로 회사별 레이아웃을 분리합니다. 회사별 레이아웃이 없으면 공통(*) 레이아웃을 사용합니다.
### Q4: 기존 테이블(screen_layouts)은 언제 삭제하나요?
V2가 안정화되고 모든 기능이 정상 동작하는지 확인된 후에 삭제합니다. 최소 1개월 이상 병행 운영 권장.
---
## 12. 변경 이력
| 날짜 | 변경 내용 | 작성자 |
|-----|----------|-------|
| 2026-01-27 | 초안 작성, 테이블 생성, 마이그레이션, API 추가 | Claude |
| 2026-01-27 | Zod 스키마 V2 확장, 변환 유틸리티, ScreenDesigner 연동 | Claude |

View File

@ -1,627 +0,0 @@
# 컴포넌트 관리 시스템 최종 설계
---
## 🔒 확정 사항 (변경 금지)
| 항목 | 확정 내용 | 비고 |
|-----|---------|-----|
| **slot 저장 위치** | `custom_config.slot` | DB 컬럼 아님 |
| **component_url** | 모든 컴포넌트 **필수** | NULL 허용 안 함 |
| **멀티테넌시** | 모든 쿼리에 `company_code` 필터 필수 | action 실행/참조 조회 포함 |
⚠️ **위 3가지는 개발 중 절대 변경하지 말 것**
---
## 1. 현재 문제점 (복사본 문제)
### 문제 상황
- 컴포넌트 코드 수정 시 기존 화면에 반영 안 됨
- JSON에 모든 설정이 저장되어 있어서 코드 변경이 무시됨
- JSON 구조가 복잡해서 디버깅 어려움
- 어떤 파일을 수정해야 하는지 찾기 어려움
### 핵심 원인: DB에 "복사본"이 생김
- 화면 저장할 때 컴포넌트 설정 **전체**를 JSON으로 저장
- 그 순간 DB 안에 **"컴포넌트 복사본"**이 생김
- 나중에 코드(원본)를 고쳐도, 화면은 DB 복사본을 읽어서 **원본 수정이 안 먹음**
### 현재 구조 (문제되는 방식)
```json
{
"componentType": "button-primary",
"componentConfig": {
"text": "저장",
"variant": "primary",
"backgroundColor": "#111", // 기본값인데도 저장됨 → 복사본
"textColor": "#fff", // 기본값인데도 저장됨 → 복사본
...전체 설정...
}
}
```
- 4,414개 레코드
- 모든 설정이 JSON에 통째로 저장 (= 복사본)
---
## 2. 해결 방안 비교
### 방안 A: 1개 레코드 (화면당 1개, components 배열)
```json
{
"components": [
{ "type": "split-panel-layout", "url": "...", "config": {...} },
{ "type": "table-list", "url": "...", "config": {...} },
{ "type": "button", "config": {...} }
]
}
```
| 장점 | 단점 |
|-----|-----|
| 레코드 수 감소 (4414 → ~200) | JSON 크기 커짐 (10~50KB/화면) |
| 화면 단위 관리 | 버튼 하나 수정해도 전체 JSON 업데이트 |
| | 동시 편집 시 충돌 위험 |
| | 특정 컴포넌트 쿼리 어려움 (JSON 내부 검색) |
**결론: 비효율적**
---
### 방안 B: 다중 레코드 + URL (선택)
```sql
screen_layouts_v3
├── component_id
├── component_url = "@/lib/registry/components/split-panel-layout"
├── custom_config = { 커스텀 설정만 }
```
| 장점 | 단점 |
|-----|-----|
| 개별 컴포넌트 수정 가능 | 레코드 수 많음 (기존과 동일) |
| 부분 업데이트 | |
| URL로 바로 파일 위치 확인 | |
| 인덱스 검색 가능 | |
| 동시 편집 안전 | |
**결론: 효율적**
---
## 3. URL + overrides 방식의 핵심
### 핵심 개념
- **URL = 참조 방식**: "이 컴포넌트의 코드는 어디 파일이냐?"
- **overrides = 차이값**: "회사/화면별로 다른 값만"
- **DB는 복사본이 아닌 참조 + 메모**
### 저장 구조 비교
**AS-IS (복사본 = 문제):**
```json
{
"componentType": "button-primary",
"componentConfig": {
"text": "저장",
"variant": "primary", // 기본값
"backgroundColor": "#111", // 기본값
"textColor": "#fff", // 기본값
...전체...
}
}
```
**TO-BE (참조 + 차이값 = 해결):**
```json
{
"component_url": "@/lib/registry/components/button-primary",
"overrides": {
"text": "저장",
"action": { "type": "save" }
}
}
```
### 왜 코드 수정이 전체 반영되나?
1. 코드(원본)에 defaults 정의: `{ variant: "primary", backgroundColor: "#111" }`
2. DB에는 overrides만: `{ text: "저장" }`
3. 렌더링 시 merge: `{ ...defaults, ...overrides }`
4. 코드의 defaults 수정 → 모든 화면 즉시 반영
### 디버깅 효율성
**URL 없을 때:**
```
1. component_type = "split-panel-layout" 확인
2. 어디에 파일이 있지? 매핑 찾기
3. 규칙 추론 또는 설정 파일 확인
4. 해당 파일로 이동
```
→ 3~4단계
**URL 있을 때:**
```
1. component_url = "@/lib/registry/components/split-panel-layout" 확인
2. 해당 파일로 바로 이동
```
→ 1단계
---
## 4. 최종 설계
### DB 구조
```sql
screen_layouts_v3 (
layout_id SERIAL PRIMARY KEY,
screen_id INTEGER,
component_id VARCHAR(100) UNIQUE NOT NULL,
component_url VARCHAR(200) NOT NULL, -- 모든 컴포넌트 URL 참조 (권장)
custom_config JSONB NOT NULL DEFAULT '{}', -- slot, dataSource 등 포함
parent_id VARCHAR(100), -- 부모 컴포넌트 ID (컨테이너-자식 관계)
position_x INTEGER DEFAULT 0,
position_y INTEGER DEFAULT 0,
width INTEGER DEFAULT 100,
height INTEGER DEFAULT 100,
display_order INTEGER DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
```
**주요 컬럼:**
- `component_url`: 컴포넌트 코드 경로 (필수)
- `custom_config`: 회사/화면별 차이값 (slot 포함)
- `parent_id`: 부모 컴포넌트 ID (계층 구조)
### component_url 정책
**원칙: 모든 컴포넌트는 URL 참조가 가능해야 함**
| 구분 | 예시 | component_url | 설명 |
|-----|-----|--------------|------|
| 메인 | split-panel, tabs, table-list | `@/lib/.../split-panel-layout` | 코드 수정 시 전체 반영 |
| 공용 | button, text-input | `@/lib/.../button-primary` | 동일하게 URL 참조 |
**참고**:
- 공용 컴포넌트도 URL로 참조하면 코드 수정 시 전체 반영 가능
- `NULL` 허용은 마이그레이션 단순화를 위한 선택적 옵션 (권장하지 않음)
### 데이터 저장/로드
**컴포넌트 파일에 defaults 정의:**
```typescript
// @/lib/registry/components/split-panel-layout/index.tsx
export const defaultConfig = {
splitRatio: 30,
resizable: true,
minSize: 100,
};
```
**저장 시 (diff만):**
```json
// DB에 저장되는 custom_config
{
"splitRatio": 50,
"tableName": "user_info"
}
// resizable, minSize는 기본값과 같으므로 저장 안 함
```
**로드 시 (merge):**
```typescript
const fullConfig = { ...defaultConfig, ...customConfig };
// 결과: { splitRatio: 50, resizable: true, minSize: 100, tableName: "user_info" }
```
### Zod 스키마
```typescript
// 컴포넌트별 스키마 (defaults 포함)
const splitPanelSchema = z.object({
splitRatio: z.number().default(30),
resizable: z.boolean().default(true),
minSize: z.number().default(100),
tableName: z.string().optional(),
columns: z.array(z.string()).optional(),
});
// 저장 시: schema.parse(config)로 검증
// 로드 시: schema.parse(customConfig)로 defaults 적용
```
---
## 5. 장점 요약
1. **코드 수정 → 전체 반영**
- 컴포넌트 파일 수정하면 해당 URL 사용하는 모든 화면에 적용
2. **JSON 크기 감소**
- 기본값과 다른 것만 저장
- 디버깅 시 "뭐가 커스텀인지" 바로 파악
3. **새 기능 추가 시 자동 적용**
- 코드에 새 필드 + default 추가
- 기존 데이터는 그대로, 로드 시 default 적용
4. **디버깅 쉬움**
- URL 보고 바로 파일 위치 확인
- 매핑 파일 불필요
5. **유지보수 용이**
- 컴포넌트별로 스키마 관리
- Zod로 타입 안전성 확보
---
## 6. 회사별 설정 & 비즈니스 로직 처리
### 회사별 UI 차이 (색깔 등)
```json
// A회사
{ "overrides": { "colorVariant": "blue" } }
// B회사
{ "overrides": { "colorVariant": "red" } }
```
- Zod로 허용 값 제한: `z.enum(["blue", "red", "primary"])`
- 임의의 hex 허용할지, 토큰만 허용할지 스키마로 강제
### 비즈니스 로직 연결 (제어관리 등)
**버튼에 함수/코드 직접 붙이면 안 됨** → 다시 복사본 문제 발생
**해결: 액션 정의(데이터)만 저장, 실행은 공통 엔진**
```json
{
"component_url": "@/lib/registry/components/button-primary",
"overrides": {
"text": "제어실행",
"action": {
"type": "CONTROL_EXECUTE",
"ruleId": "RULE_001",
"params": { "targetTable": "user_info" }
}
}
}
```
**실행 흐름:**
1. 버튼 클릭
2. 공통 ActionRunner가 `action.type` 확인
3. `CONTROL_EXECUTE` → 제어관리 로직 실행
4. `ruleId`, `params`로 실제 동작
**장점:**
- 액션 시스템 버그 수정 → 전 회사 버튼 같이 개선
- 회사별로는 `ruleId`/`params`만 다르게 저장
- Zod로 `action` 타입/필수필드 검증 가능
---
## 7. 구현 순서
1. **DB 스키마 변경**
- `screen_layouts_v3` 테이블 생성
- `component_url`, `custom_config` 컬럼
2. **컴포넌트별 defaults 정의**
- 각 컴포넌트 파일에 `defaultConfig` export
3. **저장 로직**
- 저장 시 defaults와 비교하여 diff만 저장
4. **로드 로직**
- 로드 시 defaults + customConfig merge
5. **마이그레이션**
- 기존 데이터에서 component_url 추출
- properties.componentConfig → custom_config 변환
- (기존 데이터는 일단 전체 저장, 추후 diff로 변환 가능)
6. **프론트엔드 수정**
- 컴포넌트 로딩 시 URL 기반으로 동적 import
- config merge 로직 적용
---
## 8. 레코드 개수 원칙
### 핵심 원칙
**컴포넌트 인스턴스 1개 = 레코드 1개**
### 현재 문제 (split-panel에 몰아넣기)
```
split-panel-layout 1개 레코드에:
├── leftPanel 설정 (table-list 역할) → 박제
├── rightPanel 설정 (card 역할) → 박제
├── relation, binding 등등 → 박제
└── 전부 JSON으로 들어감
```
**문제점:**
- table-list 코드 수정해도 반영 안 됨 (JSON에 박제)
- 컨테이너 스키마가 계속 비대해짐
- URL 참조 체계와 충돌
### 올바른 구조 (레코드 분리)
```
레코드 1: split-panel-layout (컨테이너)
└── component_url: @/lib/.../split-panel-layout ← URL 필수 (코드 참조)
└── parent_id: null
└── custom_config: { splitRatio: 30 }
레코드 2: table-list (왼쪽)
└── component_url: @/lib/.../table-list
└── parent_id: "comp_split_001"
└── custom_config: {
slot: "left", ← slot은 custom_config 안에
dataSource: {...},
selection: { publishKey: "selectedId" }
}
레코드 3: card-display (오른쪽)
└── component_url: @/lib/.../card-display
└── parent_id: "comp_split_001"
└── custom_config: {
slot: "right", ← slot은 custom_config 안에
dataSource: { where: { id: { fromContext: "selectedId" } } }
}
```
**주의**:
- 컨테이너도 컴포넌트이므로 `component_url` 필수
- `slot`은 DB 컬럼이 아닌 `custom_config` 안에 저장
### 부모-자식 연결 방식
| 컬럼 | 위치 | 설명 |
|-----|-----|-----|
| `parent_id` | DB 컬럼 | 부모 컴포넌트 ID |
| `slot` | custom_config 내부 | 슬롯명 (left/right/header/footer) |
`parent_id`는 DB 컬럼, `slot`은 JSON 안에 → **일관성 유지**
**장점:**
- table-list 코드 수정 → 전체 반영 ✅
- card-display 코드 수정 → 전체 반영 ✅
- 컨테이너는 레이아웃만 담당 (설정 폭발 방지)
- 재사용/확장 용이
### 연결 방식
**연결 정보는 각 컴포넌트의 custom_config에 저장**, 실행은 공통 컨텍스트 매니저가 처리:
```json
// table-list의 custom_config
{ "selection": { "publishKey": "selectedId" } }
// card-display의 custom_config
{ "dataSource": { "where": { "id": { "fromContext": "selectedId" } } } }
```
- **저장**: 각 컴포넌트 custom_config에 바인딩 정보
- **실행**: 공통 ScreenContext가 publish/subscribe 처리
---
## 9. 마이그레이션 전략
### 2단계 전략 (반자동 + 검증)
**1단계: 자동 변환**
```
split-panel-layout 레코드에서:
├── properties.componentConfig.leftPanel → 왼쪽 컴포넌트 레코드 생성
├── properties.componentConfig.rightPanel → 오른쪽 컴포넌트 레코드 생성
├── properties.componentConfig.relation → 바인딩 설정으로 변환
└── 원본 → 컨테이너 레코드 (레이아웃만)
```
**2단계: 검증/수동 보정**
- 특이 케이스 (커스텀 필드, 중첩 구조) 확인
- 사람이 검증 후 보정
**이유**: "완전 자동"은 예외가 많고, "완전 수동"은 시간이 너무 듦
---
## 10. publish/subscribe 바인딩 설계
### 스코프
**화면(screen) 단위**가 기본
**이유**: 같은 key(selectedId)가 다른 화면에서 섞이면 사고
### 구현 방식 (React)
**권장: ScreenContext 기반**
```typescript
// ScreenContext + 내부 store
const ScreenContext = createContext<Map<string, any>>();
// 사용
const { publish, subscribe } = useScreenContext();
// table-list에서
publish("selectedId", row.id);
// card-display에서
const selectedId = subscribe("selectedId");
```
**장점:**
- 화면 언마운트 시 상태 자동 폐기
- 디버깅 쉬움 ("현재 화면 컨텍스트 값" 표시 가능)
---
## 11. ActionRunner 설계
### 원칙
- 버튼에는 **"실행할 일의 데이터"만** 저장
- 실행은 **공통 ActionRunner**가 처리
### 구조
```typescript
// action.type은 enum으로 고정 (Zod 검증)
const actionTypeSchema = z.enum([
"OPEN_SCREEN",
"CRUD_SAVE",
"CRUD_DELETE",
"CONTROL_EXECUTE",
"FLOW_EXECUTE",
"API_CALL",
]);
// payload는 타입별 스키마로 분기
const actionSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("OPEN_SCREEN"), screenId: z.number(), filters: z.record(z.any()).optional() }),
z.object({ type: z.literal("CRUD_SAVE"), tableName: z.string() }),
z.object({ type: z.literal("CONTROL_EXECUTE"), ruleId: z.string(), params: z.record(z.any()).optional() }),
z.object({ type: z.literal("FLOW_EXECUTE"), flowId: z.number() }),
// ...
]);
```
### 초기 action.type 목록
| type | 설명 | payload |
|-----|-----|---------|
| `OPEN_SCREEN` | 화면 이동 | `{ screenId, filters? }` |
| `CRUD_SAVE` | 저장 | `{ tableName }` |
| `CRUD_DELETE` | 삭제 | `{ tableName }` |
| `CONTROL_EXECUTE` | 제어관리 실행 | `{ ruleId, params? }` |
| `FLOW_EXECUTE` | 플로우 실행 | `{ flowId }` |
| `API_CALL` | 외부/내부 API 호출 | `{ endpoint, method, body? }` (보안/허용 목록 필수) |
---
## 12. 구현 우선순위
### 순서 (권장)
| 순서 | 단계 | 설명 |
|-----|-----|-----|
| 1 | **데이터 모델/스키마 확정** | component_url 정책, parent_id + slot 위치 |
| 2 | **프론트 렌더링 파이프라인** | 로드 → merge → Zod → 렌더링 |
| 3 | **바인딩 컨텍스트 + ActionRunner** | publish/subscribe + 공통 실행 엔진 |
| 4 | **화면 디자이너 저장 포맷 변경** | "박제 JSON" 방지 (저장 시 차단) |
| 5 | **마이그레이션 스크립트** | 기존 데이터 → 새 구조 변환 |
### 핵심
- 렌더링이 먼저 되어야 검증 가능
- 저장 로직을 마지막에 수정해야 "새 박제" 방지
---
## 13. 주의사항
- 기존 화면은 **동일하게 렌더링**되어야 함
- 마이그레이션 시 데이터 손실 없어야 함
- 새 테이블(v1)에서 테스트 후 전환
- **company_code 필터 필수** (멀티테넌시)
- action.type `API_CALL`**허용 목록 필수** (보안)
---
## 14. 구현 진행 상황
### 완료된 작업
| 단계 | 내용 | 상태 |
|-----|-----|-----|
| 1-1 | `screen_layouts_v1` 테이블 생성 | ✅ 완료 |
| 1-2 | 복합 인덱스 생성 (company_code, screen_id) | ✅ 완료 |
| 1-3 | 기존 데이터 마이그레이션 (4,414개) | ✅ 완료 |
| 1-4 | **split-panel 자식 분리** (leftPanel/rightPanel → 별도 레코드) | ✅ 완료 |
| 1-5 | **repeat-container 자식 분리** (children → 별도 레코드) | ✅ 완료 |
| 2-1 | 백엔드 `getLayoutV1` API 구현 | ✅ 완료 |
| 2-2 | 프론트엔드 `getLayoutV1` API 추가 | ✅ 완료 |
| 2-3 | Zod 스키마 및 merge 함수 | ✅ 완료 |
### 마이그레이션 결과
```
총 레코드: 4,691개
├── 루트 컴포넌트: 4,414개
└── 자식 컴포넌트: 277개 (parent_id 있음)
slot 분포:
├── left: 136개
├── right: 135개
└── child_0~3: 6개
박제 제거:
├── split-panel의 leftPanel/rightPanel: 0개 (완료)
├── repeat-container의 children: 0개 (완료)
└── tabs 내부 components: 13개 (추후 처리)
```
### 샘플 구조 (screen 1383 - 수주등록)
```
comp_lspd9b9m (split-panel-layout)
├── comp_lspd9b9m_left (table-list)
│ ├── slot: "left"
│ └── tableName: "sales_order_mng"
└── comp_lspd9b9m_right (table-list)
├── slot: "right"
└── tableName: "sales_order_detail"
```
### DB 스키마
```sql
CREATE TABLE screen_layouts_v1 (
layout_id SERIAL PRIMARY KEY,
screen_id VARCHAR(50) NOT NULL,
component_id VARCHAR(100) NOT NULL,
component_url VARCHAR(200) NOT NULL, -- 🔒 필수
custom_config JSONB NOT NULL DEFAULT '{}', -- slot 포함
parent_id VARCHAR(100),
position_x INTEGER NOT NULL DEFAULT 0,
position_y INTEGER NOT NULL DEFAULT 0,
width INTEGER NOT NULL DEFAULT 100,
height INTEGER NOT NULL DEFAULT 100,
display_order INTEGER DEFAULT 0,
company_code VARCHAR(20) NOT NULL, -- 🔒 멀티테넌시
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(company_code, screen_id, component_id)
);
-- 인덱스
CREATE INDEX idx_v1_company_screen ON screen_layouts_v1(company_code, screen_id);
CREATE INDEX idx_v1_company_parent ON screen_layouts_v1(company_code, parent_id);
CREATE INDEX idx_v1_component_url ON screen_layouts_v1(component_url);
```
### API 엔드포인트
```
GET /api/screen-management/screens/:screenId/layout-v1
```
### 남은 작업
| 단계 | 내용 | 상태 |
|-----|-----|-----|
| 3-1 | 바인딩 컨텍스트 (ScreenContext) 구현 | 🔲 대기 |
| 3-2 | ActionRunner 공통 엔진 구현 | 🔲 대기 |
| 4 | 화면 디자이너 저장 포맷 변경 | 🔲 대기 |
| 5 | 컴포넌트별 defaultConfig 정의 | 🔲 대기 |

View File

@ -1,496 +0,0 @@
# 컴포넌트 관리 시스템 리팩토링 제안서
## 1. 현재 문제점
### 1.1 핵심 문제
```
컴포넌트 오류 발생 시 → 코드 수정 → 해당 컴포넌트 사용하는 모든 화면에 영향
```
현재 구조에서는:
- 컴포넌트 코드가 **프론트엔드에 하드코딩**되어 있음
- 설정이 **JSONB로 각 화면마다 중복 저장**됨
- 컴포넌트 수정 시 **개별 화면 데이터 마이그레이션 필요**
### 1.2 구체적 문제 사례
```
예: v2-table-list 컴포넌트의 pagination 구조 변경 시
현재 방식:
1. 프론트엔드 코드 수정
2. screen_layouts 테이블의 모든 해당 컴포넌트 JSON 수정 필요
3. 100개 화면에서 사용 중이면 100개 레코드 마이그레이션
4. 테스트 및 검증 공수 발생
```
---
## 2. 개선 방안 비교
### 방안 1: URL 기반 코드 참조 + 설정 분리
#### 개념
```
┌─────────────────────────────────────────────────────────────┐
│ 컴포넌트 코드 (URL 참조) │
├─────────────────────────────────────────────────────────────┤
│ 경로: /lib/registry/components/v2-table-list/ │
│ - 상대경로: ./v2-table-list │
│ - 절대경로: @/lib/registry/components/v2-table-list │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 설정 분리 저장 │
├────────────────────────┬────────────────────────────────────┤
│ 공용 설정 (1개) │ 회사별 설정 (N개) │
│ │ │
│ - 기본 pagination │ - A회사: pageSize=20 │
│ - 기본 toolbar │ - B회사: pageSize=50 │
│ - 기본 columns 구조 │ - C회사: 특수 컬럼 추가 │
└────────────────────────┴────────────────────────────────────┘
```
#### 데이터베이스 구조 (예시)
```sql
-- 1. 컴포넌트 정의 테이블 (공용)
CREATE TABLE component_definitions (
component_id VARCHAR(50) PRIMARY KEY, -- 'v2-table-list'
component_path VARCHAR(200) NOT NULL, -- '@/lib/registry/components/v2-table-list'
component_name VARCHAR(100), -- '테이블 리스트'
category VARCHAR(50), -- 'display'
version VARCHAR(20), -- '2.1.0'
default_config JSONB, -- 기본 설정 (공용)
is_active CHAR(1) DEFAULT 'Y'
);
-- 2. 회사별 컴포넌트 설정 오버라이드
CREATE TABLE company_component_config (
id SERIAL PRIMARY KEY,
company_code VARCHAR(50) NOT NULL,
component_id VARCHAR(50) REFERENCES component_definitions(component_id),
config_override JSONB, -- 회사별 오버라이드 설정
UNIQUE(company_code, component_id)
);
-- 3. 화면 레이아웃 (간소화)
CREATE TABLE screen_layouts (
layout_id SERIAL PRIMARY KEY,
screen_id INTEGER,
component_id VARCHAR(50) REFERENCES component_definitions(component_id),
position_x INTEGER,
position_y INTEGER,
width INTEGER,
height INTEGER,
instance_config JSONB -- 해당 인스턴스만의 설정 (최소화)
);
```
#### 설정 병합 로직
```typescript
// 설정 우선순위: 인스턴스 설정 > 회사 설정 > 공용 기본 설정
function getComponentConfig(componentId: string, companyCode: string, instanceConfig: any) {
const defaultConfig = await getDefaultConfig(componentId); // 공용
const companyConfig = await getCompanyConfig(componentId, companyCode); // 회사별
return deepMerge(defaultConfig, companyConfig, instanceConfig);
}
```
#### 장점
| 장점 | 설명 |
|-----|-----|
| **코드 단일 관리** | 컴포넌트 코드는 한 곳에서만 관리 (URL 참조) |
| **설정 계층화** | 공용 → 회사 → 인스턴스 순으로 설정 상속 |
| **유연한 커스터마이징** | 회사별로 다른 기본값 설정 가능 |
| **마이그레이션 최소화** | 공용 설정 변경 시 한 곳만 수정 |
| **버전 관리** | 컴포넌트 버전별 호환성 관리 가능 |
#### 단점
| 단점 | 설명 |
|-----|-----|
| **복잡한 병합 로직** | 3단계 설정 병합 로직 필요 |
| **런타임 오버헤드** | 설정 조회 시 여러 테이블 JOIN |
| **디버깅 어려움** | 최종 설정이 어디서 온 것인지 추적 필요 |
| **기존 데이터 마이그레이션** | 기존 JSONB 데이터를 분리 저장 필요 |
---
### 방안 2: 정형화된 테이블 (컬럼 파싱)
#### 개념
```
┌─────────────────────────────────────────────────────────────┐
│ 컴포넌트별 전용 테이블 생성 │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────┼─────────────────────┐
▼ ▼ ▼
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│ table_list │ │ button_config │ │ split_panel │
│ _components │ │ _components │ │ _components │
├───────────────┤ ├───────────────┤ ├───────────────┤
│ id │ │ id │ │ id │
│ screen_id │ │ screen_id │ │ screen_id │
│ table_name │ │ action_type │ │ left_table │
│ page_size │ │ target_screen │ │ right_table │
│ show_checkbox │ │ button_text │ │ split_ratio │
│ show_excel │ │ icon │ │ transfer_type │
│ ... │ │ ... │ │ ... │
└───────────────┘ └───────────────┘ └───────────────┘
```
#### 데이터베이스 구조 (예시)
```sql
-- 1. 공통 컴포넌트 메타 테이블
CREATE TABLE component_instances (
instance_id SERIAL PRIMARY KEY,
screen_id INTEGER NOT NULL,
component_type VARCHAR(50) NOT NULL, -- 'table-list', 'button', 'split-panel'
position_x INTEGER,
position_y INTEGER,
width INTEGER,
height INTEGER,
company_code VARCHAR(50)
);
-- 2. 테이블 리스트 컴포넌트 전용 테이블
CREATE TABLE component_table_list (
id SERIAL PRIMARY KEY,
instance_id INTEGER REFERENCES component_instances(instance_id),
table_name VARCHAR(100),
page_size INTEGER DEFAULT 20,
show_checkbox BOOLEAN DEFAULT true,
checkbox_multiple BOOLEAN DEFAULT true,
show_excel BOOLEAN DEFAULT true,
show_refresh BOOLEAN DEFAULT true,
show_search BOOLEAN DEFAULT true,
header_style VARCHAR(20) DEFAULT 'default',
row_height VARCHAR(20) DEFAULT 'normal',
auto_load BOOLEAN DEFAULT true
);
-- 3. 테이블 리스트 컬럼 설정 테이블
CREATE TABLE component_table_list_columns (
id SERIAL PRIMARY KEY,
table_list_id INTEGER REFERENCES component_table_list(id),
column_name VARCHAR(100) NOT NULL,
display_name VARCHAR(100),
visible BOOLEAN DEFAULT true,
sortable BOOLEAN DEFAULT true,
searchable BOOLEAN DEFAULT false,
width INTEGER,
align VARCHAR(10) DEFAULT 'left',
format VARCHAR(20) DEFAULT 'text',
display_order INTEGER DEFAULT 0,
fixed VARCHAR(10), -- 'left', 'right', null
editable BOOLEAN DEFAULT true
);
-- 4. 버튼 컴포넌트 전용 테이블
CREATE TABLE component_button (
id SERIAL PRIMARY KEY,
instance_id INTEGER REFERENCES component_instances(instance_id),
button_text VARCHAR(100),
action_type VARCHAR(50), -- 'save', 'delete', 'navigate', 'popup'
target_screen_id INTEGER,
target_url VARCHAR(500),
numbering_rule_id VARCHAR(100),
variant VARCHAR(20) DEFAULT 'default',
size VARCHAR(10) DEFAULT 'md',
icon VARCHAR(50)
);
-- 5. 분할 패널 컴포넌트 전용 테이블
CREATE TABLE component_split_panel (
id SERIAL PRIMARY KEY,
instance_id INTEGER REFERENCES component_instances(instance_id),
left_table_name VARCHAR(100),
right_table_name VARCHAR(100),
split_ratio INTEGER DEFAULT 50,
transfer_enabled BOOLEAN DEFAULT true,
transfer_button_label VARCHAR(100)
);
```
#### 장점
| 장점 | 설명 |
|-----|-----|
| **타입 안정성** | 각 컬럼이 명확한 데이터 타입 |
| **SQL 쿼리 용이** | `WHERE page_size > 50` 같은 직접 쿼리 가능 |
| **인덱스 최적화** | 특정 컬럼에 인덱스 생성 가능 |
| **데이터 무결성** | 외래키, CHECK 제약 조건 적용 가능 |
| **일괄 수정 용이** | `UPDATE component_table_list SET page_size = 30 WHERE ...` |
| **명확한 스키마** | 어떤 설정이 있는지 테이블 구조로 명확히 파악 |
#### 단점
| 단점 | 설명 |
|-----|-----|
| **테이블 폭발** | 70+ 컴포넌트 × 하위 설정 = 100개 이상 테이블 |
| **스키마 변경 필수** | 새 설정 추가 시 ALTER TABLE 필요 |
| **JOIN 복잡도** | 화면 로드 시 여러 테이블 JOIN |
| **유연성 저하** | 임시/실험적 설정 저장 어려움 |
| **마이그레이션 대규모** | 기존 JSONB → 정형 테이블 대규모 작업 |
---
## 3. 상세 비교 분석
### 3.1 개발 공수 비교
| 항목 | 방안 1 (URL + 설정 분리) | 방안 2 (정형 테이블) |
|-----|------------------------|-------------------|
| 초기 설계 | 중간 | 높음 (테이블 설계) |
| 마이그레이션 | 중간 | 매우 높음 |
| 프론트엔드 수정 | 중간 | 높음 (쿼리 변경) |
| 백엔드 수정 | 중간 | 높음 (ORM/쿼리) |
| 테스트 | 중간 | 높음 |
### 3.2 유지보수 비교
| 항목 | 방안 1 | 방안 2 |
|-----|-------|-------|
| 컴포넌트 버그 수정 | 쉬움 (코드만) | 쉬움 (코드만) |
| 새 설정 추가 | 쉬움 (JSON 확장) | 어려움 (ALTER TABLE) |
| 일괄 설정 변경 | 중간 (JSON 쿼리) | 쉬움 (SQL UPDATE) |
| 디버깅 | 중간 | 쉬움 (명확한 컬럼) |
### 3.3 성능 비교
| 항목 | 방안 1 | 방안 2 |
|-----|-------|-------|
| 읽기 성능 | 중간 (설정 병합) | 좋음 (직접 조회) |
| 쓰기 성능 | 좋음 (단일 JSONB) | 중간 (여러 테이블) |
| 검색 성능 | 나쁨 (JSONB 검색) | 좋음 (인덱스) |
| 캐싱 | 좋음 (계층 캐싱) | 중간 |
---
## 4. 하이브리드 방안 제안
두 방안의 장점을 결합한 **하이브리드 접근법**:
### 4.1 구조
```
┌─────────────────────────────────────────────────────────────┐
│ 컴포넌트 메타 (정형 테이블) │
├─────────────────────────────────────────────────────────────┤
│ component_id | path | name | category | version │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 설정 계층 (공용 → 회사 → 인스턴스) │
├────────────────────────┬────────────────────────────────────┤
│ 공용 기본 설정 (JSONB) │ 회사별 오버라이드 (JSONB) │
└────────────────────────┴────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 핵심 설정만 정형 컬럼 (자주 검색/수정) │
├─────────────────────────────────────────────────────────────┤
│ table_name | page_size | is_active | ... │
│ + extra_config JSONB (나머지 설정) │
└─────────────────────────────────────────────────────────────┘
```
### 4.2 데이터베이스 구조
```sql
-- 1. 컴포넌트 정의 (공용)
CREATE TABLE component_definitions (
component_id VARCHAR(50) PRIMARY KEY,
component_path VARCHAR(200) NOT NULL,
component_name VARCHAR(100),
category VARCHAR(50),
version VARCHAR(20),
default_config JSONB, -- 기본 설정
schema_version INTEGER DEFAULT 1, -- 설정 스키마 버전
is_active CHAR(1) DEFAULT 'Y'
);
-- 2. 컴포넌트 인스턴스 (핵심 필드 정형화 + 나머지 JSONB)
CREATE TABLE component_instances (
instance_id SERIAL PRIMARY KEY,
screen_id INTEGER NOT NULL,
company_code VARCHAR(50) NOT NULL,
component_id VARCHAR(50) REFERENCES component_definitions(component_id),
-- 공통 정형 필드 (자주 검색/수정)
position_x INTEGER,
position_y INTEGER,
width INTEGER,
height INTEGER,
is_visible BOOLEAN DEFAULT true,
display_order INTEGER DEFAULT 0,
-- 컴포넌트 타입별 핵심 필드 (자주 검색/수정)
target_table VARCHAR(100), -- table-list, split-panel 등
action_type VARCHAR(50), -- button
-- 나머지 상세 설정 (유연성)
config_override JSONB, -- 인스턴스별 설정 오버라이드
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- 3. 회사별 컴포넌트 기본 설정
CREATE TABLE company_component_defaults (
id SERIAL PRIMARY KEY,
company_code VARCHAR(50) NOT NULL,
component_id VARCHAR(50) REFERENCES component_definitions(component_id),
config_override JSONB, -- 회사별 기본값 오버라이드
UNIQUE(company_code, component_id)
);
-- 인덱스 최적화
CREATE INDEX idx_instances_screen ON component_instances(screen_id);
CREATE INDEX idx_instances_company ON component_instances(company_code);
CREATE INDEX idx_instances_component ON component_instances(component_id);
CREATE INDEX idx_instances_target_table ON component_instances(target_table);
```
### 4.3 설정 조회 로직
```typescript
async function getComponentFullConfig(
instanceId: number,
companyCode: string
): Promise<ComponentConfig> {
// 1. 인스턴스 + 컴포넌트 정의 조회 (단일 쿼리)
const result = await query(`
SELECT
i.*,
d.default_config,
c.config_override as company_override
FROM component_instances i
JOIN component_definitions d ON i.component_id = d.component_id
LEFT JOIN company_component_defaults c
ON c.component_id = i.component_id
AND c.company_code = i.company_code
WHERE i.instance_id = $1
`, [instanceId]);
// 2. 설정 병합 (공용 → 회사 → 인스턴스)
return deepMerge(
result.default_config, // 공용 기본값
result.company_override, // 회사별 오버라이드
result.config_override // 인스턴스별 오버라이드
);
}
```
### 4.4 일괄 수정 예시
```sql
-- 특정 테이블을 사용하는 모든 컴포넌트의 page_size 변경
UPDATE component_instances
SET config_override = jsonb_set(
COALESCE(config_override, '{}'),
'{pagination,pageSize}',
'30'
)
WHERE target_table = 'user_info';
-- 특정 회사의 모든 테이블 리스트 기본값 변경
UPDATE company_component_defaults
SET config_override = jsonb_set(
COALESCE(config_override, '{}'),
'{pagination,pageSize}',
'50'
)
WHERE company_code = 'COMPANY_A'
AND component_id = 'v2-table-list';
```
---
## 5. 권장사항
### 5.1 단기 (1-2주)
**방안 1 (URL + 설정 분리)** 권장
이유:
- 현재 JSONB 구조와 호환성 유지
- 마이그레이션 공수 최소화
- 점진적 적용 가능
### 5.2 장기 (1-2개월)
**하이브리드 방안** 권장
이유:
- 자주 검색/수정되는 핵심 필드만 정형화
- 나머지는 JSONB로 유연성 유지
- 성능과 유연성의 균형
---
## 6. 마이그레이션 로드맵
### Phase 1: 컴포넌트 정의 분리 (1주)
```sql
-- 기존 컴포넌트를 component_definitions로 추출
INSERT INTO component_definitions (component_id, component_path, default_config)
SELECT DISTINCT
componentType,
CONCAT('@/lib/registry/components/', componentType),
'{}' -- 기본값은 코드에서 정의
FROM (
SELECT properties->>'componentType' as componentType
FROM screen_layouts
WHERE properties->>'componentType' IS NOT NULL
) t;
```
### Phase 2: 회사별 설정 분리 (1주)
```typescript
// 각 회사별 공통 패턴 분석 후 company_component_defaults 생성
async function extractCompanyDefaults(companyCode: string) {
// 해당 회사의 컴포넌트 사용 패턴 분석
// 가장 많이 사용되는 설정을 기본값으로 추출
}
```
### Phase 3: 인스턴스 설정 최소화 (2주)
```typescript
// 인스턴스별 설정에서 기본값과 동일한 부분 제거
async function minimizeInstanceConfig(instanceId: number) {
const fullConfig = currentConfig;
const defaultConfig = getDefaultConfig();
const companyConfig = getCompanyConfig();
// 차이나는 부분만 저장
const minimalConfig = getDiff(fullConfig, merge(defaultConfig, companyConfig));
await saveInstanceConfig(instanceId, minimalConfig);
}
```
---
## 7. 결론
| 방안 | 적합한 상황 |
|-----|-----------|
| **방안 1 (URL + 설정 분리)** | 빠른 개선이 필요하고, 현재 구조와의 호환성 중요 시 |
| **방안 2 (정형 테이블)** | 완전한 재설계가 가능하고, 장기적 유지보수 최우선 시 |
| **하이브리드** | 두 방안의 장점을 모두 원하고, 충분한 개발 리소스 있을 시 |
**권장**: 단기적으로 **방안 1**을 적용하고, 안정화 후 **하이브리드**로 전환

View File

@ -1,672 +0,0 @@
# 컴포넌트 시스템 마이그레이션 계획서
## 1. 개요
### 1.1 목적
- 현재 JSON 기반 컴포넌트 관리 시스템을 URL 참조 + Zod 스키마 기반으로 전환
- 컴포넌트 코드 수정 시 모든 회사에 즉시 반영되는 구조로 개선
- JSON 구조 표준화 및 런타임 검증 체계 구축
### 1.2 핵심 원칙
1. **화면 동일성 유지**: 마이그레이션 전후 렌더링 결과가 100% 동일해야 함
2. **안전한 테스트**: 기존 테이블 수정 없이 새 테이블에서 테스트
3. **롤백 가능**: 문제 발생 시 즉시 원복 가능한 구조
### 1.3 현재 상태 (DB 분석 결과)
| 항목 | 수치 |
|-----|-----|
| 총 레코드 | 7,170개 |
| 화면 수 | 1,363개 |
| 회사 수 | 15개 |
| 컴포넌트 타입 | 50개 |
---
## 2. 테이블 구조
### 2.1 기존 테이블: `screen_layouts`
```sql
CREATE TABLE screen_layouts (
layout_id SERIAL PRIMARY KEY,
screen_id INTEGER REFERENCES screen_definitions(screen_id),
component_type VARCHAR(50) NOT NULL,
component_id VARCHAR(100) UNIQUE NOT NULL,
parent_id VARCHAR(100),
position_x INTEGER NOT NULL,
position_y INTEGER NOT NULL,
width INTEGER NOT NULL,
height INTEGER NOT NULL,
properties JSONB, -- 전체 설정이 포함됨
display_order INTEGER DEFAULT 0,
layout_type VARCHAR(50),
layout_config JSONB,
zones_config JSONB,
zone_id VARCHAR(100)
);
```
### 2.2 신규 테이블: `screen_layouts_v2` (테스트용)
```sql
CREATE TABLE screen_layouts_v2 (
layout_id SERIAL PRIMARY KEY,
screen_id INTEGER REFERENCES screen_definitions(screen_id),
component_type VARCHAR(50) NOT NULL,
component_id VARCHAR(100) UNIQUE NOT NULL,
parent_id VARCHAR(100),
position_x INTEGER NOT NULL,
position_y INTEGER NOT NULL,
width INTEGER NOT NULL,
height INTEGER NOT NULL,
-- 변경된 부분
component_ref VARCHAR(100) NOT NULL, -- 컴포넌트 URL 참조 (예: "button-primary")
config_overrides JSONB DEFAULT '{}', -- 기본값과 다른 설정만 저장
-- 기존 필드 유지
properties JSONB, -- 기존 호환용 (마이그레이션 완료 후 제거)
display_order INTEGER DEFAULT 0,
layout_type VARCHAR(50),
layout_config JSONB,
zones_config JSONB,
zone_id VARCHAR(100),
-- 마이그레이션 추적
migrated_at TIMESTAMPTZ,
migration_status VARCHAR(20) DEFAULT 'pending' -- pending, success, failed
);
```
---
## 3. 마이그레이션 단계
### 3.1 Phase 1: 테이블 생성 및 데이터 복사
```sql
-- Step 1: 새 테이블 생성
CREATE TABLE screen_layouts_v2 AS
SELECT * FROM screen_layouts;
-- Step 2: 새 컬럼 추가
ALTER TABLE screen_layouts_v2
ADD COLUMN component_ref VARCHAR(100),
ADD COLUMN config_overrides JSONB DEFAULT '{}',
ADD COLUMN migrated_at TIMESTAMPTZ,
ADD COLUMN migration_status VARCHAR(20) DEFAULT 'pending';
-- Step 3: component_ref 초기값 설정
UPDATE screen_layouts_v2
SET component_ref = properties->>'componentType'
WHERE properties->>'componentType' IS NOT NULL;
```
### 3.2 Phase 2: Zod 스키마 정의
각 컴포넌트별 스키마 파일 생성:
```
frontend/lib/schemas/components/
├── button-primary.schema.ts
├── text-input.schema.ts
├── table-list.schema.ts
├── select-basic.schema.ts
├── date-input.schema.ts
├── file-upload.schema.ts
├── tabs-widget.schema.ts
├── split-panel-layout.schema.ts
├── flow-widget.schema.ts
└── ... (50개)
```
### 3.3 Phase 3: 차이값 추출
```typescript
// 마이그레이션 스크립트 (backend-node)
async function extractConfigDiff(layoutId: number) {
const layout = await getLayoutById(layoutId);
const componentType = layout.properties?.componentType;
if (!componentType) {
return { status: 'skip', reason: 'no componentType' };
}
// 스키마에서 기본값 가져오기
const schema = getSchemaByType(componentType);
const defaults = schema.parse({});
// 현재 저장된 설정
const currentConfig = layout.properties?.componentConfig || {};
// 기본값과 다른 것만 추출
const overrides = extractDifferences(defaults, currentConfig);
return {
status: 'success',
component_ref: componentType,
config_overrides: overrides,
original_config: currentConfig
};
}
```
### 3.4 Phase 4: 렌더링 동일성 검증
```typescript
// 검증 스크립트
async function verifyRenderingEquality(layoutId: number) {
// 기존 방식으로 로드
const originalConfig = await loadOriginalConfig(layoutId);
// 새 방식으로 로드 (기본값 + overrides 병합)
const migratedConfig = await loadMigratedConfig(layoutId);
// 깊은 비교
const isEqual = deepEqual(originalConfig, migratedConfig);
if (!isEqual) {
const diff = getDifferences(originalConfig, migratedConfig);
console.error(`Layout ${layoutId} 불일치:`, diff);
return false;
}
return true;
}
```
---
## 4. 컴포넌트별 분석
### 4.1 상위 10개 컴포넌트 (우선 처리)
| 순위 | 컴포넌트 | 개수 | JSON 일관성 | 복잡도 |
|-----|---------|-----|------------|-------|
| 1 | button-primary | 1,527 | 100% | 낮음 |
| 2 | text-input | 700 | 95% | 낮음 |
| 3 | table-search-widget | 353 | 100% | 중간 |
| 4 | table-list | 280 | 84% | 높음 |
| 5 | file-upload | 143 | 100% | 중간 |
| 6 | select-basic | 129 | 100% | 낮음 |
| 7 | split-panel-layout | 129 | 100% | 높음 |
| 8 | date-input | 116 | 100% | 낮음 |
| 9 | unified-list | 97 | 100% | 높음 |
| 10 | number-input | 87 | 100% | 낮음 |
### 4.2 발견된 문제점
#### 문제 1: componentType ≠ componentConfig.type
```sql
-- 166개 불일치 발견
SELECT COUNT(*) FROM screen_layouts
WHERE properties->>'componentType' = 'text-input'
AND properties->'componentConfig'->>'type' != 'text-input';
```
**해결**: 마이그레이션 시 `componentConfig.type``componentType`으로 통일
#### 문제 2: 키 누락 (table-list)
```sql
-- 44개 (16%) pagination/checkbox 없음
SELECT COUNT(*) FROM screen_layouts
WHERE properties->>'componentType' = 'table-list'
AND properties->'componentConfig' ? 'pagination' = false;
```
**해결**: 누락된 키는 기본값으로 자동 채움 (Zod 스키마 활용)
---
## 5. Zod 스키마 예시
### 5.1 button-primary
```typescript
// frontend/lib/schemas/components/button-primary.schema.ts
import { z } from "zod";
export const buttonActionSchema = z.object({
type: z.enum([
"save", "modal", "openModalWithData", "edit", "delete",
"control", "excel_upload", "excel_download", "transferData",
"copy", "code_merge", "view_table_history", "quickInsert",
"openRelatedModal", "operation_control", "geolocation",
"update_field", "search", "submit", "cancel", "add",
"navigate", "empty_vehicle", "reset", "close"
]).default("save"),
targetScreenId: z.number().optional(),
successMessage: z.string().optional(),
errorMessage: z.string().optional(),
});
export const buttonPrimarySchema = z.object({
text: z.string().default("저장"),
type: z.literal("button-primary").default("button-primary"),
actionType: z.enum(["button", "submit", "reset"]).default("button"),
variant: z.enum(["primary", "secondary", "danger"]).default("primary"),
webType: z.literal("button").default("button"),
action: buttonActionSchema.optional(),
});
export type ButtonPrimaryConfig = z.infer<typeof buttonPrimarySchema>;
export const buttonPrimaryDefaults = buttonPrimarySchema.parse({});
```
### 5.2 table-list
```typescript
// frontend/lib/schemas/components/table-list.schema.ts
import { z } from "zod";
export const paginationSchema = z.object({
enabled: z.boolean().default(true),
pageSize: z.number().default(20),
showSizeSelector: z.boolean().default(true),
showPageInfo: z.boolean().default(true),
pageSizeOptions: z.array(z.number()).default([10, 20, 50, 100]),
});
export const checkboxSchema = z.object({
enabled: z.boolean().default(true),
multiple: z.boolean().default(true),
position: z.enum(["left", "right"]).default("left"),
selectAll: z.boolean().default(true),
});
export const tableListSchema = z.object({
type: z.literal("table-list").default("table-list"),
webType: z.literal("table").default("table"),
displayMode: z.enum(["table", "card"]).default("table"),
showHeader: z.boolean().default(true),
showFooter: z.boolean().default(true),
autoLoad: z.boolean().default(true),
autoWidth: z.boolean().default(true),
stickyHeader: z.boolean().default(false),
height: z.enum(["auto", "fixed", "viewport"]).default("auto"),
columns: z.array(z.any()).default([]),
pagination: paginationSchema.default({}),
checkbox: checkboxSchema.default({}),
horizontalScroll: z.object({
enabled: z.boolean().default(false),
}).default({}),
filter: z.object({
enabled: z.boolean().default(false),
filters: z.array(z.any()).default([]),
}).default({}),
actions: z.object({
showActions: z.boolean().default(false),
actions: z.array(z.any()).default([]),
bulkActions: z.boolean().default(false),
bulkActionList: z.array(z.string()).default([]),
}).default({}),
tableStyle: z.object({
theme: z.enum(["default", "striped", "bordered", "minimal"]).default("default"),
headerStyle: z.enum(["default", "dark", "light"]).default("default"),
rowHeight: z.enum(["compact", "normal", "comfortable"]).default("normal"),
alternateRows: z.boolean().default(false),
hoverEffect: z.boolean().default(true),
borderStyle: z.enum(["none", "light", "heavy"]).default("light"),
}).default({}),
});
export type TableListConfig = z.infer<typeof tableListSchema>;
export const tableListDefaults = tableListSchema.parse({});
```
---
## 6. 렌더링 로직 변경
### 6.1 현재 방식
```typescript
// DynamicComponentRenderer.tsx (현재)
function renderComponent(layout: ScreenLayout) {
const config = layout.properties?.componentConfig || {};
return <Component config={config} />;
}
```
### 6.2 변경 후 방식
```typescript
// DynamicComponentRenderer.tsx (변경 후)
function renderComponent(layout: ScreenLayoutV2) {
const componentRef = layout.component_ref;
const overrides = layout.config_overrides || {};
// 스키마에서 기본값 가져오기
const schema = getSchemaByType(componentRef);
const defaults = schema.parse({});
// 기본값 + overrides 병합
const config = deepMerge(defaults, overrides);
return <Component config={config} />;
}
```
---
## 7. 테스트 계획
### 7.1 단위 테스트
```typescript
describe("ComponentMigration", () => {
test("button-primary 기본값 병합", () => {
const overrides = { text: "등록" };
const result = mergeWithDefaults("button-primary", overrides);
expect(result.text).toBe("등록"); // override 값
expect(result.variant).toBe("primary"); // 기본값
expect(result.actionType).toBe("button"); // 기본값
});
test("table-list 누락된 키 복구", () => {
const overrides = { columns: [...] }; // pagination 없음
const result = mergeWithDefaults("table-list", overrides);
expect(result.pagination.enabled).toBe(true);
expect(result.pagination.pageSize).toBe(20);
});
});
```
### 7.2 통합 테스트
```typescript
describe("RenderingEquality", () => {
test("모든 레이아웃 렌더링 동일성 검증", async () => {
const layouts = await getAllLayouts();
for (const layout of layouts) {
const original = await renderOriginal(layout);
const migrated = await renderMigrated(layout);
expect(migrated).toEqual(original);
}
});
});
```
---
## 8. 롤백 계획
### 8.1 즉시 롤백
```sql
-- 마이그레이션 실패 시 원래 properties 사용
UPDATE screen_layouts_v2
SET migration_status = 'rollback'
WHERE layout_id = ?;
```
### 8.2 전체 롤백
```sql
-- 기존 테이블로 복귀
DROP TABLE screen_layouts_v2;
-- 기존 screen_layouts 계속 사용
```
---
## 9. 작업 순서
### Step 1: 테이블 생성 및 데이터 복사
- [ ] `screen_layouts_v2` 테이블 생성
- [ ] 기존 데이터 복사
- [ ] 새 컬럼 추가
### Step 2: Zod 스키마 정의 (상위 10개)
- [ ] button-primary
- [ ] text-input
- [ ] table-search-widget
- [ ] table-list
- [ ] file-upload
- [ ] select-basic
- [ ] split-panel-layout
- [ ] date-input
- [ ] unified-list
- [ ] number-input
### Step 3: 마이그레이션 스크립트
- [ ] 차이값 추출 함수
- [ ] 렌더링 동일성 검증 함수
- [ ] 배치 마이그레이션 스크립트
### Step 4: 테스트
- [ ] 단위 테스트
- [ ] 통합 테스트
- [ ] 화면 렌더링 비교
### Step 5: 적용
- [ ] 프론트엔드 렌더링 로직 수정
- [ ] 백엔드 저장 로직 수정
- [ ] 기존 테이블 교체
---
## 10. 예상 일정
| 단계 | 작업 | 예상 기간 |
|-----|-----|---------|
| 1 | 테이블 생성 및 복사 | 1일 |
| 2 | 상위 10개 스키마 정의 | 3일 |
| 3 | 마이그레이션 스크립트 | 3일 |
| 4 | 테스트 및 검증 | 3일 |
| 5 | 나머지 40개 스키마 | 5일 |
| 6 | 전체 마이그레이션 | 2일 |
| 7 | 프론트엔드 적용 | 2일 |
| **총계** | | **약 19일 (4주)** |
---
## 11. 주의사항
1. **기존 DB 수정 금지**: 모든 테스트는 `screen_layouts_v2`에서만 진행
2. **화면 동일성 우선**: 렌더링 결과가 다르면 마이그레이션 중단
3. **단계별 검증**: 각 단계 완료 후 검증 통과해야 다음 단계 진행
4. **롤백 대비**: 언제든 기존 시스템으로 복귀 가능해야 함
---
## 12. 마이그레이션 실행 결과 (2026-01-27)
### 12.1 실행 환경
```
테이블: screen_layouts_v2 (테스트용)
백업: screen_layouts_backup_20260127
원본: screen_layouts (변경 없음)
```
### 12.2 마이그레이션 결과
| 상태 | 개수 | 비율 |
|-----|-----|-----|
| **success** | 5,805 | 81.0% |
| **skip** | 1,365 | 19.0% (metadata) |
| **pending** | 0 | 0% |
| **fail** | 0 | 0% |
### 12.3 데이터 절약량
| 항목 | 수치 |
|-----|-----|
| 원본 총 크기 | **5.81 MB** |
| config_overrides 총 크기 | **2.54 MB** |
| **절약량** | **3.27 MB (56.2%)** |
### 12.4 컴포넌트별 결과
| 컴포넌트 | 개수 | 원본(bytes) | override(bytes) | 절약률 |
|---------|-----|------------|-----------------|-------|
| text-input | 1,797 | 701 | 143 | **79.6%** |
| button-primary | 1,527 | 939 | 218 | **76.8%** |
| table-search-widget | 353 | 635 | 150 | **76.4%** |
| select-basic | 287 | 660 | 172 | **73.9%** |
| table-list | 280 | 2,690 | 2,020 | 24.9% |
| file-upload | 143 | 1,481 | 53 | **96.4%** |
| date-input | 137 | 628 | 111 | **82.3%** |
| split-panel-layout | 129 | 2,556 | 2,040 | 20.2% |
| number-input | 115 | 646 | 121 | **81.2%** |
### 12.5 config_overrides 구조
```json
{
"_originalKeys": ["text", "type", "action", "variant", "webType", "actionType"],
"text": "등록",
"action": {
"type": "modal",
"targetScreenId": 26
}
}
```
- `_originalKeys`: 원본에 있던 키 목록 (복원 시 사용)
- 나머지: 기본값과 다른 설정만 저장
### 12.6 렌더링 복원 로직
```typescript
function reconstructConfig(componentRef: string, overrides: any): any {
const defaults = getDefaultsByType(componentRef);
const originalKeys = overrides._originalKeys || Object.keys(defaults);
const result = {};
for (const key of originalKeys) {
if (overrides.hasOwnProperty(key) && key !== '_originalKeys') {
result[key] = overrides[key];
} else if (defaults.hasOwnProperty(key)) {
result[key] = defaults[key];
}
}
return result;
}
```
### 12.7 검증 결과
- **button-primary**: 1,527개 전체 검증 통과 (100%)
- **text-input**: 1,797개 전체 검증 통과 (100%)
- **table-list**: 280개 전체 검증 통과 (100%)
- **기타 모든 컴포넌트**: 전체 검증 통과 (100%)
### 12.8 다음 단계
1. [x] ~~Zod 스키마 파일 생성~~ ✅ 완료
2. [x] ~~백엔드 API에서 config_overrides 기반 응답 추가~~ ✅ 완료
3. [ ] 프론트엔드에서 V2 API 호출 테스트
4. [ ] 실제 화면에서 렌더링 테스트
5. [ ] screen_layouts 테이블 교체 (운영 적용)
---
## 13. Zod 스키마 파일 생성 완료 (2026-01-27)
### 13.1 생성된 파일 목록
```
frontend/lib/schemas/components/
├── index.ts # 메인 인덱스 + 복원 유틸리티
├── button-primary.ts # 버튼 스키마
├── text-input.ts # 텍스트 입력 스키마
├── table-list.ts # 테이블 리스트 스키마
├── select-basic.ts # 셀렉트 스키마
├── date-input.ts # 날짜 입력 스키마
├── file-upload.ts # 파일 업로드 스키마
└── number-input.ts # 숫자 입력 스키마
```
### 13.2 주요 유틸리티 함수
```typescript
// 컴포넌트 기본값 조회
import { getComponentDefaults } from "@/lib/schemas/components";
const defaults = getComponentDefaults("button-primary");
// 설정 복원 (기본값 + overrides 병합)
import { reconstructConfig } from "@/lib/schemas/components";
const fullConfig = reconstructConfig("button-primary", overrides);
// 차이값 추출 (저장 시 사용)
import { extractConfigDiff } from "@/lib/schemas/components";
const diff = extractConfigDiff("button-primary", currentConfig);
```
### 13.3 componentDefaults 레지스트리
50개 컴포넌트의 기본값이 `componentDefaults` 맵에 등록됨:
- button-primary, v2-button-primary
- text-input, number-input, date-input
- select-basic, checkbox-basic, radio-basic
- table-list, v2-table-list
- tabs-widget, v2-tabs-widget
- split-panel-layout, v2-split-panel-layout
- flow-widget, category-manager
- 기타 40+ 컴포넌트
---
## 14. 백엔드 API 추가 완료 (2026-01-27)
### 14.1 수정된 파일
| 파일 | 변경 내용 |
|-----|----------|
| `backend-node/src/utils/componentDefaults.ts` | 컴포넌트 기본값 + 복원 유틸리티 신규 생성 |
| `backend-node/src/services/screenManagementService.ts` | `getLayoutV2()` 함수 추가 |
| `backend-node/src/controllers/screenManagementController.ts` | `getLayoutV2` 컨트롤러 추가 |
| `backend-node/src/routes/screenManagementRoutes.ts` | `/screens/:screenId/layout-v2` 라우트 추가 |
### 14.2 새로운 API 엔드포인트
```
GET /api/screen-management/screens/:screenId/layout-v2
```
**응답 구조**: 기존 `getLayout`과 동일
**차이점**:
- `screen_layouts_v2` 테이블에서 조회
- `migration_status = 'success'`인 레코드는 `config_overrides` + 기본값 병합
- 마이그레이션 안 된 레코드는 기존 `properties.componentConfig` 사용
### 14.3 복원 로직 흐름
```
1. screen_layouts_v2에서 조회
2. migration_status 확인
├─ 'success': reconstructConfig(componentRef, configOverrides)
└─ 기타: 기존 properties.componentConfig 사용
3. 최신 inputType 정보 병합 (table_type_columns)
4. 전체 componentConfig 반환
```
### 14.4 테스트 방법
```bash
# 기존 API
curl "http://localhost:8080/api/screen-management/screens/1/layout" -H "Authorization: Bearer ..."
# V2 API
curl "http://localhost:8080/api/screen-management/screens/1/layout-v2" -H "Authorization: Bearer ..."
```
두 응답의 `components[].componentConfig`가 동일해야 함
---
*작성일: 2026-01-27*
*작성자: AI Assistant*
*버전: 1.1 (마이그레이션 실행 결과 추가)*

View File

@ -1,233 +0,0 @@
ㅡㄹ ㅣ # 컴포넌트 URL 시스템 구현 완료
## 실행 일시: 2026-01-27
## 1. 목표
- 컴포넌트 코드 수정 시 **모든 회사에 즉시 반영**
- 회사별 고유 설정은 **JSON으로 안전하게 관리** (Zod 검증) ✅
- 기존 화면 **100% 동일하게 렌더링** 보장 ✅
---
## 2. 완료된 작업
### 2.1 DB 테이블 생성
- `screen_layouts_v3` 테이블 생성 완료
- 4,414개 레코드 마이그레이션 완료
### 2.2 파일 생성/수정
| 파일 | 상태 |
|-----|-----|
| `frontend/lib/schemas/componentConfig.ts` | ✅ 신규 생성 |
| `backend-node/src/services/screenManagementService.ts` | ✅ getLayoutV3 추가 |
| `backend-node/src/controllers/screenManagementController.ts` | ✅ getLayoutV3 추가 |
| `backend-node/src/routes/screenManagementRoutes.ts` | ✅ 라우트 추가 |
### 2.3 API 엔드포인트
```
GET /api/screen-management/screens/:screenId/layout-v3
```
---
## 3. 핵심 구조
### 2.1 컴포넌트 코드 (파일 시스템)
```
frontend/lib/registry/components/{component-name}/
├── index.ts # 렌더링 로직, UI
├── schema.ts # Zod 스키마 + 기본값
└── types.ts # 타입 정의
```
### 2.2 DB 구조
```sql
screen_layouts_v3 (
layout_id SERIAL PRIMARY KEY,
screen_id INTEGER REFERENCES screen_definitions(screen_id),
component_id VARCHAR(100) UNIQUE NOT NULL,
-- 컴포넌트 URL (파일 경로)
component_url VARCHAR(200) NOT NULL,
-- 예: "@/lib/registry/components/split-panel-layout"
-- 회사별 커스텀 설정 (비즈니스 데이터만)
custom_config JSONB NOT NULL DEFAULT '{}',
-- 레이아웃 정보
parent_id VARCHAR(100),
position_x INTEGER NOT NULL DEFAULT 0,
position_y INTEGER NOT NULL DEFAULT 0,
width INTEGER NOT NULL DEFAULT 100,
height INTEGER NOT NULL DEFAULT 100,
display_order INTEGER DEFAULT 0,
-- 기타
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
)
```
---
## 3. 대상 컴포넌트 (고수준)
| 컴포넌트 | 개수 | 우선순위 |
|---------|-----|---------|
| split-panel-layout | 129 | 높음 |
| tabs-widget | 74 | 높음 |
| modal-repeater-table | 68 | 높음 |
| category-manager | 69 | 중간 |
| flow-widget | 11 | 중간 |
| table-list | 280 | 높음 |
| table-search-widget | 353 | 높음 |
| conditional-container | 53 | 중간 |
| selected-items-detail-input | 83 | 중간 |
---
## 4. 작업 단계
### Phase 1: 스키마 정의
- [ ] split-panel-layout/schema.ts
- [ ] tabs-widget/schema.ts
- [ ] modal-repeater-table/schema.ts
- [ ] table-list/schema.ts
- [ ] table-search-widget/schema.ts
- [ ] 기타 컴포넌트들
### Phase 2: DB 테이블 생성
- [ ] screen_layouts_v3 테이블 생성
- [ ] 인덱스 생성
### Phase 3: 마이그레이션
- [ ] 기존 데이터에서 component_url 추출
- [ ] 기존 데이터에서 custom_config 분리
- [ ] 검증 (기존 화면과 동일 렌더링)
### Phase 4: 백엔드 수정
- [ ] getLayoutV3 API 추가
- [ ] saveLayoutV3 API 추가
### Phase 5: 프론트엔드 수정
- [ ] 렌더링 로직에 스키마 병합 적용
- [ ] 화면 디자이너 저장 로직 수정
---
## 5. Zod 스키마 설계 원칙
### 5.1 기본값 (코드에서 관리)
```typescript
// 컴포넌트 UI/동작 관련 - 코드 수정 시 전체 반영
const baseDefaults = {
resizable: true,
splitRatio: 30,
syncSelection: true,
};
```
### 5.2 커스텀 설정 (DB에서 관리)
```typescript
// 비즈니스 데이터 - 회사별 개별 관리
const customConfigSchema = z.object({
leftPanel: z.object({
title: z.string().optional(),
tableName: z.string(),
columns: z.array(z.any()).default([]),
}).passthrough(),
rightPanel: z.object({
title: z.string().optional(),
tableName: z.string(),
relation: z.any().optional(),
}).passthrough(),
}).passthrough();
```
### 5.3 병합 로직
```typescript
function mergeConfig(baseDefaults: any, customConfig: any) {
// 1. 스키마로 customConfig 파싱 (없는 필드는 기본값)
const parsed = customConfigSchema.parse(customConfig);
// 2. 기본값과 병합
return { ...baseDefaults, ...parsed };
}
```
---
## 6. 렌더링 흐름
```
1. DB 조회
├─ component_url: "@/lib/registry/components/split-panel-layout"
└─ custom_config: { leftPanel: { tableName: "sales_order_mng", ... } }
2. 컴포넌트 로드
└─ ComponentRegistry.get("split-panel-layout")
3. 스키마 로드
└─ import { schema, baseDefaults } from "./schema"
4. 설정 병합
└─ baseDefaults + schema.parse(custom_config)
5. 렌더링
└─ <SplitPanelLayout config={mergedConfig} />
```
---
## 7. 마이그레이션 전략
### 7.1 component_url 추출
```sql
-- properties.componentType → component_url 변환
UPDATE screen_layouts_v3
SET component_url = '@/lib/registry/components/' || (properties->>'componentType')
WHERE properties->>'componentType' IS NOT NULL;
```
### 7.2 custom_config 분리
```javascript
// 기존 componentConfig에서 비즈니스 데이터만 추출
function extractCustomConfig(componentType, componentConfig) {
const baseKeys = getBaseKeys(componentType); // 코드 기본값 키들
const customConfig = {};
for (const key of Object.keys(componentConfig)) {
if (!baseKeys.includes(key)) {
customConfig[key] = componentConfig[key];
}
}
return customConfig;
}
```
### 7.3 검증
```javascript
// 기존 렌더링과 동일한지 확인
function verify(original, migrated) {
const originalRender = renderWithConfig(original.componentConfig);
const migratedRender = renderWithConfig(
merge(baseDefaults, migrated.custom_config)
);
return deepEqual(originalRender, migratedRender);
}
```
---
## 8. 체크리스트
- [ ] 컴포넌트 코드 수정 → 전체 회사 즉시 반영 확인
- [ ] 기존 고유 설정 100% 유지 확인
- [ ] 새 필드 추가 시 기본값 자동 적용 확인
- [ ] 기존 화면 렌더링 동일성 확인
- [ ] 화면 디자이너 저장/로드 정상 동작 확인

View File

@ -1,436 +0,0 @@
# 방안 1: 컴포넌트 URL 참조 + Zod 스키마 관리
## 1. 현재 문제점 정리
### 1.1 JSON 구조 불일치
```
현재 상태:
┌─────────────────────────────────────────────────────────────┐
│ v2-table-list 컴포넌트 │
│ 화면 A: { pageSize: 20, showCheckbox: true } │
│ 화면 B: { pagination: { size: 20 }, checkbox: true } │
│ 화면 C: { paging: { pageSize: 20 }, hasCheckbox: true } │
│ │
│ → 같은 설정인데 키 이름이 다름 │
│ → 타입 검증 없음 (런타임 에러 발생) │
└─────────────────────────────────────────────────────────────┘
```
### 1.2 컴포넌트 수정 시 마이그레이션 필요
```
컴포넌트 구조 변경:
pageSize → pagination.pageSize 로 변경하면?
→ 100개 화면의 JSON 전부 마이그레이션 필요
→ 테스트 공수 발생
→ 누락 시 런타임 에러
```
---
## 2. 방안 1 + Zod 아키텍처
### 2.1 전체 구조
```
┌─────────────────────────────────────────────────────────────┐
│ 1. 컴포넌트 코드 + Zod 스키마 (프론트엔드) │
│ │
@/lib/registry/components/v2-table-list/ │
│ ├── index.ts # 컴포넌트 등록 │
│ ├── TableListRenderer.tsx # 렌더링 로직 │
│ ├── schema.ts # ⭐ Zod 스키마 정의 │
│ └── defaults.ts # ⭐ 기본값 정의 │
│ │
│ 코드 수정 → 빌드 → 전 회사 즉시 적용 │
└─────────────────────────────────────────────────────────────┘
│ URL로 참조
┌─────────────────────────────────────────────────────────────┐
│ 2. DB (최소한의 차이점만 저장) │
│ │
│ screen_layouts.properties = { │
│ "componentUrl": "@/registry/v2-table-list", │
│ "config": { │
│ "pageSize": 50 ← 기본값(20)과 다른 것만 │
│ } │
│ } │
└─────────────────────────────────────────────────────────────┘
│ 설정 병합
┌─────────────────────────────────────────────────────────────┐
│ 3. 런타임: 기본값 + 오버라이드 병합 + Zod 검증 │
│ │
│ 최종 설정 = deepMerge(기본값, 오버라이드) │
│ 검증된 설정 = schema.parse(최종 설정) │
└─────────────────────────────────────────────────────────────┘
```
### 2.2 Zod 스키마 예시
```typescript
// @/lib/registry/components/v2-table-list/schema.ts
import { z } from "zod";
// 컬럼 설정 스키마
const columnSchema = z.object({
columnName: z.string(),
displayName: z.string(),
visible: z.boolean().default(true),
sortable: z.boolean().default(true),
width: z.number().optional(),
align: z.enum(["left", "center", "right"]).default("left"),
format: z.enum(["text", "number", "date", "currency"]).default("text"),
order: z.number().default(0),
});
// 페이지네이션 스키마
const paginationSchema = z.object({
enabled: z.boolean().default(true),
pageSize: z.number().default(20),
showSizeSelector: z.boolean().default(true),
pageSizeOptions: z.array(z.number()).default([10, 20, 50, 100]),
});
// 체크박스 스키마
const checkboxSchema = z.object({
enabled: z.boolean().default(true),
multiple: z.boolean().default(true),
position: z.enum(["left", "right"]).default("left"),
});
// 테이블 리스트 전체 스키마
export const tableListSchema = z.object({
tableName: z.string(),
columns: z.array(columnSchema).default([]),
pagination: paginationSchema.default({}),
checkbox: checkboxSchema.default({}),
showHeader: z.boolean().default(true),
autoLoad: z.boolean().default(true),
});
// 타입 자동 추론
export type TableListConfig = z.infer<typeof tableListSchema>;
```
### 2.3 기본값 정의
```typescript
// @/lib/registry/components/v2-table-list/defaults.ts
import { TableListConfig } from "./schema";
export const defaultConfig: Partial<TableListConfig> = {
pagination: {
enabled: true,
pageSize: 20,
showSizeSelector: true,
pageSizeOptions: [10, 20, 50, 100],
},
checkbox: {
enabled: true,
multiple: true,
position: "left",
},
showHeader: true,
autoLoad: true,
};
```
### 2.4 설정 로드 로직
```typescript
// @/lib/registry/utils/configLoader.ts
import { deepMerge } from "@/lib/utils";
export function loadComponentConfig<T>(
componentUrl: string,
overrideConfig: Partial<T>
): T {
// 1. 컴포넌트 모듈에서 스키마와 기본값 가져오기
const { schema, defaultConfig } = getComponentModule(componentUrl);
// 2. 기본값 + 오버라이드 병합
const mergedConfig = deepMerge(defaultConfig, overrideConfig);
// 3. Zod 스키마로 검증 + 기본값 자동 적용
const validatedConfig = schema.parse(mergedConfig);
return validatedConfig;
}
```
---
## 3. 현재 시스템 적응도 분석
### 3.1 변경이 필요한 부분
| 영역 | 현재 | 변경 후 | 공수 |
|-----|-----|--------|-----|
| **컴포넌트 폴더 구조** | types.ts만 있음 | schema.ts, defaults.ts 추가 | 중간 |
| **screen_layouts** | 모든 설정 저장 | URL + 차이점만 저장 | 중간 |
| **화면 저장 로직** | JSON 통째로 저장 | 차이점 추출 후 저장 | 중간 |
| **화면 로드 로직** | JSON 그대로 사용 | 기본값 병합 + Zod 검증 | 낮음 |
| **기존 데이터** | - | 마이그레이션 필요 | 높음 |
### 3.2 기존 코드와의 호환성
```
현재 Zod 사용 현황:
✅ zod v4.1.5 이미 설치됨
@hookform/resolvers 설치됨 (react-hook-form + Zod 연동)
✅ 공통코드 관리에 Zod 스키마 사용 중 (lib/schemas/commonCode.ts)
→ Zod 패턴이 이미 프로젝트에 존재함
→ 동일한 패턴으로 컴포넌트 스키마 추가 가능
```
### 3.3 점진적 마이그레이션 가능 여부
```
Phase 1: 새 컴포넌트만 적용
- 신규 컴포넌트는 schema.ts + defaults.ts 구조로 생성
- 기존 컴포넌트는 그대로 유지
Phase 2: 핵심 컴포넌트 마이그레이션
- v2-table-list, v2-button-primary 등 자주 사용하는 것 먼저
- 기존 JSON 데이터 → 차이점만 남기고 정리
Phase 3: 전체 마이그레이션
- 나머지 컴포넌트 순차 적용
→ 점진적 적용 가능 ✅
```
---
## 4. 향후 장점
### 4.1 컴포넌트 수정 시
```
변경 전:
컴포넌트 수정 → 100개 화면 JSON 마이그레이션 → 테스트 → 배포
변경 후:
컴포넌트 수정 → 빌드 → 배포 → 끝
왜?
- 기본값/로직은 코드에 있음
- DB에는 "다른 것만" 저장되어 있음
- 코드 변경이 자동으로 모든 화면에 적용됨
```
### 4.2 새 설정 추가 시
```
변경 전:
1. types.ts 수정
2. 100개 화면 JSON에 새 필드 추가 (마이그레이션)
3. 기본값 없으면 에러 발생
변경 후:
1. schema.ts에 필드 추가 + .default() 설정
2. 끝. 기존 데이터는 자동으로 기본값 적용됨
// 예시
const schema = z.object({
// 기존 필드
pageSize: z.number().default(20),
// 🆕 새 필드 추가 - 기본값 있으면 마이그레이션 불필요
showRowNumber: z.boolean().default(false),
});
```
### 4.3 타입 안정성
```typescript
// 현재: 타입 검증 없음
const config = component.componentConfig; // any 타입
config.pageSize; // 있을 수도, 없을 수도...
config.pagination.pageSize; // 구조가 다를 수도...
// 변경 후: Zod로 검증 + TypeScript 타입 추론
const config = tableListSchema.parse(rawConfig);
config.pagination.pageSize; // ✅ 타입 보장
config.unknownField; // ❌ 컴파일 에러
```
### 4.4 런타임 에러 방지
```typescript
// Zod 검증 실패 시 명확한 에러 메시지
try {
const config = tableListSchema.parse(rawConfig);
} catch (error) {
if (error instanceof z.ZodError) {
console.error("설정 오류:", error.errors);
// [
// { path: ["pagination", "pageSize"], message: "Expected number, received string" },
// { path: ["columns", 0, "align"], message: "Invalid enum value" }
// ]
}
}
```
### 4.5 문서화 자동화
```typescript
// Zod 스키마에서 자동으로 문서 생성 가능
import { zodToJsonSchema } from "zod-to-json-schema";
const jsonSchema = zodToJsonSchema(tableListSchema);
// → JSON Schema 형식으로 변환 → 문서화 도구에서 사용
```
---
## 5. 유지보수 측면
### 5.1 컴포넌트 개발자 입장
| 작업 | 현재 | 변경 후 |
|-----|-----|--------|
| 새 컴포넌트 생성 | types.ts 작성 (선택) | schema.ts + defaults.ts 작성 (필수) |
| 설정 구조 변경 | 마이그레이션 스크립트 작성 | schema 수정 + 기본값 설정 |
| 타입 체크 | 수동 검증 | Zod가 자동 검증 |
| 디버깅 | console.log로 추적 | Zod 에러 메시지로 바로 파악 |
### 5.2 화면 개발자 입장
| 작업 | 현재 | 변경 후 |
|-----|-----|--------|
| 화면 생성 | 모든 설정 직접 지정 | 필요한 것만 오버라이드 |
| 설정 실수 | 런타임 에러 | 저장 시 Zod 검증 에러 |
| 기본값 확인 | 코드 뒤져보기 | defaults.ts 확인 |
### 5.3 운영자 입장
| 작업 | 현재 | 변경 후 |
|-----|-----|--------|
| 일괄 설정 변경 | 100개 JSON 수정 | defaults.ts 수정 → 전체 적용 |
| 회사별 기본값 | 불가능 | 회사별 defaults 테이블 추가 가능 |
| 오류 추적 | 어려움 | Zod 검증 로그 확인 |
---
## 6. 데이터 마이그레이션 계획
### 6.1 차이점 추출 스크립트
```typescript
// 기존 JSON에서 기본값과 다른 것만 추출
async function extractDiff(componentUrl: string, fullConfig: any): Promise<any> {
const { defaultConfig } = getComponentModule(componentUrl);
function getDiff(defaults: any, current: any): any {
const diff: any = {};
for (const key of Object.keys(current)) {
if (defaults[key] === undefined) {
// 기본값에 없는 키 = 그대로 유지
diff[key] = current[key];
} else if (typeof current[key] === 'object' && !Array.isArray(current[key])) {
// 중첩 객체 = 재귀 비교
const nestedDiff = getDiff(defaults[key], current[key]);
if (Object.keys(nestedDiff).length > 0) {
diff[key] = nestedDiff;
}
} else if (JSON.stringify(defaults[key]) !== JSON.stringify(current[key])) {
// 값이 다름 = 저장
diff[key] = current[key];
}
// 값이 같음 = 저장 안 함 (기본값 사용)
}
return diff;
}
return getDiff(defaultConfig, fullConfig);
}
```
### 6.2 마이그레이션 순서
```
1. 컴포넌트별 schema.ts, defaults.ts 작성
2. 기존 데이터 분석 (어떤 설정이 자주 사용되는지)
3. 가장 많이 사용되는 값을 기본값으로 설정
4. 차이점 추출 스크립트 실행
5. 새 구조로 데이터 업데이트
6. 테스트
```
---
## 7. 예상 공수
| 단계 | 작업 | 예상 공수 |
|-----|-----|---------|
| **Phase 1** | 아키텍처 설계 + 유틸리티 함수 | 1주 |
| **Phase 2** | 핵심 컴포넌트 5개 스키마 작성 | 1주 |
| **Phase 3** | 데이터 마이그레이션 스크립트 | 1주 |
| **Phase 4** | 테스트 + 버그 수정 | 1주 |
| **Phase 5** | 나머지 컴포넌트 순차 적용 | 2-3주 |
| **총계** | | **6-7주** |
---
## 8. 위험 요소 및 대응
### 8.1 위험 요소
| 위험 | 영향 | 대응 |
|-----|-----|-----|
| 기존 데이터 손실 | 높음 | 마이그레이션 전 백업 필수 |
| 스키마 설계 실수 | 중간 | 충분한 리뷰 + 테스트 |
| 런타임 성능 저하 | 낮음 | Zod는 충분히 빠름 |
| 개발자 학습 비용 | 낮음 | Zod는 직관적, 이미 사용 중 |
### 8.2 롤백 계획
```
문제 발생 시:
1. 기존 JSON 구조로 데이터 복원 (백업에서)
2. 새 로직 비활성화 (feature flag)
3. 원인 분석 후 재시도
```
---
## 9. 결론
### 9.1 방안 1 + Zod 조합의 평가
| 항목 | 점수 | 이유 |
|-----|-----|-----|
| **현재 시스템 적응도** | ★★★★☆ | Zod 이미 사용 중, 점진적 적용 가능 |
| **향후 확장성** | ★★★★★ | 새 설정 추가 용이, 타입 안정성 |
| **유지보수성** | ★★★★★ | 코드 수정 → 전 회사 적용, 명확한 에러 |
| **마이그레이션 공수** | ★★★☆☆ | 6-7주 소요, 점진적 적용으로 리스크 분산 |
| **안정성** | ★★★★☆ | Zod 검증으로 런타임 에러 방지 |
### 9.2 최종 권장
```
✅ 방안 1 (URL 참조 + Zod 스키마) 적용 권장
이유:
1. 컴포넌트 수정 → 코드만 변경 → 전 회사 자동 적용
2. Zod로 JSON 구조 일관성 보장
3. 타입 안정성 + 런타임 검증
4. 기존 시스템과 호환 (Zod 이미 사용 중)
5. 점진적 마이그레이션 가능
```
### 9.3 다음 단계
1. 핵심 컴포넌트 1개로 PoC (Proof of Concept)
2. 팀 리뷰 및 피드백
3. 표준 패턴 확정
4. 순차적 적용

View File

@ -1,278 +0,0 @@
# DB 정리 작업 로그 (2026-01-20)
## 작업 개요
- **작업일**: 2026-01-20
- **작업자**: AI Assistant (Claude)
- **대상 DB**: postgresql://39.117.244.52:11132/plm
- **백업 파일**: `/db/plm_full_backup_20260120_182421.dump` (5.3MB)
---
## 작업 결과 요약
| 구분 | 정리 전 | 정리 후 | 변동 |
|------|---------|---------|------|
| 테이블 수 | 336개 | 206개 | -130개 |
| table_type_columns | 3,307개 | 3,307개 | 0 (복원됨) |
| **FK 제약조건** | **119개** | **0개** | **-119개** |
---
## 삭제된 테이블 목록 (130개)
### 1. 백업/날짜 패턴 테이블 (6개)
```
item_info_20251202
item_info_20251202_log
order_table_20251201
purchase_order_master_241216
q20251001
sales_bom_report_part_241218
```
### 2. 테스트 테이블 (3개)
```
copy_table
my_custom_table
writer_test_table
```
### 3. PMS 레거시 (14개)
```
pms_invest_cost_mng
pms_pjt_concept_info
pms_pjt_info
pms_pjt_year_goal
pms_rel_pjt_concept_milestone
pms_rel_pjt_concept_prod
pms_rel_pjt_prod
pms_rel_prod_ref_dept
pms_wbs_task
pms_wbs_task_confirm
pms_wbs_task_info
pms_wbs_task_standard
pms_wbs_task_standard2
pms_wbs_template
```
### 4. profit_loss 관련 (12개)
```
profit_loss
profit_loss_coefficient
profit_loss_coolingtime
profit_loss_depth
profit_loss_lossrate
profit_loss_machine
profit_loss_pretime
profit_loss_srrate
profit_loss_total
profit_loss_total_addlist
profit_loss_total_addlist2
profit_loss_weight
```
### 5. OEM 관련 (3개)
```
oem_factory_mng
oem_milestone_mng
oem_mng
```
### 6. 기타 레거시 (4개)
```
chartmgmt
counselingmgmt
inboxtask
klbom_tbl
nswos100_tbl (table_type_columns에 등록되어 있었으나 2개 컬럼뿐이라 유지 안함)
```
### 7. 미사용 비즈니스 테이블 (약 90개)
계약/견적, 고객/서비스, 자재/제품, 주문/발주, 생산/BOM, 출하/배송, 영업, 공급업체 관련 테이블들
---
## 복원된 테이블 (7개)
`table_type_columns`에 등록되어 있어서 복원한 테이블:
| 테이블 | 컬럼 정의 수 | 데이터 |
|--------|-------------|--------|
| purchase_order_master | 112개 | 0건 |
| production_record | 24개 | 0건 |
| dtg_maintenance_history | 30개 | 0건 |
| inspection_equipment_mng | 12개 | 0건 |
| shipment_instruction | 21개 | 0건 |
| work_order | 24개 | 0건 |
| work_orders | 42개 | 0건 |
---
## FK 제약조건 전체 제거 (119개)
### 제거 이유
1. **로우코드 플랫폼 특성**: 동적으로 테이블/관계 생성되므로 DB FK가 방해됨
2. **앱 레벨 관계 관리**: `cascading_relation`, `screen_field_joins`에서 관리
3. **코드에서 JOIN 처리**: SQL JOIN으로 직접 처리
4. **삭제 유연성**: MES 공정 등에서 FK로 인한 삭제 불가 문제 해결
### 제거된 FK 유형
- `→ company_mng.company_code`: 약 30개 (멀티테넌시용)
- `flow_*` 관련: 약 15개
- `screen_*` 관련: 약 15개
- `batch_*`, `cascading_*`, `dashboard_*` 등 시스템용: 약 60개
### 주의사항
- 앱 레벨에서 참조 무결성 체크 필요
- 고아 데이터 관리 로직 필요
- `cascading_relation` 활용 권장
---
## 중요 유의사항
### 1. table_type_columns 관련
- **절대 함부로 정리하지 말 것!**
- 이 테이블은 **로우코드 플랫폼의 가상 테이블 정의**를 저장
- 실제 DB 테이블과 **무관한 독립적인 메타데이터**
- `/admin/systemMng/tableMngList` 페이지에서 관리하는 데이터
- 잘못 삭제 후 덤프에서 복원함 (3,307개 레코드)
### 2. 삭제 전 체크리스트
테이블 삭제 전 반드시 확인할 것:
1. **table_type_columns에 등록 여부** - 등록되어 있으면 삭제 금지
2. **screen_definitions에서 사용 여부** - 화면에서 사용 중이면 삭제 금지
3. **백엔드 코드 사용 여부** - Grep 검색으로 확인
4. **프론트엔드 코드 사용 여부** - Grep 검색으로 확인
5. **wace 작성자 데이터 여부** - 신규 시스템에서 생성된 데이터인지 확인
6. **덕일 DB 비교** - 덕일에 있으면 레거시 가능성 높음
### 3. 덕일 DB 정보
- 구시스템 (Java 기반)
- 연결 정보: `jdbc:postgresql://59.13.244.189:5432/duckil`
- 322개 테이블 보유
- 현재 DB와 교집합: 17개 테이블 (핵심 시스템 테이블)
### 4. 복원 방법
```bash
# 전체 복원
docker run --rm --network host -v /Users/gbpark/ERP-node/db:/backup postgres:16 \
pg_restore --clean --if-exists --no-owner --no-privileges \
-d "postgresql://postgres:ph0909!!@39.117.244.52:11132/plm" \
/backup/plm_full_backup_20260120_182421.dump
# 특정 테이블만 복원
docker run --rm --network host -v /Users/gbpark/ERP-node/db:/backup postgres:16 \
pg_restore -t "테이블명" --no-owner --no-privileges \
-d "postgresql://postgres:ph0909!!@39.117.244.52:11132/plm" \
/backup/plm_full_backup_20260120_182421.dump
```
---
## 현재 DB 현황
### 테이블 분류
- **총 테이블**: 206개
- **table_type_columns 등록**: 98개
- **화면에서 사용**: 약 70개
- **wace 데이터 있음**: 75개
### 추가 검토 필요 테이블
다음 테이블들은 데이터가 있지만 코드/화면에서 미사용:
- `sales_bom_part_qty` (404건) - 2022년 데이터
- `sales_bom_report` (1,116건)
- `sales_long_delivery_input` (1,588건)
- `sales_part_chg` (248건)
- `sales_request_part` (25건)
→ 삭제 전 업무 담당자 확인 필요
---
## 변경 이력
| 시간 | 작업 | 비고 |
|------|------|------|
| 18:21 | 스키마 덤프 생성 | plm_schema_20260120.sql |
| 18:24 | 전체 덤프 생성 | plm_full_backup_20260120_182421.dump |
| 18:25 | 1차 삭제 (115개) | 백업/테스트/레거시 테이블 |
| 18:26 | table_type_columns 정리 | 686개 레코드 삭제 (잘못된 작업) |
| 18:35 | 2차 삭제 (21개) | 미사용 비즈니스 테이블 |
| 18:36 | table_type_columns 추가 정리 | 153개 레코드 삭제 (잘못된 작업) |
| 18:50 | table_type_columns 복원 | 3,307개 레코드 복원 |
| 19:05 | 7개 테이블 복원 | table_type_columns에 등록된 테이블 복원 |
| 19:45 | **FK 전체 제거** | 119개 Foreign Key 제약조건 삭제 |
| 20:15 | **미사용 배치 테이블 삭제** | batch_jobs(5건), batch_schedules, batch_job_executions, batch_job_parameters |
| 20:25 | **중복 external_db 테이블 정리** | external_db_connection(단수형) 삭제 + flowExecutionService.ts 코드 수정 |
| 20:35 | **레거시 comm 테이블 삭제** | comm_code(752건), comm_code_history(1720건), comm_exchange_rate(4건) + referenceCacheService.ts 정리 |
| 20:50 | **미사용 0건 테이블 삭제** | defect_standard_mng_log, file_down_log, inspection_equipment_mng_log, sales_order_detail_log, work_instruction_log, work_instruction_detail_log, dashboard_shares, dashboard_slider_items, dashboard_sliders, category_column_mapping_test (10개) |
| 21:00 | **미사용 테이블 추가 삭제** | dataflow_external_calls, external_call_logs, mail_log (3개) |
| 21:10 | **미구현 기능 테이블 삭제** | flow_external_connection_permission |
| 21:20 | **미사용 테이블 삭제** | category_values_test(11건), ratecal_mgmt(2건) |
| 21:40 | **레거시 테이블 삭제 (13개)** | sales_*, drivers, dtg_*, time_sheet 등 (총 3,612건) |
| 22:00 | **미사용 0건 테이블 삭제 (6개)** | cascading_reverse_lookup, cascading_multi_parent*, category_values_test, screen_widgets, screen_group_members |
| 22:15 | **미사용 0건 테이블 삭제 (2개)** | collection_batch_executions, collection_batch_management |
| 22:30 | **레거시 테이블 삭제 (1개)** | customer_service_workingtime (5건, 2023년 데이터) |
---
## 삭제된 레거시 테이블 (2026-01-22 추가)
코드 미사용 + TTC/SD 미등록 + 레거시 데이터(wace 아님) 13개:
| 테이블 | 데이터 | 작성자 |
|--------|--------|--------|
| sales_long_delivery_input | 1,588건 | 레거시 |
| sales_bom_report | 1,116건 | plm_admin 등 |
| sales_bom_part_qty | 404건 | 레거시 |
| sales_part_chg | 248건 | hosang.park 등 |
| time_sheet | 155건 | 레거시 |
| sales_request_part | 25건 | plm_admin 등 |
| supply_mng | 24건 | 레거시 |
| work_request | 12건 | 레거시 |
| dtg_monthly_settlements | 10건 | admin |
| used_mng | 10건 | plm_admin |
| drivers | 9건 | 레거시 |
| input_resource | 8건 | plm_admin |
| dtg_contracts | 3건 | admin |
---
## 작업자 메모
1. `table_type_columns`는 로우코드 플랫폼의 핵심 메타데이터 테이블
2. 실제 DB 테이블 삭제와 `table_type_columns` 레코드는 별개로 관리해야 함
3. 앞으로 DB 정리 시 `table_type_columns` 등록 여부를 **가장 먼저** 확인할 것
4. 덤프 파일은 최소 1개월간 보관 권장
5. pg_stat_user_tables의 n_live_tup 값은 부정확할 수 있음 - 실제 COUNT(*) 확인 필수
### production_task (2026-01-22 22:50)
- **데이터**: 336건 (2021년 3월~5월)
- **작성자**: esshin, plm_admin (레거시)
- **TTC/SD**: 미등록/미사용
- **코드 사용**: 없음 (문서만)
- **삭제 사유**: 5년 전 레거시 데이터
---
## 2026-01-22 최종 정리 완료
### 미사용 테이블 분석 결과
- **0건 + TTC/SD 미등록 테이블**: 18개 → **전부 코드에서 사용 중** (삭제 불가)
- **현재 총 테이블**: 164개
- **추가 삭제 대상**: 없음
### 생성된 문서
- `DB_STRUCTURE_DIAGRAM.md`: 전체 DB 구조 및 ER 다이어그램
- 핵심 테이블 관계도 6개 섹션
- 코드 기반 JOIN 분석 완료
- Mermaid 다이어그램 포함
### 정리 완료 요약
| 항목 | 수치 |
|------|------|
| 삭제된 테이블 | 약 50개+ |
| 남은 테이블 | 164개 |
| 활성 테이블 비율 | 100% |

View File

@ -1,681 +0,0 @@
# DB 비효율성 분석 보고서
> 분석일: 2026-01-20 | 분석 기준: 코드 사용 빈도 + DB 설계 원칙 + 유지보수성
---
## 전체 요약
```mermaid
pie title 비효율성 분류
"🔴 즉시 개선" : 2
"🟡 검토 후 개선" : 2
"🟢 선택적 개선" : 2
```
| 심각도 | 개수 | 항목 |
|--------|------|------|
| 🔴 즉시 개선 | 2 | layout_metadata 미사용, user_dept 비정규화 |
| 🟡 검토 후 개선 | 2 | 히스토리 테이블 39개, cascading 미사용 3개 |
| 🟢 선택적 개선 | 2 | dept_info 중복, screen 테이블 통합 |
---
## 🔴 1. screen_definitions.layout_metadata (미사용 컬럼)
### 현재 구조
```mermaid
erDiagram
screen_definitions {
uuid screen_id PK
varchar screen_name
varchar table_name
jsonb layout_metadata "❌ 미사용"
}
screen_layouts {
int layout_id PK
uuid screen_id FK
jsonb properties "✅ 실제 사용"
jsonb layout_config "✅ 실제 사용"
jsonb zones_config "✅ 실제 사용"
}
screen_definitions ||--o{ screen_layouts : "screen_id"
```
### 문제점
| 항목 | 상세 |
|------|------|
| **중복 저장** | `screen_definitions.layout_metadata``screen_layouts.properties`가 유사 데이터 |
| **코드 증거** | `screenManagementService.ts:534` - "기존 layout_metadata도 확인 (하위 호환성) - **현재는 사용하지 않음**" |
| **사용 빈도** | 전체 코드에서 6회만 참조 (대부분 복사/마이그레이션용) |
| **저장 낭비** | JSONB 컬럼이 NULL 또는 빈 객체로 유지 |
### 코드 증거
```typescript
// screenManagementService.ts:534-535
// 기존 layout_metadata도 확인 (하위 호환성) - 현재는 사용하지 않음
// 실제 데이터는 screen_layouts 테이블에서 개별적으로 조회해야 함
```
### 영향도 분석
```mermaid
flowchart LR
A[layout_metadata 삭제] --> B{영향 범위}
B --> C[menuCopyService.ts]
B --> D[screenManagementService.ts]
C --> E[복사 시 해당 필드 제외]
D --> F[조회 시 해당 필드 제외]
E --> G[✅ 정상 동작]
F --> G
```
### 개선 방안
```sql
-- Step 1: 데이터 확인 (실행 전)
SELECT screen_id, screen_name,
CASE WHEN layout_metadata IS NULL THEN 'NULL'
WHEN layout_metadata = '{}' THEN 'EMPTY'
ELSE 'HAS_DATA' END as status
FROM screen_definitions
WHERE layout_metadata IS NOT NULL AND layout_metadata != '{}';
-- Step 2: 컬럼 삭제
ALTER TABLE screen_definitions DROP COLUMN layout_metadata;
```
### 예상 효과
- ✅ 스키마 단순화
- ✅ 데이터 정합성 혼란 제거
- ✅ 저장 공간 절약 (JSONB 오버헤드 제거)
---
## 🔴 2. user_dept 비정규화 (중복 저장)
### 현재 구조 (비효율)
```mermaid
erDiagram
user_info {
varchar user_id PK
varchar user_name "원본"
varchar dept_code
}
dept_info {
varchar dept_code PK
varchar dept_name "원본"
varchar company_code
}
user_dept {
varchar user_id FK
varchar dept_code FK
varchar dept_name "❌ 중복 (dept_info에서 JOIN)"
varchar user_name "❌ 중복 (user_info에서 JOIN)"
varchar position_name "❓ 별도 테이블 필요?"
boolean is_primary
}
user_info ||--o{ user_dept : "user_id"
dept_info ||--o{ user_dept : "dept_code"
```
### 문제점
| 항목 | 상세 |
|------|------|
| **데이터 불일치 위험** | 부서명 변경 시 `dept_info`만 수정하면 `user_dept.dept_name`은 구 데이터 유지 |
| **수정 비용** | 부서명 변경 시 모든 `user_dept` 레코드 UPDATE 필요 |
| **저장 낭비** | 동일 부서의 모든 사용자에게 부서명 반복 저장 |
| **사용 빈도** | 코드에서 `user_dept.dept_name` 직접 조회는 2회뿐 |
### 비정규화로 인한 데이터 불일치 시나리오
```mermaid
sequenceDiagram
participant Admin as 관리자
participant DI as dept_info
participant UD as user_dept
Admin->>DI: UPDATE dept_name = '개발2팀'<br/>WHERE dept_code = 'DEV'
Note over DI: dept_name = '개발2팀' ✅
Note over UD: dept_name = '개발1팀' ❌ 구 데이터
Admin->>UD: ⚠️ 수동으로 모든 레코드 UPDATE 필요
Note over UD: dept_name = '개발2팀' ✅
```
### 권장 구조 (정규화)
```mermaid
erDiagram
user_info {
varchar user_id PK
varchar user_name
varchar position_name "직위 (여기서 관리)"
}
dept_info {
varchar dept_code PK
varchar dept_name
}
user_dept {
varchar user_id FK
varchar dept_code FK
boolean is_primary
}
user_info ||--o{ user_dept : "user_id"
dept_info ||--o{ user_dept : "dept_code"
```
> **참고**: `position_info` 마스터 테이블은 현재 없음. `user_info.position_name`에 직접 저장 중.
> 직위 표준화 필요 시 별도 마스터 테이블 생성 검토.
### 개선 방안
```sql
-- Step 1: 중복 컬럼 삭제 준비 (조회 쿼리 수정 선행)
-- 기존: SELECT ud.dept_name FROM user_dept ud
-- 변경: SELECT di.dept_name FROM user_dept ud JOIN dept_info di ON ud.dept_code = di.dept_code
-- Step 2: 중복 컬럼 삭제
ALTER TABLE user_dept DROP COLUMN dept_name;
ALTER TABLE user_dept DROP COLUMN user_name;
-- position_name은 user_info에서 조회하도록 변경
ALTER TABLE user_dept DROP COLUMN position_name;
```
### 예상 효과
- ✅ 데이터 정합성 보장 (Single Source of Truth)
- ✅ 수정 비용 감소 (한 곳만 수정)
- ✅ 저장 공간 절약
---
## 🟡 3. 과도한 히스토리/로그 테이블 (39개)
### 현재 구조
```mermaid
graph TB
subgraph HISTORY["히스토리 테이블 (39개)"]
H1[authority_master_history]
H2[carrier_contract_mng_log]
H3[carrier_mng_log]
H4[carrier_vehicle_mng_log]
H5[comm_code_history]
H6[data_collection_history]
H7[ddl_execution_log]
H8[defect_standard_mng_log]
H9[delivery_history]
H10[...]
H11[user_info_history]
H12[vehicle_location_history]
H13[work_instruction_log]
end
subgraph PROBLEM["문제점"]
P1["스키마 변경 시<br/>모든 히스토리 테이블 수정"]
P2["테이블 수 폭증<br/>(원본 + 히스토리)"]
P3["관리 복잡도 증가"]
end
HISTORY --> PROBLEM
```
### 현재 테이블 목록 (39개)
| 카테고리 | 테이블명 | 용도 |
|----------|----------|------|
| 시스템 | authority_master_history | 권한 변경 이력 |
| 시스템 | user_info_history | 사용자 정보 이력 |
| 시스템 | dept_info_history | 부서 정보 이력 |
| 시스템 | login_access_log | 로그인 기록 |
| 시스템 | ddl_execution_log | DDL 실행 기록 |
| 물류 | carrier_mng_log | 운송사 변경 이력 |
| 물류 | carrier_contract_mng_log | 운송 계약 이력 |
| 물류 | carrier_vehicle_mng_log | 운송 차량 이력 |
| 물류 | delivery_history | 배송 이력 |
| 물류 | delivery_route_mng_log | 배송 경로 이력 |
| 물류 | logistics_cost_mng_log | 물류 비용 이력 |
| 물류 | vehicle_location_history | 차량 위치 이력 |
| 설비 | equipment_mng_log | 설비 변경 이력 |
| 설비 | equipment_consumable_log | 설비 소모품 이력 |
| 설비 | equipment_inspection_item_log | 설비 점검 이력 |
| 설비 | dtg_maintenance_history | DTG 유지보수 이력 |
| 설비 | dtg_management_log | DTG 관리 이력 |
| 생산 | defect_standard_mng_log | 불량 기준 이력 |
| 생산 | work_instruction_log | 작업 지시 이력 |
| 생산 | work_instruction_detail_log | 작업 지시 상세 이력 |
| 생산 | safety_inspections_log | 안전 점검 이력 |
| 영업 | supplier_mng_log | 공급사 이력 |
| 영업 | sales_order_detail_log | 판매 주문 이력 |
| 기타 | flow_audit_log | 플로우 감사 로그 ✅ 필요 |
| 기타 | flow_integration_log | 플로우 통합 로그 ✅ 필요 |
| 기타 | mail_log | 메일 발송 로그 ✅ 필요 |
| ... | ... | ... |
### 문제점 상세
```mermaid
flowchart TB
A[원본 테이블 컬럼 추가] --> B[히스토리 테이블도 수정 필요]
B --> C{수동 작업}
C -->|잊음| D[❌ 스키마 불일치]
C -->|수동 수정| E[⚠️ 추가 작업 비용]
F[테이블 39개 × 평균 15컬럼] --> G[약 585개 컬럼 관리]
```
### 권장 구조 (통합 감사 테이블)
```mermaid
erDiagram
audit_log {
bigint id PK
varchar table_name "원본 테이블명"
varchar record_id "레코드 식별자"
varchar action "INSERT|UPDATE|DELETE"
jsonb old_data "변경 전 전체 데이터"
jsonb new_data "변경 후 전체 데이터"
jsonb changed_fields "변경된 필드만"
varchar changed_by "변경자"
inet ip_address "IP 주소"
timestamp changed_at "변경 시각"
varchar company_code "회사 코드"
}
```
### 개선 방안
```sql
-- 통합 감사 테이블 생성
CREATE TABLE audit_log (
id bigserial PRIMARY KEY,
table_name varchar(100) NOT NULL,
record_id varchar(100) NOT NULL,
action varchar(10) NOT NULL CHECK (action IN ('INSERT', 'UPDATE', 'DELETE')),
old_data jsonb,
new_data jsonb,
changed_fields jsonb, -- UPDATE 시 변경된 필드만
changed_by varchar(50),
ip_address inet,
changed_at timestamp DEFAULT now(),
company_code varchar(20)
);
-- 인덱스
CREATE INDEX idx_audit_log_table ON audit_log(table_name);
CREATE INDEX idx_audit_log_record ON audit_log(table_name, record_id);
CREATE INDEX idx_audit_log_time ON audit_log(changed_at);
CREATE INDEX idx_audit_log_company ON audit_log(company_code);
-- PostgreSQL 트리거 함수 (자동 감사)
CREATE OR REPLACE FUNCTION audit_trigger_func()
RETURNS TRIGGER AS $$
BEGIN
IF TG_OP = 'INSERT' THEN
INSERT INTO audit_log (table_name, record_id, action, new_data, changed_by, changed_at)
VALUES (TG_TABLE_NAME, NEW.id::text, 'INSERT', row_to_json(NEW)::jsonb,
current_setting('app.current_user', true), now());
RETURN NEW;
ELSIF TG_OP = 'UPDATE' THEN
INSERT INTO audit_log (table_name, record_id, action, old_data, new_data, changed_by, changed_at)
VALUES (TG_TABLE_NAME, NEW.id::text, 'UPDATE', row_to_json(OLD)::jsonb,
row_to_json(NEW)::jsonb, current_setting('app.current_user', true), now());
RETURN NEW;
ELSIF TG_OP = 'DELETE' THEN
INSERT INTO audit_log (table_name, record_id, action, old_data, changed_by, changed_at)
VALUES (TG_TABLE_NAME, OLD.id::text, 'DELETE', row_to_json(OLD)::jsonb,
current_setting('app.current_user', true), now());
RETURN OLD;
END IF;
END;
$$ LANGUAGE plpgsql;
```
### 예상 효과
- ✅ 테이블 수 39개 → 1개로 감소
- ✅ 스키마 변경 시 히스토리 수정 불필요 (JSONB 저장)
- ✅ 통합 조회/분석 용이
- ⚠️ 주의: 기존 히스토리 데이터 마이그레이션 필요
---
## 🟡 4. Cascading 미사용 테이블 (3개)
### 현재 구조
```mermaid
graph TB
subgraph USED["✅ 사용 중 (9개)"]
U1[cascading_hierarchy_group]
U2[cascading_hierarchy_level]
U3[cascading_auto_fill_group]
U4[cascading_auto_fill_mapping]
U5[cascading_relation]
U6[cascading_condition]
U7[cascading_mutual_exclusion]
U8[category_value_cascading_group]
U9[category_value_cascading_mapping]
end
subgraph UNUSED["❌ 미사용 (3개)"]
X1[cascading_multi_parent]
X2[cascading_multi_parent_source]
X3[cascading_reverse_lookup]
end
UNUSED --> DELETE[삭제 검토]
```
### 코드 사용 분석
| 테이블 | 코드 참조 | 판정 |
|--------|----------|------|
| `cascading_hierarchy_group` | 다수 | ✅ 유지 |
| `cascading_hierarchy_level` | 다수 | ✅ 유지 |
| `cascading_auto_fill_group` | 다수 | ✅ 유지 |
| `cascading_auto_fill_mapping` | 다수 | ✅ 유지 |
| `cascading_relation` | 다수 | ✅ 유지 |
| `cascading_condition` | 7회 | ⚠️ 검토 |
| `cascading_mutual_exclusion` | 소수 | ⚠️ 검토 |
| `cascading_multi_parent` | **0회** | ❌ 삭제 |
| `cascading_multi_parent_source` | **0회** | ❌ 삭제 |
| `cascading_reverse_lookup` | **0회** | ❌ 삭제 |
| `category_value_cascading_group` | 다수 | ✅ 유지 |
| `category_value_cascading_mapping` | 다수 | ✅ 유지 |
### 개선 방안
```sql
-- Step 1: 데이터 확인
SELECT 'cascading_multi_parent' as tbl, count(*) FROM cascading_multi_parent
UNION ALL
SELECT 'cascading_multi_parent_source', count(*) FROM cascading_multi_parent_source
UNION ALL
SELECT 'cascading_reverse_lookup', count(*) FROM cascading_reverse_lookup;
-- Step 2: 데이터 없으면 삭제
DROP TABLE IF EXISTS cascading_multi_parent_source; -- 자식 먼저
DROP TABLE IF EXISTS cascading_multi_parent;
DROP TABLE IF EXISTS cascading_reverse_lookup;
```
---
## 🟢 5. dept_info.company_name 중복
### 현재 구조
```mermaid
erDiagram
company_mng {
varchar company_code PK
varchar company_name "원본"
}
dept_info {
varchar dept_code PK
varchar company_code FK
varchar company_name "❌ 중복"
varchar dept_name
}
company_mng ||--o{ dept_info : "company_code"
```
### 문제점
- `dept_info.company_name``company_mng.company_name`과 동일한 값
- 회사명 변경 시 두 테이블 모두 수정 필요
### 개선 방안
```sql
-- 중복 컬럼 삭제
ALTER TABLE dept_info DROP COLUMN company_name;
-- 조회 시 JOIN 사용
SELECT di.*, cm.company_name
FROM dept_info di
JOIN company_mng cm ON di.company_code = cm.company_code;
```
---
## 🟢 6. screen 관련 테이블 통합 가능성
### 현재 구조
```mermaid
erDiagram
screen_data_flows {
int id PK
uuid source_screen_id
uuid target_screen_id
varchar flow_type
}
screen_table_relations {
int id PK
uuid screen_id
varchar table_name
varchar relation_type
}
screen_field_joins {
int id PK
uuid screen_id
varchar source_field
varchar target_field
}
```
### 분석
| 테이블 | 용도 | 사용 빈도 |
|--------|------|----------|
| `screen_data_flows` | 화면 간 데이터 흐름 | 15회 (screenGroupController) |
| `screen_table_relations` | 화면-테이블 관계 | 일부 |
| `screen_field_joins` | 필드 조인 설정 | 일부 |
### 통합 가능성
- 세 테이블 모두 "화면 간 관계" 정의
- 하나의 `screen_relations` 테이블로 통합 가능
- **단, 현재 사용 중이므로 신중한 검토 필요**
---
## 실행 계획
```mermaid
gantt
title DB 개선 실행 계획
dateFormat YYYY-MM-DD
section 즉시 실행
layout_metadata 컬럼 삭제 :a1, 2026-01-21, 1d
미사용 cascading 테이블 삭제 :a2, 2026-01-21, 1d
section 단기 (1주)
user_dept 정규화 :b1, 2026-01-22, 5d
dept_info.company_name 삭제 :b2, 2026-01-22, 2d
section 장기 (1개월)
히스토리 테이블 통합 설계 :c1, 2026-01-27, 7d
히스토리 마이그레이션 :c2, after c1, 14d
```
---
## 즉시 실행 가능 SQL 스크립트
```sql
-- ============================================
-- 🔴 즉시 개선 항목
-- ============================================
-- 1. screen_definitions.layout_metadata 삭제
BEGIN;
-- 백업 (선택)
-- CREATE TABLE screen_definitions_backup AS SELECT * FROM screen_definitions;
ALTER TABLE screen_definitions DROP COLUMN IF EXISTS layout_metadata;
COMMIT;
-- 2. 미사용 cascading 테이블 삭제
BEGIN;
DROP TABLE IF EXISTS cascading_multi_parent_source;
DROP TABLE IF EXISTS cascading_multi_parent;
DROP TABLE IF EXISTS cascading_reverse_lookup;
COMMIT;
-- 3. dept_info.company_name 삭제 (선택)
BEGIN;
ALTER TABLE dept_info DROP COLUMN IF EXISTS company_name;
COMMIT;
```
---
## 7. 채번-카테고리 시스템 (범용화 완료)
### 현황
| 테이블 | 건수 | menu_objid | 상태 |
|--------|------|------------|------|
| `numbering_rules_test` | 108건 | ❌ 없음 | ✅ 범용화 완료 |
| `numbering_rule_parts_test` | 267건 | ❌ 없음 | ✅ 범용화 완료 |
| `category_values_test` | 3건 | ❌ 없음 | ✅ 범용화 완료 |
| `category_column_mapping_test` | 0건 | ❌ 없음 | 미사용 |
### 연결관계도
```mermaid
erDiagram
numbering_rules_test {
varchar rule_id PK "규칙 ID"
varchar rule_name "규칙명"
varchar table_name "테이블명"
varchar column_name "컬럼명"
varchar category_column "카테고리 컬럼"
int category_value_id FK "카테고리 값 ID"
varchar separator "구분자"
varchar reset_period "리셋 주기"
int current_sequence "현재 시퀀스"
date last_generated_date "마지막 생성일"
varchar company_code "회사코드"
}
numbering_rule_parts_test {
serial id PK "파트 ID"
varchar rule_id FK "규칙 ID"
int part_order "순서 (1-6)"
varchar part_type "유형"
varchar generation_method "생성방식"
jsonb auto_config "자동설정"
jsonb manual_config "수동설정"
varchar company_code "회사코드"
}
category_values_test {
serial value_id PK "값 ID"
varchar table_name "테이블명"
varchar column_name "컬럼명"
varchar value_code "코드"
varchar value_label "라벨"
int value_order "정렬순서"
int parent_value_id FK "부모 (계층)"
int depth "깊이"
varchar path "경로"
varchar color "색상"
varchar icon "아이콘"
bool is_active "활성"
bool is_default "기본값"
varchar company_code "회사코드"
}
numbering_rules_test ||--o{ numbering_rule_parts_test : "1:N"
numbering_rules_test }o--o| category_values_test : "카테고리 조건"
category_values_test ||--o{ category_values_test : "계층구조"
```
### 데이터 흐름
```
┌──────────────────────────────────────────────────────────────────────┐
│ 범용 채번 시스템 (menu_objid 제거 완료) │
├──────────────────────────────────────────────────────────────────────┤
│ │
│ ┌────────────────────┐ ┌─────────────────────────┐ │
│ │ category_values │ │ numbering_rules_test │ │
│ │ _test (3건) │◄─────────────│ (108건) │ │
│ ├────────────────────┤ FK ├─────────────────────────┤ │
│ │ table + column │ 조인 │ table + column 기준 │ │
│ │ 기준 카테고리 값 │ │ category_value_id로 │ │
│ │ │ │ 카테고리별 규칙 구분 │ │
│ └────────────────────┘ └───────────┬─────────────┘ │
│ │ │
│ │ 1:N │
│ ▼ │
│ ┌─────────────────────────┐ │
│ │ numbering_rule_parts │ │
│ │ _test (267건) │ │
│ ├─────────────────────────┤ │
│ │ 파트별 설정 (최대 6개) │ │
│ │ - prefix, sequence │ │
│ │ - date, year, month │ │
│ │ - custom │ │
│ └─────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────────────┘
```
### 조회 흐름
```mermaid
sequenceDiagram
participant UI as 사용자 화면
participant CV as category_values_test
participant NR as numbering_rules_test
participant NRP as numbering_rule_parts_test
UI->>CV: 1. 카테고리 값 조회<br/>(table_name + column_name)
CV-->>UI: 카테고리 목록 반환
UI->>NR: 2. 채번 규칙 조회<br/>(table + column + category_value_id)
NR-->>UI: 규칙 반환
UI->>NRP: 3. 채번 파트 조회<br/>(rule_id)
NRP-->>UI: 파트 목록 반환 (1-6개)
UI->>UI: 4. 파트 조합하여 채번 생성<br/>"PREFIX-2026-0001"
```
### 범용화 전/후 비교
| 항목 | 기존 (menu_objid 의존) | 현재 (범용화) |
|------|------------------------|---------------|
| **식별 기준** | menu_objid (메뉴별) | table_name + column_name |
| **공유 범위** | 메뉴 단위 | 테이블 단위 (여러 메뉴에서 공유) |
| **중복 규칙** | 같은 테이블도 메뉴마다 별도 | 하나의 규칙을 공유 |
| **유지보수** | 메뉴 변경 시 규칙도 수정 | 테이블 기준으로 독립 |
---
## 참고
- 분석 대상: `/Users/gbpark/ERP-node/backend-node/src/**/*.ts`
- 스키마 파일: `/Users/gbpark/ERP-node/db/plm_schema_20260120.sql`
- 관련 문서: `DB_STRUCTURE_DIAGRAM.md`, `DB_CLEANUP_LOG_20260120.md`

View File

@ -1,548 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PLM 데이터베이스 구조 다이어그램</title>
<script src="https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.min.js"></script>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
max-width: 1400px;
margin: 0 auto;
padding: 20px;
background: #f5f5f5;
}
h1 { color: #333; border-bottom: 2px solid #4a90d9; padding-bottom: 10px; }
h2 { color: #4a90d9; margin-top: 40px; }
h3 { color: #666; }
.diagram-container {
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
margin: 20px 0;
overflow-x: auto;
}
.mermaid { text-align: center; }
table { border-collapse: collapse; width: 100%; margin: 10px 0; }
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
th { background: #4a90d9; color: white; }
tr:nth-child(even) { background: #f9f9f9; }
.info { background: #e7f3ff; padding: 10px; border-radius: 4px; margin: 10px 0; }
</style>
</head>
<body>
<h1>PLM 데이터베이스 구조 다이어그램</h1>
<div class="info">
<strong>생성일:</strong> 2026-01-22 | <strong>총 테이블:</strong> 164개 | <strong>코드 기반 관계 분석 완료</strong>
</div>
<h2>사용자 화면 플로우 (User Flow)</h2>
<h3>1. 로그인 → 메뉴 → 화면 접근 플로우</h3>
<div class="diagram-container">
<div class="mermaid">
flowchart LR
subgraph LOGIN["🔐 로그인"]
A[사용자 로그인] --> B{user_info 인증}
B -->|성공| C[company_code 확인]
B -->|실패| D[login_access_log 기록]
end
subgraph COMPANY["🏢 회사 분기"]
C --> E{company_code 타입}
E -->|"*"| F[최고관리자 SUPER_ADMIN]
E -->|회사코드| G[회사관리자/일반사용자]
F --> H[company_mng 회사정보 조회]
G --> H
H --> I[JWT 토큰 발급 + companyCode 포함]
end
subgraph AUTH["👤 권한 확인"]
I --> J[authority_sub_user 조회]
J --> K[authority_master 권한 확인]
end
subgraph MENU["📋 메뉴 로딩"]
K --> L[rel_menu_auth 메뉴권한 조회]
L --> M[menu_info 메뉴 목록]
M -->|company_code 필터| N[해당 회사 메뉴만 표시]
end
subgraph SCREEN["📱 화면 렌더링"]
N -->|메뉴 클릭| O[screen_menu_assignments 조회]
O --> P[screen_definitions 화면정의]
P --> Q[screen_layouts 레이아웃]
Q --> R[table_type_columns 컬럼정보]
R -->|company_code 필터| S[해당 회사 데이터만 조회]
end
</div>
</div>
<h3>2. Low-code 화면 데이터 조회 플로우</h3>
<div class="diagram-container">
<div class="mermaid">
flowchart TB
subgraph USER["👤 사용자 액션"]
A[화면 접속] --> B[데이터 조회 요청]
end
subgraph SCREEN_DEF["📱 화면 정의 조회"]
B --> C[screen_definitions]
C --> D[screen_layouts]
D --> E{위젯 타입 확인}
end
subgraph TABLE_INFO["🏷️ 테이블 정보"]
E -->|테이블 위젯| F[table_type_columns]
F --> G[table_labels 라벨]
F --> H[table_column_category_values 카테고리]
F --> I[table_relationships 관계]
end
subgraph DATA_QUERY["📊 데이터 조회"]
G --> J[동적 SQL 생성]
H --> J
I --> J
J --> K[실제 비즈니스 테이블 조회]
K --> L[데이터 반환]
end
subgraph RENDER["🖥️ 화면 표시"]
L --> M[그리드/폼에 데이터 바인딩]
M --> N[사용자에게 표시]
end
</div>
</div>
<h3>3. 플로우 시스템 데이터 이동 플로우</h3>
<div class="diagram-container">
<div class="mermaid">
flowchart LR
subgraph FLOW_DEF["🔄 플로우 정의"]
A[flow_definition] --> B[flow_step]
B --> C[flow_step_connection]
end
subgraph USER_ACTION["👤 사용자 액션"]
D[데이터 선택] --> E[이동 버튼 클릭]
end
subgraph MOVE_PROCESS["📤 데이터 이동"]
E --> F{flow_step_connection 다음 스텝 확인}
F --> G[flow_data_mapping 매핑]
G --> H[소스 테이블에서 데이터 복사]
H --> I[타겟 테이블에 INSERT]
end
subgraph LOGGING["📝 로깅"]
I --> J[flow_audit_log 기록]
J --> K[flow_data_status 상태 업데이트]
end
subgraph RESULT["✅ 결과"]
K --> L[화면 새로고침]
L --> M[이동된 데이터 표시]
end
</div>
</div>
<h3>4. 배치 실행 플로우</h3>
<div class="diagram-container">
<div class="mermaid">
flowchart TB
subgraph TRIGGER["⏰ 트리거"]
A[스케줄러 cron] --> B[batch_configs 조회]
B --> C{활성화 여부}
end
subgraph CONNECTION["🔌 연결"]
C -->|활성| D[external_db_connections]
D --> E[외부 DB 연결]
end
subgraph MAPPING["🗺️ 매핑"]
E --> F[batch_mappings 조회]
F --> G[소스 테이블 → 타겟 테이블]
end
subgraph EXECUTION["⚡ 실행"]
G --> H[외부 DB에서 데이터 조회]
H --> I[내부 DB에 동기화]
I --> J[batch_execution_logs 기록]
end
subgraph RESULT["📊 결과"]
J --> K{성공/실패}
K -->|성공| L[다음 스케줄 대기]
K -->|실패| M[에러 로그 기록]
end
</div>
</div>
<h3>5. 화면 간 데이터 전달 플로우</h3>
<div class="diagram-container">
<div class="mermaid">
flowchart LR
subgraph PARENT["📱 부모 화면"]
A[screen_definitions A] --> B[그리드에서 행 선택]
B --> C[선택된 데이터]
end
subgraph TRANSFER["🔗 데이터 전달"]
C --> D[screen_embedding 관계 확인]
D --> E[screen_data_transfer 설정]
E --> F{전달 필드 매핑}
end
subgraph CHILD["📱 자식 화면"]
F --> G[screen_definitions B]
G --> H[필터 조건으로 적용]
H --> I[관련 데이터만 조회]
I --> J[자식 화면에 표시]
end
</div>
</div>
<h3>6. 캐스케이딩 선택 플로우</h3>
<div class="diagram-container">
<div class="mermaid">
flowchart TB
subgraph SELECT1["1⃣ 첫 번째 선택"]
A[사용자가 대분류 선택] --> B[cascading_hierarchy_group]
end
subgraph CASCADE["🔗 캐스케이딩"]
B --> C[cascading_hierarchy_level 조회]
C --> D[cascading_relation 관계 확인]
D --> E[하위 레벨 옵션 필터링]
end
subgraph SELECT2["2⃣ 두 번째 선택"]
E --> F[중분류 옵션만 표시]
F --> G[사용자가 중분류 선택]
end
subgraph SELECT3["3⃣ 세 번째 선택"]
G --> H[소분류 옵션 필터링]
H --> I[소분류 옵션만 표시]
I --> J[최종 선택 완료]
end
subgraph AUTOFILL["✨ 자동 채움"]
J --> K[cascading_auto_fill_mapping]
K --> L[관련 필드 자동 입력]
end
</div>
</div>
<hr/>
<h2>핵심 테이블 관계도 (ER Diagram)</h2>
<h3>1. 사용자/권한 시스템</h3>
<div class="diagram-container">
<div class="mermaid">
erDiagram
company_mng ||--o{ user_info : "company_code"
company_mng ||--o{ dept_info : "company_code"
user_info ||--o{ user_dept : "user_id"
dept_info ||--o{ user_dept : "dept_code"
authority_master ||--o{ authority_sub_user : "objid → master_objid"
user_info ||--o{ authority_sub_user : "user_id"
authority_master ||--o{ authority_master_history : "objid"
user_info ||--o{ user_info_history : "user_id"
user_info ||--o{ auth_tokens : "user_id"
user_info ||--o{ login_access_log : "user_id"
authority_master ||--o{ rel_menu_auth : "auth_group_id"
menu_info ||--o{ rel_menu_auth : "menu_objid"
user_info {
string user_id PK
string company_code
string user_name
}
authority_master {
int objid PK
string company_code
string auth_group_name
}
company_mng {
string company_code PK
string company_name
}
</div>
</div>
<h2>2. 메뉴/화면 시스템</h2>
<div class="diagram-container">
<div class="mermaid">
erDiagram
menu_info ||--o{ screen_menu_assignments : "objid → menu_objid"
screen_definitions ||--o{ screen_menu_assignments : "screen_id"
screen_definitions ||--|| screen_layouts : "screen_id"
screen_groups ||--o{ screen_group_screens : "id → group_id"
screen_definitions ||--o{ screen_group_screens : "screen_id"
menu_info ||--o{ menu_screen_groups : "objid → menu_objid"
menu_screen_groups ||--o{ menu_screen_group_items : "id → group_id"
screen_definitions ||--o{ screen_data_flows : "source/target_screen_id"
screen_groups ||--o{ screen_data_flows : "group_id"
screen_definitions ||--o{ screen_table_relations : "screen_id"
screen_groups ||--o{ screen_table_relations : "group_id"
screen_definitions ||--o{ screen_field_joins : "screen_id"
screen_definitions ||--o{ screen_embedding : "parent/child_screen_id"
screen_embedding ||--o{ screen_split_panel : "left/right_embedding_id"
screen_embedding ||--o{ screen_data_transfer : "source/target"
screen_definitions {
uuid screen_id PK
string company_code
string screen_name
string table_name
}
screen_layouts {
uuid screen_id PK_FK
jsonb layout_metadata
}
menu_info {
int objid PK
string company_code
string menu_name
string menu_url
}
</div>
</div>
<h2>3. 플로우 시스템</h2>
<div class="diagram-container">
<div class="mermaid">
erDiagram
flow_definition ||--o{ flow_step : "id → definition_id"
flow_step ||--o{ flow_step_connection : "id → from/to_step_id"
flow_step ||--o{ flow_audit_log : "id → from/to_step_id"
flow_step ||--o{ flow_data_mapping : "step_id"
flow_step ||--o{ flow_data_status : "step_id"
flow_definition ||--o{ flow_integration_log : "definition_id"
flow_definition ||--o{ node_flows : "definition_id"
flow_definition ||--o{ dataflow_diagrams : "definition_id"
flow_definition ||--o{ flow_external_db_connection : "definition_id"
flow_definition {
int id PK
string company_code
string name
string description
}
flow_step {
int id PK
int definition_id
string step_name
string table_name
}
flow_step_connection {
int id PK
int from_step_id
int to_step_id
}
</div>
</div>
<h2>4. 테이블타입/코드 시스템</h2>
<div class="diagram-container">
<div class="mermaid">
erDiagram
table_type_columns ||--o{ table_labels : "table_name, column_name"
table_type_columns ||--o{ table_column_category_values : "table_name, column_name"
table_type_columns ||--o{ category_column_mapping : "table_name, column_name"
table_type_columns ||--o{ table_relationships : "table_name"
table_type_columns ||--o{ table_log_config : "original_table_name"
code_category ||--o{ code_info : "category_code"
cascading_hierarchy_group ||--o{ cascading_hierarchy_level : "group_code"
cascading_hierarchy_group ||--o{ cascading_relation : "group_code"
cascading_auto_fill_group ||--o{ cascading_auto_fill_mapping : "group_code"
category_value_cascading_group ||--o{ category_value_cascading_mapping : "group_id"
language_master ||--o{ multi_lang_category : "lang_code"
table_type_columns {
string table_name PK
string column_name PK
string company_code PK
string display_name
string data_type
}
code_category {
string category_code PK
string company_code PK
string category_name
}
code_info {
string category_code PK_FK
string code_value PK
string company_code PK
string code_name
}
</div>
</div>
<h2>5. 배치/수집 시스템</h2>
<div class="diagram-container">
<div class="mermaid">
erDiagram
batch_configs ||--o{ batch_mappings : "id → config_id"
batch_configs ||--o{ batch_execution_logs : "id → config_id"
external_db_connections ||--o{ batch_configs : "connection_id"
external_db_connections ||--o{ data_collection_configs : "connection_id"
data_collection_configs ||--o{ data_collection_jobs : "id → config_id"
data_collection_jobs ||--o{ data_collection_history : "job_id"
external_rest_api_connections ||--o{ external_call_configs : "connection_id"
batch_configs {
int id PK
string company_code
string batch_name
string cron_expression
}
external_db_connections {
int id PK
string company_code
string connection_name
string db_type
}
</div>
</div>
<h2>6. 업무 도메인 (동적 관계)</h2>
<div class="diagram-container">
<div class="mermaid">
erDiagram
customer_mng ||--o{ sales_order_mng : "customer_code"
sales_order_mng ||--o{ sales_order_detail : "order_id"
supplier_mng ||--o{ purchase_order_mng : "supplier_code"
purchase_order_mng ||--o{ purchase_detail : "order_id"
warehouse_info ||--o{ warehouse_location : "warehouse_code"
warehouse_info ||--o{ inventory_stock : "warehouse_code"
inventory_stock ||--o{ inventory_history : "stock_id"
item_info ||--o{ item_routing_version : "item_code"
item_routing_version ||--o{ item_routing_detail : "version_id"
process_mng ||--o{ process_equipment : "process_code"
carrier_mng ||--o{ carrier_vehicle_mng : "carrier_code"
carrier_mng ||--o{ carrier_contract_mng : "carrier_code"
vehicles ||--o{ vehicle_locations : "vehicle_id"
vehicles ||--o{ vehicle_location_history : "vehicle_id"
equipment_mng ||--o{ equipment_consumable : "equipment_code"
equipment_mng ||--o{ maintenance_schedules : "equipment_code"
</div>
</div>
<h2>전체 구조 개요</h2>
<div class="diagram-container">
<div class="mermaid">
graph TB
subgraph SYSTEM["🔐 시스템/인증 (11개)"]
AUTH[authority_master<br/>authority_sub_user<br/>rel_menu_auth]
USER[user_info<br/>user_dept<br/>auth_tokens]
ORG[company_mng<br/>dept_info]
end
subgraph SCREEN["📱 메뉴/화면 (18개)"]
MENU[menu_info<br/>menu_screen_groups]
SCR[screen_definitions<br/>screen_layouts<br/>screen_groups]
DASH[dashboards<br/>dashboard_elements]
end
subgraph CODE["🏷️ 테이블타입/코드 (20개)"]
TTC[table_type_columns<br/>table_labels<br/>table_relationships]
CODE_M[code_category<br/>code_info]
CASC[cascading_*]
end
subgraph FLOW["🔄 플로우 (10개)"]
FLOW_DEF[flow_definition<br/>flow_step<br/>flow_step_connection]
FLOW_DATA[flow_data_mapping<br/>flow_audit_log]
end
subgraph BATCH["⚙️ 배치/수집 (9개)"]
BATCH_CFG[batch_configs<br/>batch_mappings]
EXT_CONN[external_db_connections<br/>external_rest_api_connections]
end
subgraph DOMAIN["📊 업무도메인 (69개)"]
SALES[영업/구매 17개]
PROD[생산/품질 20개]
LOGI[물류/창고 8개]
TRANS[차량/운송 16개]
EQUIP[설비/안전 8개]
end
USER --> AUTH
MENU --> SCR
SCR --> TTC
FLOW_DEF --> FLOW_DATA
BATCH_CFG --> EXT_CONN
</div>
</div>
<h2>카테고리별 테이블 수</h2>
<table>
<tr><th>카테고리</th><th>테이블 수</th></tr>
<tr><td>🔐 시스템/인증</td><td>11개</td></tr>
<tr><td>📱 메뉴/화면</td><td>18개</td></tr>
<tr><td>🏷️ 테이블타입/코드</td><td>20개</td></tr>
<tr><td>🔄 플로우</td><td>10개</td></tr>
<tr><td>⚙️ 배치/수집</td><td>9개</td></tr>
<tr><td>📊 보고서</td><td>5개</td></tr>
<tr><td>📦 물류/창고</td><td>8개</td></tr>
<tr><td>🏭 생산/품질</td><td>20개</td></tr>
<tr><td>💰 영업/구매</td><td>17개</td></tr>
<tr><td>🔧 설비/안전</td><td>8개</td></tr>
<tr><td>🚛 차량/운송</td><td>16개</td></tr>
<tr><td>📁 기타</td><td>22개</td></tr>
<tr><th>총계</th><th>164개</th></tr>
</table>
<script>
mermaid.initialize({
startOnLoad: true,
theme: 'default',
securityLevel: 'loose'
});
</script>
</body>
</html>

View File

@ -1,468 +0,0 @@
# Vexplor 구조 다이어그램
> 생성일: 2026-01-22 | 총 테이블: 164개 | 코드 기반 관계 분석 완료
---
## 1. 테이블 JOIN 관계도 (핵심)
### 1-1. 사용자/권한 시스템 JOIN 관계
| CRUD | 테이블 순서 | 설명 |
|------|-------------|------|
| **C** | `user_info``user_dept``authority_sub_user` | 사용자 생성 → 부서 배정 → 권한 부여 |
| **R** | `user_info` + `company_mng` + `authority_sub_user` + `authority_master` JOIN | 로그인/조회 시 회사+권한 JOIN |
| **U** | `user_info` / `user_dept` / `authority_sub_user` 개별 | 각 테이블 독립 수정 |
| **D** | 각각 독립 삭제 (별도 API) | user_dept, authority_sub_user, user_info 각각 삭제 |
```mermaid
erDiagram
company_mng {
varchar company_code PK "회사코드"
varchar company_name "회사명"
}
user_info {
varchar user_id PK "사용자ID"
varchar company_code "회사코드 (멀티테넌시)"
varchar user_name "사용자명"
varchar user_type "SUPER_ADMIN | COMPANY_ADMIN | USER"
}
dept_info {
varchar dept_code PK "부서코드"
varchar company_code "회사코드"
varchar dept_name "부서명"
}
user_dept {
varchar user_id "사용자ID"
varchar dept_code "부서코드"
varchar company_code "회사코드"
}
authority_master {
int objid PK "권한그룹ID"
varchar company_code "회사코드"
varchar auth_group_name "권한그룹명"
}
authority_sub_user {
int master_objid "권한그룹ID"
varchar user_id "사용자ID"
varchar company_code "회사코드"
}
company_mng ||--o{ user_info : "company_code = company_code"
company_mng ||--o{ dept_info : "company_code = company_code"
user_info ||--o{ user_dept : "user_id = user_id"
dept_info ||--o{ user_dept : "dept_code = dept_code"
authority_master ||--o{ authority_sub_user : "objid = master_objid"
user_info ||--o{ authority_sub_user : "user_id = user_id"
```
**실제 코드 JOIN 예시:**
```sql
-- 사용자 권한 조회 (authService.ts:158)
SELECT am.auth_group_name, am.objid
FROM authority_sub_user asu
INNER JOIN authority_master am ON asu.master_objid = am.objid
WHERE asu.user_id = $1
```
### 1-2. 메뉴/권한 시스템 JOIN 관계
| CRUD | 테이블 순서 | 설명 |
|------|-------------|------|
| **C** | `menu_info``rel_menu_auth` | 메뉴 생성 → 권한그룹에 메뉴 할당 |
| **R** | `authority_master``rel_menu_auth``menu_info` | 사용자 권한으로 접근 가능 메뉴 필터링 |
| **U** | `menu_info` 단독 / `rel_menu_auth` 삭제 후 재생성 | 메뉴 수정 or 권한 재할당 |
| **D** | `rel_menu_auth``menu_info` | 권한 매핑 먼저 삭제 → 메뉴 삭제 |
```mermaid
erDiagram
menu_info {
int objid PK "메뉴ID"
varchar company_code "회사코드"
varchar menu_name_kor "메뉴명"
varchar menu_url "메뉴URL"
int parent_obj_id "상위메뉴ID"
}
rel_menu_auth {
int menu_objid "메뉴ID"
int auth_objid "권한그룹ID"
varchar company_code "회사코드"
}
authority_master {
int objid PK "권한그룹ID"
varchar company_code "회사코드"
}
menu_info ||--o{ rel_menu_auth : "objid = menu_objid"
authority_master ||--o{ rel_menu_auth : "objid = auth_objid"
```
**실제 코드 JOIN 예시:**
```sql
-- 사용자 메뉴 조회 (adminService.ts)
SELECT mi.*
FROM menu_info mi
JOIN rel_menu_auth rma ON mi.objid = rma.menu_objid
WHERE rma.auth_objid IN (사용자권한목록)
AND mi.company_code = $companyCode
```
### 1-3. 화면 시스템 JOIN 관계
| CRUD | 테이블 순서 | 설명 |
|------|-------------|------|
| **C** | `screen_definitions``screen_layouts``screen_menu_assignments` | 화면 정의 → 레이아웃 → 메뉴 연결 |
| **R** | `menu_info``screen_menu_assignments``screen_definitions` + `screen_layouts` JOIN | 메뉴에서 화면+레이아웃 JOIN |
| **U** | `screen_definitions` / `screen_layouts` 개별 (같은 screen_id) | 정의와 레이아웃 각각 수정 |
| **D** | `screen_layouts``screen_menu_assignments``screen_definitions` | 레이아웃 → 메뉴연결 → 정의 순서 |
> **그룹**: `screen_groups``screen_group_screens`는 별도 API로 관리 (복사/그룹화 용도)
```mermaid
erDiagram
screen_definitions {
uuid screen_id PK "화면ID"
varchar company_code "회사코드"
varchar screen_name "화면명"
varchar table_name "연결테이블"
}
screen_layouts {
uuid screen_id PK "화면ID"
jsonb layout_metadata "레이아웃JSON"
}
screen_menu_assignments {
uuid screen_id "화면ID"
int menu_objid "메뉴ID"
varchar company_code "회사코드"
}
screen_groups {
int id PK "그룹ID"
varchar company_code "회사코드"
varchar group_name "그룹명"
}
screen_group_screens {
int group_id "그룹ID"
uuid screen_id "화면ID"
varchar company_code "회사코드"
}
screen_definitions ||--|| screen_layouts : "screen_id = screen_id"
screen_definitions ||--o{ screen_menu_assignments : "screen_id = screen_id"
menu_info ||--o{ screen_menu_assignments : "objid = menu_objid"
screen_groups ||--o{ screen_group_screens : "id = group_id"
screen_definitions ||--o{ screen_group_screens : "screen_id = screen_id"
```
**실제 코드 JOIN 예시:**
```sql
-- 화면 정의 + 레이아웃 조회 (screenGroupController.ts:1272)
SELECT sd.*, sl.layout_metadata
FROM screen_definitions sd
JOIN screen_layouts sl ON sd.screen_id = sl.screen_id
WHERE sd.screen_id = $1
```
### 1-4. 테이블 타입/메타데이터 JOIN 관계
| CRUD | 테이블 순서 | 설명 |
|------|-------------|------|
| **C** | 각 테이블 독립 생성 | DDL 실행 시 자동 생성, 또는 개별 등록 |
| **R** | `table_type_columns` + `table_labels` + `table_relationships` LEFT JOIN | 화면 로딩 시 메타데이터 조합 |
| **U** | 각 테이블 개별 (table_name + column_name + company_code 기준) | 컬럼 정의/라벨/관계 각각 수정 |
| **D** | 각 테이블 독립 삭제 | 테이블 삭제 시 관련 메타데이터 개별 삭제 |
> **코드값 조회**: `table_column_category_values``code_category``code_info` (드롭다운 옵션)
```mermaid
erDiagram
table_type_columns {
varchar table_name PK "테이블명"
varchar column_name PK "컬럼명"
varchar company_code PK "회사코드"
varchar display_name "표시명"
varchar data_type "데이터타입"
varchar reference_table "참조테이블"
varchar reference_column "참조컬럼"
}
table_labels {
varchar table_name PK "테이블명"
varchar company_code PK "회사코드"
varchar display_name "테이블표시명"
}
table_column_category_values {
varchar table_name "테이블명"
varchar column_name "컬럼명"
varchar category_code "카테고리코드"
varchar company_code "회사코드"
}
table_relationships {
varchar table_name "테이블명"
varchar source_column "소스컬럼"
varchar target_table "타겟테이블"
varchar target_column "타겟컬럼"
varchar company_code "회사코드"
}
code_category {
varchar category_code PK "카테고리코드"
varchar company_code PK "회사코드"
varchar category_name "카테고리명"
}
code_info {
varchar category_code "카테고리코드"
varchar code_value PK "코드값"
varchar company_code PK "회사코드"
varchar code_name "코드명"
}
table_type_columns ||--o{ table_labels : "table_name = table_name"
table_type_columns ||--o{ table_column_category_values : "table_name, column_name"
table_type_columns ||--o{ table_relationships : "table_name = table_name"
code_category ||--o{ code_info : "category_code = category_code"
table_column_category_values }o--|| code_category : "category_code = category_code"
```
**실제 코드 JOIN 예시:**
```sql
-- 테이블 컬럼 정보 조회 (tableManagementService.ts:210)
SELECT ttc.*, cl.display_name as column_label
FROM table_type_columns ttc
LEFT JOIN column_labels cl
ON ttc.table_name = cl.table_name
AND ttc.column_name = cl.column_name
WHERE ttc.table_name = $1
AND ttc.company_code = $2
```
### 1-5. 플로우 시스템 JOIN 관계
| CRUD | 테이블 순서 | 설명 |
|------|-------------|------|
| **C** | `flow_definition``flow_step``flow_step_connection``flow_data_mapping` | 플로우 → 스텝 → 연결선 → 매핑 |
| **R** | `flow_definition` + `flow_step` + `flow_step_connection` JOIN | 플로우 화면 렌더링 |
| **U** | 각 테이블 개별 (definition_id/step_id 기준) | 정의/스텝/연결 각각 수정 |
| **D** | 각 테이블 독립 삭제 (DB CASCADE 의존) | step/connection/definition 각각 삭제 API |
> **데이터 이동**: `flow_data_mapping`(컬럼 변환) → 소스→타겟 INSERT → `flow_audit_log`(자동 기록)
```mermaid
erDiagram
flow_definition {
int id PK "플로우ID"
varchar company_code "회사코드"
varchar name "플로우명"
}
flow_step {
int id PK "스텝ID"
int definition_id "플로우ID"
varchar company_code "회사코드"
varchar step_name "스텝명"
varchar table_name "연결테이블"
int step_order "순서"
}
flow_step_connection {
int id PK "연결ID"
int from_step_id "출발스텝ID"
int to_step_id "도착스텝ID"
int definition_id "플로우ID"
}
flow_data_mapping {
int from_step_id "출발스텝ID"
int to_step_id "도착스텝ID"
varchar source_column "소스컬럼"
varchar target_column "타겟컬럼"
}
flow_audit_log {
int id PK "로그ID"
int definition_id "플로우ID"
int from_step_id "출발스텝ID"
int to_step_id "도착스텝ID"
int data_id "데이터ID"
timestamp moved_at "이동시간"
}
flow_definition ||--o{ flow_step : "id = definition_id"
flow_step ||--o{ flow_step_connection : "id = from_step_id"
flow_step ||--o{ flow_step_connection : "id = to_step_id"
flow_step ||--o{ flow_data_mapping : "id = from_step_id"
flow_step ||--o{ flow_audit_log : "id = from_step_id"
```
**실제 코드 JOIN 예시:**
```sql
-- 플로우 감사로그 조회 (flowDataMoveService.ts:461)
SELECT fal.*,
fs_from.step_name as from_step_name,
fs_to.step_name as to_step_name
FROM flow_audit_log fal
LEFT JOIN flow_step fs_from ON fal.from_step_id = fs_from.id
LEFT JOIN flow_step fs_to ON fal.to_step_id = fs_to.id
WHERE fal.definition_id = $1
```
### 1-6. 배치/수집 시스템 JOIN 관계
| CRUD | 테이블 순서 | 설명 |
|------|-------------|------|
| **C** | `external_db_connections``batch_configs``batch_mappings` | 외부DB 연결 → 배치 설정 → 매핑 규칙 |
| **R** | `batch_configs` + `external_db_connections` + `batch_mappings` JOIN | 배치 실행 시 전체 설정 조회 |
| **U** | `batch_mappings` 삭제 후 재생성 / `batch_configs` 개별 수정 | 매핑은 전체 교체 방식 |
| **D** | `batch_configs` 삭제 시 `batch_mappings` CASCADE 삭제 | 설정만 삭제하면 매핑 자동 삭제 |
> **실행 시**: 크론 → 외부DB 조회 → 내부 테이블 동기화 → `batch_execution_logs`(결과 기록)
```mermaid
erDiagram
external_db_connections {
int id PK "연결ID"
varchar company_code "회사코드"
varchar connection_name "연결명"
varchar db_type "postgresql|mysql|mssql"
varchar host "호스트"
int port "포트"
}
batch_configs {
int id PK "배치ID"
varchar company_code "회사코드"
varchar batch_name "배치명"
varchar cron_expression "크론식"
int connection_id "연결ID"
varchar is_active "Y|N"
}
batch_mappings {
int id PK "매핑ID"
int batch_config_id "배치ID"
varchar source_table "소스테이블"
varchar source_column "소스컬럼"
varchar target_table "타겟테이블"
varchar target_column "타겟컬럼"
}
batch_execution_logs {
int id PK "로그ID"
int batch_config_id "배치ID"
timestamp started_at "시작시간"
timestamp finished_at "종료시간"
varchar status "SUCCESS|FAILED"
}
external_db_connections ||--o{ batch_configs : "id = connection_id"
batch_configs ||--o{ batch_mappings : "id = batch_config_id"
batch_configs ||--o{ batch_execution_logs : "id = batch_config_id"
```
**실제 코드 JOIN 예시:**
```sql
-- 배치 설정 + 매핑 조회 (batchService.ts:143)
SELECT bc.*, bm.*
FROM batch_configs bc
LEFT JOIN batch_mappings bm ON bc.id = bm.batch_config_id
WHERE bc.id = $1
AND bc.company_code = $2
ORDER BY bm.mapping_order
```
---
## 2. 로직 플로우 요약
> 위 JOIN 관계가 **언제** 사용되는지 간략 설명
### 2-1. 로그인 → 화면 접근 순서
| 단계 | 테이블 | JOIN 관계 | 설명 |
|------|--------|-----------|------|
| 1 | `user_info` | - | user_id, password 확인 |
| 2 | `user_info` | - | company_code 조회 → 멀티테넌시 분기 |
| 3 | `company_mng` | user_info.company_code = company_mng.company_code | 회사명 조회 |
| 4 | `authority_sub_user``authority_master` | asu.master_objid = am.objid | 사용자 권한 조회 |
| 5 | `menu_info``rel_menu_auth` | mi.objid = rma.menu_objid | 권한별 메뉴 필터 |
| 6 | `screen_menu_assignments``screen_definitions` | sma.screen_id = sd.screen_id | 메뉴-화면 연결 |
| 7 | `screen_definitions``screen_layouts` | sd.screen_id = sl.screen_id | 화면+레이아웃 |
| 8 | `table_type_columns` | WHERE table_name = $1 | 컬럼 메타데이터 |
### 2-2. 데이터 조회 순서
| 단계 | 테이블 | JOIN 관계 | 설명 |
|------|--------|-----------|------|
| 1 | `table_type_columns` | - | 컬럼 정의 조회 |
| 2 | `table_labels` | ttc.table_name = tl.table_name | 테이블 표시명 |
| 3 | `table_column_category_values` | ttc.table_name, column_name | 카테고리 값 |
| 4 | `table_relationships` | ttc.table_name = tr.table_name | 참조 관계 |
| 5 | `code_category``code_info` | cc.category_code = ci.category_code | 코드값 조회 |
| 6 | 비즈니스 테이블 | LEFT JOIN (table_relationships 기반) | 실제 데이터 |
### 2-3. 플로우 데이터 이동 순서
| 단계 | 테이블 | JOIN 관계 | 설명 |
|------|--------|-----------|------|
| 1 | `flow_definition` | - | 플로우 정의 |
| 2 | `flow_step` | fs.definition_id = fd.id | 스텝 목록 |
| 3 | `flow_step_connection` | fsc.from_step_id = fs.id | 연결 관계 |
| 4 | `flow_data_mapping` | fdm.from_step_id, to_step_id | 컬럼 매핑 |
| 5 | 소스 테이블 | - | 데이터 조회 |
| 6 | 타겟 테이블 | - | 데이터 INSERT |
| 7 | `flow_audit_log` | - | 이동 기록 |
### 2-4. 배치 실행 순서
| 단계 | 테이블 | JOIN 관계 | 설명 |
|------|--------|-----------|------|
| 1 | `batch_configs` | - | 활성 배치 조회 |
| 2 | `external_db_connections` | bc.connection_id = edc.id | 외부 DB 정보 |
| 3 | `batch_mappings` | bm.batch_config_id = bc.id | 매핑 규칙 |
| 4 | 외부 DB | - | 데이터 조회 |
| 5 | 내부 테이블 | - | 데이터 동기화 |
| 6 | `batch_execution_logs` | bel.batch_config_id = bc.id | 실행 로그 |
---
## 3. 멀티테넌시 (company_code) 적용 요약
| 테이블 | company_code 필터 | 비고 |
|--------|------------------|------|
| `user_info` | O | 사용자별 회사 구분 |
| `menu_info` | O | 회사별 메뉴 |
| `screen_definitions` | O | 회사별 화면 |
| `table_type_columns` | O | 회사별 컬럼 정의 |
| `flow_definition` | O | 회사별 플로우 |
| `batch_configs` | O | 회사별 배치 |
| 모든 비즈니스 테이블 | O | 자동 필터 적용 |
| `company_mng` | X (PK) | 회사 마스터 |
**company_code = '*'**: 최고관리자, 모든 회사 데이터 접근 가능
---
## 4. 비효율성 분석
> 상세 내용: [DB_INEFFICIENCY_ANALYSIS.md](./DB_INEFFICIENCY_ANALYSIS.md)
| 심각도 | 항목 | 권장 조치 |
|--------|------|-----------|
| 🔴 | `screen_definitions.layout_metadata` | 미사용 컬럼 삭제 |
| 🔴 | `user_dept` 비정규화 | 정규화 리팩토링 |
| 🟡 | 히스토리 테이블 39개 | 통합 감사 테이블 |
| 🟡 | cascading 미사용 3개 | 테이블 삭제 |
| 🟢 | `dept_info.company_name` | 선택적 정규화 |

View File

@ -126,19 +126,7 @@ export default function ScreenManagementPage() {
if (isDesignMode) { if (isDesignMode) {
return ( return (
<div className="fixed inset-0 z-50 bg-background"> <div className="fixed inset-0 z-50 bg-background">
<ScreenDesigner <ScreenDesigner selectedScreen={selectedScreen} onBackToList={() => goToStep("list")} />
selectedScreen={selectedScreen}
onBackToList={() => goToStep("list")}
onScreenUpdate={(updatedFields) => {
// 저장 후 화면 정보 즉시 업데이트 (테이블명 등)
if (selectedScreen) {
setSelectedScreen({
...selectedScreen,
...updatedFields,
});
}
}}
/>
</div> </div>
); );
} }

View File

@ -22,7 +22,7 @@ export function RegistryProvider({ children }: RegistryProviderProps) {
// V2 Core 초기화 (느슨한 결합 아키텍처) // V2 Core 초기화 (느슨한 결합 아키텍처)
initV2Core({ initV2Core({
debug: false, debug: process.env.NODE_ENV === "development",
legacyBridge: { legacyBridge: {
legacyToV2: true, legacyToV2: true,
v2ToLegacy: true, v2ToLegacy: true,

View File

@ -33,7 +33,7 @@ import {
CommandItem, CommandItem,
CommandList, CommandList,
} from "@/components/ui/command"; } from "@/components/ui/command";
import { Loader2, Copy, Link as LinkIcon, Trash2, AlertCircle, AlertTriangle, Check, ChevronsUpDown, FolderTree, Hash, Table, Settings } from "lucide-react"; import { Loader2, Copy, Link as LinkIcon, Trash2, AlertCircle, AlertTriangle, Check, ChevronsUpDown, FolderTree, Hash, Code, Table, Settings, Database } from "lucide-react";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { ScreenDefinition } from "@/types/screen"; import { ScreenDefinition } from "@/types/screen";
import { screenApi, updateTabScreenReferences } from "@/lib/api/screen"; import { screenApi, updateTabScreenReferences } from "@/lib/api/screen";
@ -45,11 +45,6 @@ import { Alert, AlertDescription } from "@/components/ui/alert";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
// 정규식 특수문자 이스케이프 헬퍼 함수
const escapeRegExp = (str: string): string => {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
};
interface LinkedModalScreen { interface LinkedModalScreen {
screenId: number; screenId: number;
screenName: string; screenName: string;
@ -145,8 +140,10 @@ export default function CopyScreenModal({
const [copyNumberingRules, setCopyNumberingRules] = useState(false); const [copyNumberingRules, setCopyNumberingRules] = useState(false);
// 추가 복사 옵션들 // 추가 복사 옵션들
const [copyCategoryValues, setCopyCategoryValues] = useState(false); // 카테고리 값 복사 const [copyCodeCategory, setCopyCodeCategory] = useState(false); // 코드 카테고리 + 코드 복사
const [copyCategoryMapping, setCopyCategoryMapping] = useState(false); // 카테고리 매핑 + 값 복사
const [copyTableTypeColumns, setCopyTableTypeColumns] = useState(false); // 테이블 타입관리 입력타입 설정 복사 const [copyTableTypeColumns, setCopyTableTypeColumns] = useState(false); // 테이블 타입관리 입력타입 설정 복사
const [copyCascadingRelation, setCopyCascadingRelation] = useState(false); // 연쇄관계 설정 복사
// 복사 중 상태 // 복사 중 상태
const [isCopying, setIsCopying] = useState(false); const [isCopying, setIsCopying] = useState(false);
@ -408,7 +405,7 @@ export default function CopyScreenModal({
// 1. 제거할 텍스트 제거 // 1. 제거할 텍스트 제거
if (removeText.trim()) { if (removeText.trim()) {
newName = newName.replace(new RegExp(escapeRegExp(removeText.trim()), "g"), ""); newName = newName.replace(new RegExp(removeText.trim(), "g"), "");
newName = newName.trim(); // 앞뒤 공백 제거 newName = newName.trim(); // 앞뒤 공백 제거
} }
@ -632,7 +629,7 @@ export default function CopyScreenModal({
// 일괄 이름 변경이 활성화된 경우 (찾기/대체 방식) // 일괄 이름 변경이 활성화된 경우 (찾기/대체 방식)
if (useGroupBulkRename && groupFindText) { if (useGroupBulkRename && groupFindText) {
// 찾을 텍스트를 대체할 텍스트로 변경 // 찾을 텍스트를 대체할 텍스트로 변경
return originalName.replace(new RegExp(escapeRegExp(groupFindText), "g"), groupReplaceText); return originalName.replace(new RegExp(groupFindText, "g"), groupReplaceText);
} }
// 다른 회사로 복제하는 경우: 원본 이름 그대로 사용 (중복될 일 없음) // 다른 회사로 복제하는 경우: 원본 이름 그대로 사용 (중복될 일 없음)
@ -983,37 +980,21 @@ export default function CopyScreenModal({
} }
} }
// 7. 메뉴 동기화 및 화면-메뉴 할당 복제 (항상 실행 - 메뉴 연결 필수) // 7. 채번규칙 복제 옵션이 선택된 경우 (복제 → 메뉴 동기화 → 채번규칙 복제)
try { if (copyNumberingRules) {
setCopyProgress({ current: stats.screens, total: totalScreenCount, message: "메뉴 동기화 중..." }); try {
console.log("📋 메뉴 동기화 시작..."); setCopyProgress({ current: stats.screens, total: totalScreenCount, message: "메뉴 동기화 중..." });
console.log("📋 메뉴 동기화 시작 (채번규칙 복제 준비)...");
// 7-1. 메뉴 동기화 (화면 그룹 → 메뉴) - 항상 실행
const syncResponse = await apiClient.post("/screen-groups/sync/screen-to-menu", {
targetCompanyCode: finalCompanyCode,
});
if (syncResponse.data?.success) {
console.log("✅ 메뉴 동기화 완료:", syncResponse.data.data);
// 7-2. 화면-메뉴 할당 복제 (screen_menu_assignments) - 항상 실행 // 7-1. 메뉴 동기화 (화면 그룹 → 메뉴)
setCopyProgress({ current: stats.screens, total: totalScreenCount, message: "화면-메뉴 할당 복제 중..." }); const syncResponse = await apiClient.post("/screen-groups/sync/screen-to-menu", {
console.log("📋 화면-메뉴 할당 복제 시작...");
const menuAssignResponse = await apiClient.post("/screen-management/copy-menu-assignments", {
sourceCompanyCode: sourceGroup.company_code,
targetCompanyCode: finalCompanyCode, targetCompanyCode: finalCompanyCode,
screenIdMap,
}); });
if (menuAssignResponse.data?.success) { if (syncResponse.data?.success) {
console.log("✅ 화면-메뉴 할당 복제 완료:", menuAssignResponse.data.data); console.log("✅ 메뉴 동기화 완료:", syncResponse.data.data);
} else {
console.warn("화면-메뉴 할당 복제 실패:", menuAssignResponse.data?.error); // 7-2. 채번규칙 복제
}
// 7-3. 채번규칙 복제 (옵션이 선택된 경우에만)
if (copyNumberingRules) {
setCopyProgress({ current: stats.screens, total: totalScreenCount, message: "채번규칙 복제 중..." }); setCopyProgress({ current: stats.screens, total: totalScreenCount, message: "채번규칙 복제 중..." });
console.log("📋 채번규칙 복제 시작..."); console.log("📋 채번규칙 복제 시작...");
@ -1029,21 +1010,62 @@ export default function CopyScreenModal({
console.warn("채번규칙 복제 실패:", numberingResponse.data?.error); console.warn("채번규칙 복제 실패:", numberingResponse.data?.error);
toast.warning("채번규칙 복제에 실패했습니다. 수동으로 복제해주세요."); toast.warning("채번규칙 복제에 실패했습니다. 수동으로 복제해주세요.");
} }
// 7-3. 화면-메뉴 할당 복제 (screen_menu_assignments)
setCopyProgress({ current: stats.screens, total: totalScreenCount, message: "화면-메뉴 할당 복제 중..." });
console.log("📋 화면-메뉴 할당 복제 시작...");
const menuAssignResponse = await apiClient.post("/screen-management/copy-menu-assignments", {
sourceCompanyCode: sourceGroup.company_code,
targetCompanyCode: finalCompanyCode,
screenIdMap,
});
if (menuAssignResponse.data?.success) {
console.log("✅ 화면-메뉴 할당 복제 완료:", menuAssignResponse.data.data);
toast.success(`화면-메뉴 할당 ${menuAssignResponse.data.data?.copiedCount || 0}개가 복제되었습니다.`);
} else {
console.warn("화면-메뉴 할당 복제 실패:", menuAssignResponse.data?.error);
}
} else {
console.warn("메뉴 동기화 실패:", syncResponse.data?.error);
toast.warning("메뉴 동기화에 실패했습니다. 채번규칙이 복제되지 않았습니다.");
} }
} else { } catch (numberingError) {
console.warn("메뉴 동기화 실패:", syncResponse.data?.error); console.error("채번규칙 복제 중 오류:", numberingError);
toast.warning("메뉴 동기화에 실패했습니다."); toast.warning("채번규칙 복제 중 오류가 발생했습니다.");
} }
} catch (menuSyncError) {
console.error("메뉴 동기화 중 오류:", menuSyncError);
toast.warning("메뉴 동기화 중 오류가 발생했습니다.");
} }
// 8. 카테고리 값 복제 // 8. 코드 카테고리 + 코드 복제
if (copyCategoryValues) { if (copyCodeCategory) {
try { try {
setCopyProgress({ current: stats.screens, total: totalScreenCount, message: "카테고리 값 복제 중..." }); setCopyProgress({ current: stats.screens, total: totalScreenCount, message: "코드 카테고리/코드 복제 중..." });
console.log("📋 카테고리 값 복제 시작..."); console.log("📋 코드 카테고리/코드 복제 시작...");
const response = await apiClient.post("/screen-management/copy-code-category", {
sourceCompanyCode: sourceGroup.company_code,
targetCompanyCode: finalCompanyCode,
});
if (response.data?.success) {
console.log("✅ 코드 카테고리/코드 복제 완료:", response.data.data);
toast.success(`코드 카테고리 ${response.data.data?.copiedCategories || 0}개, 코드 ${response.data.data?.copiedCodes || 0}개가 복제되었습니다.`);
} else {
console.warn("코드 카테고리/코드 복제 실패:", response.data?.error);
toast.warning("코드 카테고리/코드 복제에 실패했습니다.");
}
} catch (error) {
console.error("코드 카테고리/코드 복제 중 오류:", error);
toast.warning("코드 카테고리/코드 복제 중 오류가 발생했습니다.");
}
}
// 9. 카테고리 매핑 + 값 복제
if (copyCategoryMapping) {
try {
setCopyProgress({ current: stats.screens, total: totalScreenCount, message: "카테고리 매핑/값 복제 중..." });
console.log("📋 카테고리 매핑/값 복제 시작...");
const response = await apiClient.post("/screen-management/copy-category-mapping", { const response = await apiClient.post("/screen-management/copy-category-mapping", {
sourceCompanyCode: sourceGroup.company_code, sourceCompanyCode: sourceGroup.company_code,
@ -1051,19 +1073,19 @@ export default function CopyScreenModal({
}); });
if (response.data?.success) { if (response.data?.success) {
console.log("✅ 카테고리 값 복제 완료:", response.data.data); console.log("✅ 카테고리 매핑/값 복제 완료:", response.data.data);
toast.success(`카테고리 ${response.data.data?.copiedValues || 0}개가 복제되었습니다.`); toast.success(`카테고리 매핑 ${response.data.data?.copiedMappings || 0}개, ${response.data.data?.copiedValues || 0}개가 복제되었습니다.`);
} else { } else {
console.warn("카테고리 값 복제 실패:", response.data?.error); console.warn("카테고리 매핑/값 복제 실패:", response.data?.error);
toast.warning("카테고리 값 복제에 실패했습니다."); toast.warning("카테고리 매핑/값 복제에 실패했습니다.");
} }
} catch (error) { } catch (error) {
console.error("카테고리 값 복제 중 오류:", error); console.error("카테고리 매핑/값 복제 중 오류:", error);
toast.warning("카테고리 값 복제 중 오류가 발생했습니다."); toast.warning("카테고리 매핑/값 복제 중 오류가 발생했습니다.");
} }
} }
// 9. 테이블 타입관리 입력타입 설정 복제 // 10. 테이블 타입관리 입력타입 설정 복제
if (copyTableTypeColumns) { if (copyTableTypeColumns) {
try { try {
setCopyProgress({ current: stats.screens, total: totalScreenCount, message: "테이블 타입 컬럼 복제 중..." }); setCopyProgress({ current: stats.screens, total: totalScreenCount, message: "테이블 타입 컬럼 복제 중..." });
@ -1087,6 +1109,30 @@ export default function CopyScreenModal({
} }
} }
// 11. 연쇄관계 설정 복제
if (copyCascadingRelation) {
try {
setCopyProgress({ current: stats.screens, total: totalScreenCount, message: "연쇄관계 설정 복제 중..." });
console.log("📋 연쇄관계 설정 복제 시작...");
const response = await apiClient.post("/screen-management/copy-cascading-relation", {
sourceCompanyCode: sourceGroup.company_code,
targetCompanyCode: finalCompanyCode,
});
if (response.data?.success) {
console.log("✅ 연쇄관계 설정 복제 완료:", response.data.data);
toast.success(`연쇄관계 설정 ${response.data.data?.copiedCount || 0}개가 복제되었습니다.`);
} else {
console.warn("연쇄관계 설정 복제 실패:", response.data?.error);
toast.warning("연쇄관계 설정 복제에 실패했습니다.");
}
} catch (error) {
console.error("연쇄관계 설정 복제 중 오류:", error);
toast.warning("연쇄관계 설정 복제 중 오류가 발생했습니다.");
}
}
toast.success( toast.success(
`그룹 복제가 완료되었습니다! (그룹 ${stats.groups}개 + 화면 ${stats.screens}개)` `그룹 복제가 완료되었습니다! (그룹 ${stats.groups}개 + 화면 ${stats.screens}개)`
); );
@ -1245,6 +1291,19 @@ export default function CopyScreenModal({
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-xs sm:text-sm font-medium"> ():</Label> <Label className="text-xs sm:text-sm font-medium"> ():</Label>
{/* 코드 카테고리 + 코드 복사 */}
<div className="flex items-center space-x-2 p-2 bg-muted/30 rounded-md ml-2">
<Checkbox
id="copyCodeCategory"
checked={copyCodeCategory}
onCheckedChange={(checked) => setCopyCodeCategory(checked === true)}
/>
<Label htmlFor="copyCodeCategory" className="text-xs sm:text-sm cursor-pointer flex items-center gap-2">
<Code className="h-4 w-4 text-muted-foreground" />
+
</Label>
</div>
{/* 채번규칙 복제 */} {/* 채번규칙 복제 */}
<div className="flex items-center space-x-2 p-2 bg-muted/30 rounded-md ml-2"> <div className="flex items-center space-x-2 p-2 bg-muted/30 rounded-md ml-2">
<Checkbox <Checkbox
@ -1258,16 +1317,16 @@ export default function CopyScreenModal({
</Label> </Label>
</div> </div>
{/* 카테고리 값 복사 */} {/* 카테고리 매핑 + 값 복사 */}
<div className="flex items-center space-x-2 p-2 bg-muted/30 rounded-md ml-2"> <div className="flex items-center space-x-2 p-2 bg-muted/30 rounded-md ml-2">
<Checkbox <Checkbox
id="copyCategoryValues" id="copyCategoryMapping"
checked={copyCategoryValues} checked={copyCategoryMapping}
onCheckedChange={(checked) => setCopyCategoryValues(checked === true)} onCheckedChange={(checked) => setCopyCategoryMapping(checked === true)}
/> />
<Label htmlFor="copyCategoryValues" className="text-xs sm:text-sm cursor-pointer flex items-center gap-2"> <Label htmlFor="copyCategoryMapping" className="text-xs sm:text-sm cursor-pointer flex items-center gap-2">
<Table className="h-4 w-4 text-muted-foreground" /> <Table className="h-4 w-4 text-muted-foreground" />
+
</Label> </Label>
</div> </div>
@ -1283,6 +1342,19 @@ export default function CopyScreenModal({
</Label> </Label>
</div> </div>
{/* 연쇄관계 설정 복사 */}
<div className="flex items-center space-x-2 p-2 bg-muted/30 rounded-md ml-2">
<Checkbox
id="copyCascadingRelation"
checked={copyCascadingRelation}
onCheckedChange={(checked) => setCopyCascadingRelation(checked === true)}
/>
<Label htmlFor="copyCascadingRelation" className="text-xs sm:text-sm cursor-pointer flex items-center gap-2">
<Database className="h-4 w-4 text-muted-foreground" />
</Label>
</div>
</div> </div>
{/* 기본 복사 항목 안내 */} {/* 기본 복사 항목 안내 */}
@ -1294,7 +1366,7 @@ export default function CopyScreenModal({
<li> (, )</li> <li> (, )</li>
</ul> </ul>
<p className="text-[10px] text-muted-foreground mt-2 italic"> <p className="text-[10px] text-muted-foreground mt-2 italic">
* , , . * , , .
</p> </p>
</div> </div>
@ -1490,7 +1562,7 @@ export default function CopyScreenModal({
<div className="mt-1"> <div className="mt-1">
"{sourceGroup?.group_name}" " "{sourceGroup?.group_name}" "
{groupFindText {groupFindText
? (sourceGroup?.group_name || "").replace(new RegExp(escapeRegExp(groupFindText), "g"), groupReplaceText) ? (sourceGroup?.group_name || "").replace(new RegExp(groupFindText, "g"), groupReplaceText)
: `${sourceGroup?.group_name} (복제)`} : `${sourceGroup?.group_name} (복제)`}
" "
</div> </div>

View File

@ -499,9 +499,9 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
); );
} }
// 탭 컴포넌트 처리 (v1, v2 모두 지원) // 탭 컴포넌트 처리
const componentType = (comp as any).componentType || (comp as any).componentId; const componentType = (comp as any).componentType || (comp as any).componentId;
if (comp.type === "tabs" || (comp.type === "component" && (componentType === "tabs-widget" || componentType === "v2-tabs-widget"))) { if (comp.type === "tabs" || (comp.type === "component" && componentType === "tabs-widget")) {
const TabsWidget = require("@/components/screen/widgets/TabsWidget").TabsWidget; const TabsWidget = require("@/components/screen/widgets/TabsWidget").TabsWidget;
// componentConfig에서 탭 정보 추출 // componentConfig에서 탭 정보 추출
@ -517,26 +517,39 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
persistSelection: tabsConfig.persistSelection || false, persistSelection: tabsConfig.persistSelection || false,
}; };
console.log("🔍 탭 컴포넌트 렌더링:", {
originalType: comp.type,
componentType,
componentId: (comp as any).componentId,
tabs: tabsComponent.tabs,
tabsConfig,
});
return ( return (
<div className="h-full w-full"> <div className="h-full w-full">
<TabsWidget <TabsWidget
component={tabsComponent as any} component={tabsComponent as any}
menuObjid={menuObjid} menuObjid={menuObjid} // 🆕 부모의 menuObjid 전달
formData={formData}
onFormDataChange={updateFormData}
/> />
</div> </div>
); );
} }
// 🆕 렉 구조 컴포넌트 처리 (v1, v2 모두 지원) // 🆕 렉 구조 컴포넌트 처리
if (comp.type === "component" && (componentType === "rack-structure" || componentType === "v2-rack-structure")) { if (comp.type === "component" && componentType === "rack-structure") {
// v2 컴포넌트 사용 (v1은 deprecated) const { RackStructureComponent } = require("@/lib/registry/components/rack-structure/RackStructureComponent");
const { RackStructureComponent } = require("@/lib/registry/components/v2-rack-structure/RackStructureComponent");
const componentConfig = (comp as any).componentConfig || {}; const componentConfig = (comp as any).componentConfig || {};
// config가 중첩되어 있을 수 있음: componentConfig.config 또는 componentConfig 직접 // config가 중첩되어 있을 수 있음: componentConfig.config 또는 componentConfig 직접
const rackConfig = componentConfig.config || componentConfig; const rackConfig = componentConfig.config || componentConfig;
console.log("🏗️ 렉 구조 컴포넌트 렌더링:", {
componentType,
componentConfig,
rackConfig,
fieldMapping: rackConfig.fieldMapping,
formData,
});
return ( return (
<div className="h-full w-full overflow-auto"> <div className="h-full w-full overflow-auto">
<RackStructureComponent <RackStructureComponent
@ -544,6 +557,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
formData={formData} formData={formData}
tableName={tableName} tableName={tableName}
onChange={(locations: any[]) => { onChange={(locations: any[]) => {
console.log("📦 렉 구조 위치 데이터 변경:", locations.length, "개");
// 컴포넌트의 columnName을 키로 사용 // 컴포넌트의 columnName을 키로 사용
const fieldKey = (comp as any).columnName || "_rackStructureLocations"; const fieldKey = (comp as any).columnName || "_rackStructureLocations";
updateFormData(fieldKey, locations); updateFormData(fieldKey, locations);

View File

@ -73,10 +73,6 @@ import { FileAttachmentDetailModal } from "./FileAttachmentDetailModal";
import { initializeComponents } from "@/lib/registry/components"; import { initializeComponents } from "@/lib/registry/components";
import { ScreenFileAPI } from "@/lib/api/screenFile"; import { ScreenFileAPI } from "@/lib/api/screenFile";
import { safeMigrateLayout, needsMigration } from "@/lib/utils/widthToColumnSpan"; import { safeMigrateLayout, needsMigration } from "@/lib/utils/widthToColumnSpan";
import { convertV2ToLegacy, convertLegacyToV2, isValidV2Layout } from "@/lib/utils/layoutV2Converter";
// V2 API 사용 플래그 (true: V2, false: 기존)
const USE_V2_API = true;
import StyleEditor from "./StyleEditor"; import StyleEditor from "./StyleEditor";
import { RealtimePreview } from "./RealtimePreviewDynamic"; import { RealtimePreview } from "./RealtimePreviewDynamic";
@ -116,7 +112,6 @@ import "@/lib/registry/utils/performanceOptimizer";
interface ScreenDesignerProps { interface ScreenDesignerProps {
selectedScreen: ScreenDefinition | null; selectedScreen: ScreenDefinition | null;
onBackToList: () => void; onBackToList: () => void;
onScreenUpdate?: (updatedScreen: Partial<ScreenDefinition>) => void;
} }
// 패널 설정 (통합 패널 1개) // 패널 설정 (통합 패널 1개)
@ -132,7 +127,7 @@ const panelConfigs: PanelConfig[] = [
}, },
]; ];
export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenUpdate }: ScreenDesignerProps) { export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenDesignerProps) {
// 패널 상태 관리 // 패널 상태 관리
const { panelStates, togglePanel, openPanel, closePanel } = usePanelState(panelConfigs); const { panelStates, togglePanel, openPanel, closePanel } = usePanelState(panelConfigs);
@ -1247,21 +1242,12 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
console.warn("⚠️ 화면에 할당된 메뉴가 없습니다"); console.warn("⚠️ 화면에 할당된 메뉴가 없습니다");
} }
// V2 API 사용 여부에 따라 분기 const response = await screenApi.getLayout(selectedScreen.screenId);
let response: any;
if (USE_V2_API) {
const v2Response = await screenApi.getLayoutV2(selectedScreen.screenId);
response = v2Response ? convertV2ToLegacy(v2Response) : null;
console.log("📦 V2 레이아웃 로드:", v2Response?.components?.length || 0, "개 컴포넌트");
} else {
response = await screenApi.getLayout(selectedScreen.screenId);
}
if (response) { if (response) {
// 🔄 마이그레이션 필요 여부 확인 (V2는 스킵) // 🔄 마이그레이션 필요 여부 확인
let layoutToUse = response; let layoutToUse = response;
if (!USE_V2_API && needsMigration(response)) { if (needsMigration(response)) {
const canvasWidth = response.screenResolution?.width || 1920; const canvasWidth = response.screenResolution?.width || 1920;
layoutToUse = safeMigrateLayout(response, canvasWidth); layoutToUse = safeMigrateLayout(response, canvasWidth);
} }
@ -1692,23 +1678,11 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
})), })),
}); });
// V2 API 사용 여부에 따라 분기 await screenApi.saveLayout(selectedScreen.screenId, layoutWithResolution);
if (USE_V2_API) {
const v2Layout = convertLegacyToV2(layoutWithResolution);
await screenApi.saveLayoutV2(selectedScreen.screenId, v2Layout);
console.log("📦 V2 레이아웃 저장:", v2Layout.components.length, "개 컴포넌트");
} else {
await screenApi.saveLayout(selectedScreen.screenId, layoutWithResolution);
}
console.log("✅ 저장 성공! 메뉴 할당 모달 열기"); console.log("✅ 저장 성공! 메뉴 할당 모달 열기");
toast.success("화면이 저장되었습니다."); toast.success("화면이 저장되었습니다.");
// 저장 성공 후 부모에게 화면 정보 업데이트 알림 (테이블명 즉시 반영)
if (onScreenUpdate && currentMainTableName) {
onScreenUpdate({ tableName: currentMainTableName });
}
// 저장 성공 후 메뉴 할당 모달 열기 // 저장 성공 후 메뉴 할당 모달 열기
setShowMenuAssignmentModal(true); setShowMenuAssignmentModal(true);
} catch (error) { } catch (error) {
@ -1717,7 +1691,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
} finally { } finally {
setIsSaving(false); setIsSaving(false);
} }
}, [selectedScreen, layout, screenResolution, tables, onScreenUpdate]); }, [selectedScreen, layout, screenResolution, tables]);
// 다국어 자동 생성 핸들러 // 다국어 자동 생성 핸들러
const handleGenerateMultilang = useCallback(async () => { const handleGenerateMultilang = useCallback(async () => {
@ -1797,12 +1771,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
// 자동 저장 (매핑 정보가 손실되지 않도록) // 자동 저장 (매핑 정보가 손실되지 않도록)
try { try {
if (USE_V2_API) { await screenApi.saveLayout(selectedScreen.screenId, updatedLayout);
const v2Layout = convertLegacyToV2(updatedLayout);
await screenApi.saveLayoutV2(selectedScreen.screenId, v2Layout);
} else {
await screenApi.saveLayout(selectedScreen.screenId, updatedLayout);
}
toast.success(`${response.data.length}개의 다국어 키가 생성되고 자동 저장되었습니다.`); toast.success(`${response.data.length}개의 다국어 키가 생성되고 자동 저장되었습니다.`);
} catch (saveError) { } catch (saveError) {
console.error("다국어 매핑 저장 실패:", saveError); console.error("다국어 매핑 저장 실패:", saveError);
@ -4795,13 +4764,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
gridSettings: layoutWithResolution.gridSettings, gridSettings: layoutWithResolution.gridSettings,
screenResolution: layoutWithResolution.screenResolution, screenResolution: layoutWithResolution.screenResolution,
}); });
// V2 API 사용 여부에 따라 분기 await screenApi.saveLayout(selectedScreen.screenId, layoutWithResolution);
if (USE_V2_API) {
const v2Layout = convertLegacyToV2(layoutWithResolution);
await screenApi.saveLayoutV2(selectedScreen.screenId, v2Layout);
} else {
await screenApi.saveLayout(selectedScreen.screenId, layoutWithResolution);
}
toast.success("레이아웃이 저장되었습니다."); toast.success("레이아웃이 저장되었습니다.");
} catch (error) { } catch (error) {
// console.error("레이아웃 저장 실패:", error); // console.error("레이아웃 저장 실패:", error);

View File

@ -175,7 +175,7 @@ export function ScreenGroupTreeView({
const [syncProgress, setSyncProgress] = useState<{ message: string; detail?: string } | null>(null); const [syncProgress, setSyncProgress] = useState<{ message: string; detail?: string } | null>(null);
// 회사 선택 (최고 관리자용) // 회사 선택 (최고 관리자용)
const { user, switchCompany } = useAuth(); const { user } = useAuth();
const [companies, setCompanies] = useState<Company[]>([]); const [companies, setCompanies] = useState<Company[]>([]);
const [selectedCompanyCode, setSelectedCompanyCode] = useState<string>(""); const [selectedCompanyCode, setSelectedCompanyCode] = useState<string>("");
const [isSyncCompanySelectOpen, setIsSyncCompanySelectOpen] = useState(false); const [isSyncCompanySelectOpen, setIsSyncCompanySelectOpen] = useState(false);
@ -301,23 +301,19 @@ export function ScreenGroupTreeView({
} }
}; };
// 회사 선택 시 회사 전환 + 상태 조회 // 회사 선택 시 상태 조회
const handleCompanySelect = async (companyCode: string) => { const handleCompanySelect = async (companyCode: string) => {
setSelectedCompanyCode(companyCode); setSelectedCompanyCode(companyCode);
setIsSyncCompanySelectOpen(false); setIsSyncCompanySelectOpen(false);
setSyncStatus(null); setSyncStatus(null);
if (companyCode) { if (companyCode) {
// 🔧 회사 전환 (JWT 토큰 변경) - 모든 API가 선택한 회사로 동작하도록 const response = await getMenuScreenSyncStatus(companyCode);
const switchResult = await switchCompany(companyCode); if (response.success && response.data) {
if (!switchResult.success) { setSyncStatus(response.data);
toast.error(switchResult.message || "회사 전환 실패"); } else {
return; toast.error(response.error || "동기화 상태 조회 실패");
} }
toast.success(`${companyCode} 회사로 전환되었습니다. 페이지를 새로고침합니다.`);
// 🔧 페이지 새로고침으로 새 JWT 확실하게 적용
window.location.reload();
} }
}; };

View File

@ -76,7 +76,7 @@ type DeletedScreenDefinition = ScreenDefinition & {
}; };
export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScreen }: ScreenListProps) { export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScreen }: ScreenListProps) {
const { user, switchCompany } = useAuth(); const { user } = useAuth();
const isSuperAdmin = user?.userType === "SUPER_ADMIN" || user?.companyCode === "*"; const isSuperAdmin = user?.userType === "SUPER_ADMIN" || user?.companyCode === "*";
const [activeTab, setActiveTab] = useState("active"); const [activeTab, setActiveTab] = useState("active");
@ -190,31 +190,6 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
} }
}; };
// 🔧 회사 선택 시 회사 전환 (JWT 토큰 변경)
const handleCompanySelect = async (companyCode: string) => {
setSelectedCompanyCode(companyCode);
// "all"은 전체 조회이므로 회사 전환하지 않음 (최고 관리자 상태 유지)
if (companyCode && companyCode !== "all") {
const result = await switchCompany(companyCode);
if (!result.success) {
console.error("회사 전환 실패:", result.message);
return;
}
// 🔧 페이지 새로고침으로 새 JWT 확실하게 적용
window.location.reload();
} else if (companyCode === "all") {
// "전체 회사" 선택 시 최고 관리자 모드로 복귀
const result = await switchCompany("*");
if (!result.success) {
console.error("최고 관리자 모드 복귀 실패:", result.message);
return;
}
// 🔧 페이지 새로고침으로 새 JWT 확실하게 적용
window.location.reload();
}
};
// 화면 그룹 목록 로드 // 화면 그룹 목록 로드
useEffect(() => { useEffect(() => {
loadGroups(); loadGroups();
@ -711,7 +686,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
{/* 최고 관리자 전용: 회사 필터 */} {/* 최고 관리자 전용: 회사 필터 */}
{isSuperAdmin && ( {isSuperAdmin && (
<div className="w-full sm:w-[200px]"> <div className="w-full sm:w-[200px]">
<Select value={selectedCompanyCode} onValueChange={handleCompanySelect} disabled={activeTab === "trash"}> <Select value={selectedCompanyCode} onValueChange={setSelectedCompanyCode} disabled={activeTab === "trash"}>
<SelectTrigger className="h-10 text-sm"> <SelectTrigger className="h-10 text-sm">
<SelectValue placeholder="전체 회사" /> <SelectValue placeholder="전체 회사" />
</SelectTrigger> </SelectTrigger>

View File

@ -303,14 +303,6 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
allComponents={allComponents} // 🆕 연쇄 드롭다운 부모 감지용 allComponents={allComponents} // 🆕 연쇄 드롭다운 부모 감지용
currentComponent={selectedComponent} // 🆕 현재 컴포넌트 정보 currentComponent={selectedComponent} // 🆕 현재 컴포넌트 정보
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달 (채번 규칙 등) menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달 (채번 규칙 등)
// 🆕 집계 위젯 등에서 사용하는 컴포넌트 목록
screenComponents={allComponents.map((comp: any) => ({
id: comp.id,
componentType: comp.componentType || comp.type,
label: comp.label || comp.name || comp.id,
tableName: comp.componentConfig?.tableName || comp.tableName,
columnName: comp.columnName || comp.componentConfig?.columnName || comp.componentConfig?.fieldName,
}))}
/> />
</Suspense> </Suspense>
</div> </div>

View File

@ -1,668 +0,0 @@
"use client";
import React, { useState, useEffect } from "react";
import { useTableOptions } from "@/contexts/TableOptionsContext";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { GripVertical, Eye, EyeOff, Lock, ArrowRight, X, Settings, Filter, Layers } from "lucide-react";
import { ColumnVisibility, TableFilter, GroupSumConfig } from "@/types/table-options";
interface Props {
isOpen: boolean;
onClose: () => void;
onFiltersApplied?: (filters: TableFilter[]) => void;
screenId?: number;
}
// 컬럼 필터 설정 인터페이스
interface ColumnFilterConfig {
columnName: string;
columnLabel: string;
inputType: string;
enabled: boolean;
filterType: "text" | "number" | "date" | "select";
width?: number;
selectOptions?: Array<{ label: string; value: string }>;
}
export const TableSettingsModal: React.FC<Props> = ({ isOpen, onClose, onFiltersApplied, screenId }) => {
const { getTable, selectedTableId } = useTableOptions();
const table = selectedTableId ? getTable(selectedTableId) : undefined;
const [activeTab, setActiveTab] = useState("columns");
// 컬럼 가시성 상태
const [localColumns, setLocalColumns] = useState<ColumnVisibility[]>([]);
const [draggedColumnIndex, setDraggedColumnIndex] = useState<number | null>(null);
const [frozenColumnCount, setFrozenColumnCount] = useState<number>(0);
// 필터 상태
const [columnFilters, setColumnFilters] = useState<ColumnFilterConfig[]>([]);
const [selectAllFilters, setSelectAllFilters] = useState(false);
const [groupSumEnabled, setGroupSumEnabled] = useState(false);
const [groupByColumn, setGroupByColumn] = useState<string>("");
// 그룹화 상태
const [selectedGroupColumns, setSelectedGroupColumns] = useState<string[]>([]);
const [draggedGroupIndex, setDraggedGroupIndex] = useState<number | null>(null);
// 테이블 정보 로드 - 컬럼 가시성
useEffect(() => {
if (table) {
setLocalColumns(
table.columns.map((col) => ({
columnName: col.columnName,
visible: col.visible,
width: col.width,
order: 0,
}))
);
setFrozenColumnCount(table.frozenColumnCount ?? 0);
}
}, [table]);
// 테이블 정보 로드 - 필터
useEffect(() => {
if (table?.columns && table?.tableName) {
const storageKey = screenId
? `table_filters_${table.tableName}_screen_${screenId}`
: `table_filters_${table.tableName}`;
const savedFilters = localStorage.getItem(storageKey);
const groupSumKey = screenId
? `table_groupsum_${table.tableName}_screen_${screenId}`
: `table_groupsum_${table.tableName}`;
const savedGroupSum = localStorage.getItem(groupSumKey);
if (savedGroupSum) {
try {
const parsed = JSON.parse(savedGroupSum) as GroupSumConfig;
setGroupSumEnabled(parsed.enabled);
setGroupByColumn(parsed.groupByColumn || "");
} catch {
setGroupSumEnabled(false);
setGroupByColumn("");
}
}
if (savedFilters) {
try {
const parsed = JSON.parse(savedFilters);
setColumnFilters(parsed);
setSelectAllFilters(parsed.every((f: ColumnFilterConfig) => f.enabled));
} catch {
initializeFilters();
}
} else {
initializeFilters();
}
}
}, [table?.columns, table?.tableName, screenId]);
const initializeFilters = () => {
if (!table?.columns) return;
const filters: ColumnFilterConfig[] = table.columns
.filter((col) => col.columnName !== "__checkbox__")
.map((col) => {
let filterType: "text" | "number" | "date" | "select" = "text";
const inputType = col.inputType || "";
if (["number", "decimal", "currency", "integer"].includes(inputType)) {
filterType = "number";
} else if (["date", "datetime", "time"].includes(inputType)) {
filterType = "date";
} else if (["select", "dropdown", "code", "category", "entity"].includes(inputType)) {
filterType = "select";
}
return {
columnName: col.columnName,
columnLabel: col.columnLabel,
inputType,
enabled: false,
filterType,
width: 200,
};
});
setColumnFilters(filters);
setSelectAllFilters(false);
};
// 컬럼 가시성 핸들러
const handleVisibilityChange = (columnName: string, visible: boolean) => {
setLocalColumns((prev) =>
prev.map((col) => (col.columnName === columnName ? { ...col, visible } : col))
);
};
const handleWidthChange = (columnName: string, width: number) => {
setLocalColumns((prev) =>
prev.map((col) => (col.columnName === columnName ? { ...col, width } : col))
);
};
const moveColumn = (fromIndex: number, toIndex: number) => {
const newColumns = [...localColumns];
const [movedItem] = newColumns.splice(fromIndex, 1);
newColumns.splice(toIndex, 0, movedItem);
setLocalColumns(newColumns);
};
// 필터 핸들러
const handleFilterEnabledChange = (columnName: string, enabled: boolean) => {
setColumnFilters((prev) =>
prev.map((f) => (f.columnName === columnName ? { ...f, enabled } : f))
);
};
const handleFilterTypeChange = (columnName: string, filterType: "text" | "number" | "date" | "select") => {
setColumnFilters((prev) =>
prev.map((f) => (f.columnName === columnName ? { ...f, filterType } : f))
);
};
const handleFilterWidthChange = (columnName: string, width: number) => {
setColumnFilters((prev) =>
prev.map((f) => (f.columnName === columnName ? { ...f, width } : f))
);
};
const handleSelectAll = (checked: boolean) => {
setSelectAllFilters(checked);
setColumnFilters((prev) => prev.map((f) => ({ ...f, enabled: checked })));
};
// 그룹화 핸들러
const toggleGroupColumn = (columnName: string) => {
if (selectedGroupColumns.includes(columnName)) {
setSelectedGroupColumns(selectedGroupColumns.filter((c) => c !== columnName));
} else {
setSelectedGroupColumns([...selectedGroupColumns, columnName]);
}
};
const removeGroupColumn = (columnName: string) => {
setSelectedGroupColumns(selectedGroupColumns.filter((c) => c !== columnName));
};
const moveGroupColumn = (fromIndex: number, toIndex: number) => {
const newColumns = [...selectedGroupColumns];
const [movedItem] = newColumns.splice(fromIndex, 1);
newColumns.splice(toIndex, 0, movedItem);
setSelectedGroupColumns(newColumns);
};
const clearGrouping = () => {
setSelectedGroupColumns([]);
table?.onGroupChange([]);
};
// 틀고정 컬럼 수 변경 핸들러
const handleFrozenColumnCountChange = (value: string) => {
const count = parseInt(value) || 0;
// 최대값은 표시 가능한 컬럼 수
const maxCount = localColumns.filter((col) => col.visible).length;
setFrozenColumnCount(Math.min(Math.max(0, count), maxCount));
};
const visibleCount = localColumns.filter((col) => col.visible).length;
// 저장
const handleSave = () => {
if (!table) return;
// 1. 컬럼 가시성 저장
table.onColumnVisibilityChange(localColumns);
// 2. 컬럼 순서 변경 콜백 호출
if (table.onColumnOrderChange) {
const newOrder = localColumns
.map((col) => col.columnName)
.filter((name) => name !== "__checkbox__");
table.onColumnOrderChange(newOrder);
}
// 3. 틀고정 컬럼 수 변경 콜백 호출 (현재 컬럼 상태도 함께 전달)
if (table.onFrozenColumnCountChange) {
const updatedColumns = localColumns.map((col) => ({
columnName: col.columnName,
visible: col.visible,
}));
table.onFrozenColumnCountChange(frozenColumnCount, updatedColumns);
}
// 2. 필터 설정 저장
const storageKey = screenId
? `table_filters_${table.tableName}_screen_${screenId}`
: `table_filters_${table.tableName}`;
localStorage.setItem(storageKey, JSON.stringify(columnFilters));
// 그룹별 합산 설정 저장
const groupSumKey = screenId
? `table_groupsum_${table.tableName}_screen_${screenId}`
: `table_groupsum_${table.tableName}`;
const groupSumConfig: GroupSumConfig = {
enabled: groupSumEnabled,
groupByColumn: groupByColumn || undefined,
};
localStorage.setItem(groupSumKey, JSON.stringify(groupSumConfig));
// 활성화된 필터만 콜백
const activeFilters: TableFilter[] = columnFilters
.filter((f) => f.enabled)
.map((f) => ({
columnName: f.columnName,
operator: "contains",
value: "",
filterType: f.filterType,
width: f.width || 200,
}));
onFiltersApplied?.(activeFilters);
// 3. 그룹화 저장
table.onGroupChange(selectedGroupColumns);
onClose();
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-[95vw] sm:max-w-2xl">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
, ,
</DialogDescription>
</DialogHeader>
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="columns" className="gap-1.5 text-xs sm:text-sm">
<Settings className="h-3.5 w-3.5" />
</TabsTrigger>
<TabsTrigger value="filters" className="gap-1.5 text-xs sm:text-sm">
<Filter className="h-3.5 w-3.5" />
</TabsTrigger>
<TabsTrigger value="grouping" className="gap-1.5 text-xs sm:text-sm">
<Layers className="h-3.5 w-3.5" />
</TabsTrigger>
</TabsList>
{/* 컬럼 설정 탭 */}
<TabsContent value="columns" className="mt-4">
<div className="space-y-4">
{/* 상태 표시 및 틀고정 설정 */}
<div className="flex flex-col gap-3 rounded-lg border bg-muted/50 p-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-4">
<div className="text-muted-foreground text-xs sm:text-sm">
{visibleCount}/{localColumns.length}
</div>
{/* 틀고정 설정 */}
<div className="flex items-center gap-2">
<Lock className="text-muted-foreground h-4 w-4" />
<Label className="text-muted-foreground whitespace-nowrap text-xs">
:
</Label>
<Input
type="number"
value={frozenColumnCount}
onChange={(e) => handleFrozenColumnCountChange(e.target.value)}
className="h-7 w-16 text-xs sm:h-8 sm:w-20 sm:text-sm"
min={0}
max={visibleCount}
placeholder="0"
/>
<span className="text-muted-foreground whitespace-nowrap text-xs">
</span>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => {
if (table) {
setLocalColumns(
table.columns.map((col) => ({
columnName: col.columnName,
visible: true,
width: 150,
order: 0,
}))
);
setFrozenColumnCount(0);
}
}}
className="h-7 text-xs"
>
</Button>
</div>
{/* 컬럼 목록 */}
<ScrollArea className="h-[300px]">
<div className="space-y-2 pr-4">
{localColumns.map((col, index) => {
const originalCol = table?.columns.find((c) => c.columnName === col.columnName);
if (!originalCol) return null;
// 표시 가능한 컬럼 중 몇 번째인지 계산 (틀고정 표시용)
const visibleIndex = localColumns
.slice(0, index + 1)
.filter((c) => c.visible).length;
const isFrozen = col.visible && visibleIndex <= frozenColumnCount;
return (
<div
key={col.columnName}
draggable
onDragStart={() => setDraggedColumnIndex(index)}
onDragOver={(e) => {
e.preventDefault();
if (draggedColumnIndex !== null && draggedColumnIndex !== index) {
moveColumn(draggedColumnIndex, index);
setDraggedColumnIndex(index);
}
}}
onDragEnd={() => setDraggedColumnIndex(null)}
className={`flex cursor-move items-center gap-3 rounded-lg border p-3 transition-colors ${
isFrozen
? "border-blue-200 bg-blue-50 dark:border-blue-800 dark:bg-blue-950/30"
: "bg-background hover:bg-muted/50"
}`}
>
{/* 드래그 핸들 */}
<GripVertical className="text-muted-foreground h-4 w-4 shrink-0" />
{/* 체크박스 */}
<Checkbox
checked={col.visible}
onCheckedChange={(checked) =>
handleVisibilityChange(col.columnName, checked as boolean)
}
/>
{/* 가시성/틀고정 아이콘 */}
{isFrozen ? (
<Lock className="h-4 w-4 shrink-0 text-blue-500" />
) : col.visible ? (
<Eye className="text-primary h-4 w-4 shrink-0" />
) : (
<EyeOff className="text-muted-foreground h-4 w-4 shrink-0" />
)}
{/* 컬럼명 */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-xs font-medium sm:text-sm">
{originalCol.columnLabel}
</span>
{isFrozen && (
<span className="text-[10px] font-medium text-blue-600 dark:text-blue-400">
()
</span>
)}
</div>
<div className="text-muted-foreground truncate text-[10px] sm:text-xs">
{col.columnName}
</div>
</div>
{/* 너비 설정 */}
<div className="flex items-center gap-2">
<Label className="text-muted-foreground text-xs">:</Label>
<Input
type="number"
value={col.width || 150}
onChange={(e) => handleWidthChange(col.columnName, parseInt(e.target.value) || 150)}
className="h-7 w-16 text-xs sm:h-8 sm:w-20 sm:text-sm"
min={50}
max={500}
/>
</div>
</div>
);
})}
</div>
</ScrollArea>
</div>
</TabsContent>
{/* 필터 설정 탭 */}
<TabsContent value="filters" className="mt-4">
<div className="space-y-4">
{/* 전체 선택 */}
<div className="flex items-center gap-2">
<Checkbox
checked={selectAllFilters}
onCheckedChange={(checked) => handleSelectAll(checked as boolean)}
/>
<Label className="text-xs sm:text-sm"> </Label>
</div>
{/* 필터 목록 */}
<ScrollArea className="h-[300px]">
<div className="space-y-2 pr-4">
{columnFilters.map((filter) => (
<div
key={filter.columnName}
className="flex items-center gap-2 rounded-lg border bg-background p-2"
>
<Checkbox
checked={filter.enabled}
onCheckedChange={(checked) =>
handleFilterEnabledChange(filter.columnName, checked as boolean)
}
/>
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-medium sm:text-sm">
{filter.columnLabel}
</div>
</div>
<Select
value={filter.filterType}
onValueChange={(v) =>
handleFilterTypeChange(filter.columnName, v as "text" | "number" | "date" | "select")
}
>
<SelectTrigger className="h-7 w-20 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="text"></SelectItem>
<SelectItem value="number"></SelectItem>
<SelectItem value="date"></SelectItem>
<SelectItem value="select"></SelectItem>
</SelectContent>
</Select>
<Input
type="number"
min={100}
max={400}
value={filter.width || 200}
onChange={(e) =>
handleFilterWidthChange(filter.columnName, parseInt(e.target.value) || 200)
}
className="h-7 w-16 text-center text-xs"
/>
<span className="text-muted-foreground text-xs">px</span>
</div>
))}
</div>
</ScrollArea>
{/* 그룹별 합산 설정 */}
<div className="rounded-lg border bg-muted/30 p-3">
<div className="flex items-center justify-between">
<div>
<div className="text-xs font-medium sm:text-sm"> </div>
<div className="text-muted-foreground text-[10px] sm:text-xs">
</div>
</div>
<Switch checked={groupSumEnabled} onCheckedChange={setGroupSumEnabled} />
</div>
{groupSumEnabled && (
<div className="mt-3">
<Select value={groupByColumn} onValueChange={setGroupByColumn}>
<SelectTrigger className="h-8 text-xs sm:text-sm">
<SelectValue placeholder="그룹화 기준 컬럼 선택" />
</SelectTrigger>
<SelectContent>
{columnFilters.map((f) => (
<SelectItem key={f.columnName} value={f.columnName}>
{f.columnLabel}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
</div>
</TabsContent>
{/* 그룹 설정 탭 */}
<TabsContent value="grouping" className="mt-4">
<div className="space-y-4">
{/* 선택된 그룹화 컬럼 */}
{selectedGroupColumns.length > 0 && (
<div>
<div className="mb-2 flex items-center justify-between">
<div className="text-xs font-medium sm:text-sm">
({selectedGroupColumns.length})
</div>
<Button variant="ghost" size="sm" onClick={clearGrouping} className="h-7 text-xs">
</Button>
</div>
<div className="space-y-2">
{selectedGroupColumns.map((colName, index) => {
const col = table?.columns.find((c) => c.columnName === colName);
if (!col) return null;
return (
<div
key={colName}
draggable
onDragStart={() => setDraggedGroupIndex(index)}
onDragOver={(e) => {
e.preventDefault();
if (draggedGroupIndex !== null && draggedGroupIndex !== index) {
moveGroupColumn(draggedGroupIndex, index);
setDraggedGroupIndex(index);
}
}}
onDragEnd={() => setDraggedGroupIndex(null)}
className="hover:bg-primary/10 bg-primary/5 flex cursor-move items-center gap-2 rounded-lg border p-2 transition-colors"
>
<GripVertical className="text-muted-foreground h-4 w-4 flex-shrink-0" />
<div className="bg-primary text-primary-foreground flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-full text-xs">
{index + 1}
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-medium sm:text-sm">{col.columnLabel}</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => removeGroupColumn(colName)}
className="h-6 w-6 flex-shrink-0 p-0"
>
<X className="h-3 w-3" />
</Button>
</div>
);
})}
</div>
{/* 그룹화 순서 미리보기 */}
<div className="bg-muted/30 mt-2 rounded-lg border p-2">
<div className="flex flex-wrap items-center gap-2 text-xs">
{selectedGroupColumns.map((colName, index) => {
const col = table?.columns.find((c) => c.columnName === colName);
return (
<React.Fragment key={colName}>
<span className="font-medium">{col?.columnLabel}</span>
{index < selectedGroupColumns.length - 1 && (
<ArrowRight className="text-muted-foreground h-3 w-3" />
)}
</React.Fragment>
);
})}
</div>
</div>
</div>
)}
{/* 사용 가능한 컬럼 */}
<div>
<div className="mb-2 text-xs font-medium sm:text-sm"> </div>
<ScrollArea className={selectedGroupColumns.length > 0 ? "h-[200px]" : "h-[320px]"}>
<div className="space-y-2 pr-4">
{table?.columns
.filter((col) => !selectedGroupColumns.includes(col.columnName))
.map((col) => (
<div
key={col.columnName}
className="hover:bg-muted/50 flex cursor-pointer items-center gap-3 rounded-lg border bg-background p-2 transition-colors"
onClick={() => toggleGroupColumn(col.columnName)}
>
<Checkbox
checked={false}
onCheckedChange={() => toggleGroupColumn(col.columnName)}
className="flex-shrink-0"
/>
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-medium sm:text-sm">{col.columnLabel}</div>
<div className="text-muted-foreground truncate text-[10px] sm:text-xs">
{col.columnName}
</div>
</div>
</div>
))}
</div>
</ScrollArea>
</div>
</div>
</TabsContent>
</Tabs>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={onClose}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button onClick={handleSave} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@ -1,20 +1,13 @@
"use client"; "use client";
import React, { useState, useEffect, useMemo } from "react"; import React, { useState, useEffect } from "react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { X, Loader2 } from "lucide-react"; import { X } from "lucide-react";
import type { TabsComponent, TabItem, TabInlineComponent, ComponentData } from "@/types/screen-management"; import type { TabsComponent, TabItem, TabInlineComponent } from "@/types/screen-management";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useActiveTab } from "@/contexts/ActiveTabContext"; import { useActiveTab } from "@/contexts/ActiveTabContext";
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer"; import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
import { screenApi } from "@/lib/api/screen";
// 확장된 TabItem 타입 (screenId 지원)
interface ExtendedTabItem extends TabItem {
screenId?: number;
screenName?: string;
}
interface TabsWidgetProps { interface TabsWidgetProps {
component: TabsComponent; component: TabsComponent;
@ -22,10 +15,10 @@ interface TabsWidgetProps {
style?: React.CSSProperties; style?: React.CSSProperties;
menuObjid?: number; menuObjid?: number;
formData?: Record<string, any>; formData?: Record<string, any>;
onFormDataChange?: (fieldName: string, value: any) => void; onFormDataChange?: (data: Record<string, any>) => void;
isDesignMode?: boolean; isDesignMode?: boolean; // 디자인 모드 여부
onComponentSelect?: (tabId: string, componentId: string) => void; onComponentSelect?: (tabId: string, componentId: string) => void; // 컴포넌트 선택 콜백
selectedComponentId?: string; selectedComponentId?: string; // 선택된 컴포넌트 ID
} }
export function TabsWidget({ export function TabsWidget({
@ -63,45 +56,14 @@ export function TabsWidget({
}; };
const [selectedTab, setSelectedTab] = useState<string>(getInitialTab()); const [selectedTab, setSelectedTab] = useState<string>(getInitialTab());
const [visibleTabs, setVisibleTabs] = useState<ExtendedTabItem[]>(tabs as ExtendedTabItem[]); const [visibleTabs, setVisibleTabs] = useState<TabItem[]>(tabs);
const [mountedTabs, setMountedTabs] = useState<Set<string>>(() => new Set([getInitialTab()])); const [mountedTabs, setMountedTabs] = useState<Set<string>>(() => new Set([getInitialTab()]));
// screenId 기반 화면 로드 상태
const [screenLayouts, setScreenLayouts] = useState<Record<string, ComponentData[]>>({});
const [screenLoadingStates, setScreenLoadingStates] = useState<Record<string, boolean>>({});
const [screenErrors, setScreenErrors] = useState<Record<string, string>>({});
// 컴포넌트 탭 목록 변경 시 동기화 // 컴포넌트 탭 목록 변경 시 동기화
useEffect(() => { useEffect(() => {
setVisibleTabs((tabs as ExtendedTabItem[]).filter((tab) => !tab.disabled)); setVisibleTabs(tabs.filter((tab) => !tab.disabled));
}, [tabs]); }, [tabs]);
// screenId가 있는 탭의 화면 레이아웃 로드
useEffect(() => {
const loadScreenLayouts = async () => {
for (const tab of visibleTabs) {
const extTab = tab as ExtendedTabItem;
// screenId가 있고, 아직 로드하지 않았으며, 인라인 컴포넌트가 없는 경우만 로드
if (extTab.screenId && !screenLayouts[tab.id] && !screenLoadingStates[tab.id] && (!extTab.components || extTab.components.length === 0)) {
setScreenLoadingStates(prev => ({ ...prev, [tab.id]: true }));
try {
const layoutData = await screenApi.getLayout(extTab.screenId);
if (layoutData && layoutData.components) {
setScreenLayouts(prev => ({ ...prev, [tab.id]: layoutData.components }));
}
} catch (error) {
console.error(`탭 "${tab.label}" 화면 로드 실패:`, error);
setScreenErrors(prev => ({ ...prev, [tab.id]: "화면을 불러올 수 없습니다." }));
} finally {
setScreenLoadingStates(prev => ({ ...prev, [tab.id]: false }));
}
}
}
};
loadScreenLayouts();
}, [visibleTabs, screenLayouts, screenLoadingStates]);
// 선택된 탭 변경 시 localStorage에 저장 + ActiveTab Context 업데이트 // 선택된 탭 변경 시 localStorage에 저장 + ActiveTab Context 업데이트
useEffect(() => { useEffect(() => {
if (persistSelection && typeof window !== "undefined") { if (persistSelection && typeof window !== "undefined") {
@ -161,110 +123,20 @@ export function TabsWidget({
return `${baseClass} ${variantClass}`; return `${baseClass} ${variantClass}`;
}; };
// 탭 컨텐츠 렌더링 (screenId 또는 인라인 컴포넌트) // 인라인 컴포넌트 렌더링
const renderTabContent = (tab: ExtendedTabItem) => { const renderTabComponents = (tab: TabItem) => {
const extTab = tab as ExtendedTabItem; const components = tab.components || [];
const inlineComponents = tab.components || [];
if (components.length === 0) {
// 1. screenId가 있고 인라인 컴포넌트가 없는 경우 -> 화면 로드 방식
if (extTab.screenId && inlineComponents.length === 0) {
// 로딩 중
if (screenLoadingStates[tab.id]) {
return (
<div className="flex h-full w-full items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<span className="ml-2 text-muted-foreground"> ...</span>
</div>
);
}
// 에러 발생
if (screenErrors[tab.id]) {
return (
<div className="flex h-full w-full items-center justify-center rounded border-2 border-dashed border-destructive/50 bg-destructive/5">
<p className="text-destructive text-sm">{screenErrors[tab.id]}</p>
</div>
);
}
// 화면 레이아웃이 로드된 경우
const loadedComponents = screenLayouts[tab.id];
if (loadedComponents && loadedComponents.length > 0) {
return renderScreenComponents(loadedComponents);
}
// 아직 로드되지 않은 경우
return ( return (
<div className="flex h-full w-full items-center justify-center"> <div className="flex h-full w-full items-center justify-center rounded border-2 border-dashed border-gray-300 bg-gray-50">
<Loader2 className="h-8 w-8 animate-spin text-primary" /> <p className="text-muted-foreground text-sm">
{isDesignMode ? "컴포넌트를 드래그하여 추가하세요" : "컴포넌트가 없습니다"}
</p>
</div> </div>
); );
} }
// 2. 인라인 컴포넌트가 있는 경우 -> 기존 v2 방식
if (inlineComponents.length > 0) {
return renderInlineComponents(tab, inlineComponents);
}
// 3. 둘 다 없는 경우
return (
<div className="flex h-full w-full items-center justify-center rounded border-2 border-dashed border-gray-300 bg-gray-50">
<p className="text-muted-foreground text-sm">
{isDesignMode ? "컴포넌트를 드래그하여 추가하세요" : "컴포넌트가 없습니다"}
</p>
</div>
);
};
// screenId로 로드한 화면 컴포넌트 렌더링
const renderScreenComponents = (components: ComponentData[]) => {
// InteractiveScreenViewerDynamic 동적 로드
const InteractiveScreenViewerDynamic = require("@/components/screen/InteractiveScreenViewerDynamic").InteractiveScreenViewerDynamic;
// 컴포넌트들의 최대 위치를 계산하여 스크롤 가능한 영역 확보
const maxBottom = Math.max(
...components.map((c) => (c.position?.y || 0) + (c.size?.height || 100)),
300
);
const maxRight = Math.max(
...components.map((c) => (c.position?.x || 0) + (c.size?.width || 200)),
400
);
return (
<div
className="relative h-full w-full overflow-auto"
style={{
minHeight: maxBottom + 20,
minWidth: maxRight + 20,
}}
>
{components.map((comp) => (
<div
key={comp.id}
className="absolute"
style={{
left: comp.position?.x || 0,
top: comp.position?.y || 0,
width: comp.size?.width || "auto",
height: comp.size?.height || "auto",
}}
>
<InteractiveScreenViewerDynamic
component={comp}
allComponents={components}
formData={formData}
onFormDataChange={onFormDataChange}
menuObjid={menuObjid}
/>
</div>
))}
</div>
);
};
// 인라인 컴포넌트 렌더링 (v2 방식)
const renderInlineComponents = (tab: TabItem, components: TabInlineComponent[]) => {
// 컴포넌트들의 최대 위치를 계산하여 스크롤 가능한 영역 확보 // 컴포넌트들의 최대 위치를 계산하여 스크롤 가능한 영역 확보
const maxBottom = Math.max( const maxBottom = Math.max(
...components.map((c) => (c.position?.y || 0) + (c.size?.height || 100)), ...components.map((c) => (c.position?.y || 0) + (c.size?.height || 100)),
@ -384,7 +256,7 @@ export function TabsWidget({
forceMount forceMount
className={cn("h-full overflow-auto", !isActive && "hidden")} className={cn("h-full overflow-auto", !isActive && "hidden")}
> >
{shouldRender && renderTabContent(tab)} {shouldRender && renderTabComponents(tab)}
</TabsContent> </TabsContent>
); );
})} })}

View File

@ -3,10 +3,9 @@
/** /**
* - * -
* - 3 (//) * - 3 (//)
* -
*/ */
import React, { useState, useEffect, useCallback, useMemo } from "react"; import React, { useState, useEffect, useCallback } from "react";
import { import {
ChevronRight, ChevronRight,
ChevronDown, ChevronDown,
@ -21,7 +20,6 @@ import {
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import {
CategoryValue, CategoryValue,
@ -67,13 +65,11 @@ interface TreeNodeProps {
expandedNodes: Set<number>; expandedNodes: Set<number>;
selectedValueId?: number; selectedValueId?: number;
searchQuery: string; searchQuery: string;
checkedIds: Set<number>;
onToggle: (valueId: number) => void; onToggle: (valueId: number) => void;
onSelect: (value: CategoryValue) => void; onSelect: (value: CategoryValue) => void;
onAdd: (parentValue: CategoryValue | null) => void; onAdd: (parentValue: CategoryValue | null) => void;
onEdit: (value: CategoryValue) => void; onEdit: (value: CategoryValue) => void;
onDelete: (value: CategoryValue) => void; onDelete: (value: CategoryValue) => void;
onCheck: (valueId: number, checked: boolean) => void;
} }
// 검색어가 노드 또는 하위에 매칭되는지 확인 // 검색어가 노드 또는 하위에 매칭되는지 확인
@ -94,18 +90,15 @@ const TreeNode: React.FC<TreeNodeProps> = ({
expandedNodes, expandedNodes,
selectedValueId, selectedValueId,
searchQuery, searchQuery,
checkedIds,
onToggle, onToggle,
onSelect, onSelect,
onAdd, onAdd,
onEdit, onEdit,
onDelete, onDelete,
onCheck,
}) => { }) => {
const hasChildren = node.children && node.children.length > 0; const hasChildren = node.children && node.children.length > 0;
const isExpanded = expandedNodes.has(node.valueId); const isExpanded = expandedNodes.has(node.valueId);
const isSelected = selectedValueId === node.valueId; const isSelected = selectedValueId === node.valueId;
const isChecked = checkedIds.has(node.valueId);
const canAddChild = node.depth < 3; const canAddChild = node.depth < 3;
// 검색 필터링 // 검색 필터링
@ -145,22 +138,11 @@ const TreeNode: React.FC<TreeNodeProps> = ({
className={cn( className={cn(
"group flex items-center gap-1 rounded-md px-2 py-2 transition-colors", "group flex items-center gap-1 rounded-md px-2 py-2 transition-colors",
isSelected ? "border-primary bg-primary/10 border-l-2" : "hover:bg-muted/50", isSelected ? "border-primary bg-primary/10 border-l-2" : "hover:bg-muted/50",
isChecked && "bg-primary/5",
"cursor-pointer", "cursor-pointer",
)} )}
style={{ paddingLeft: `${level * 20 + 8}px` }} style={{ paddingLeft: `${level * 20 + 8}px` }}
onClick={() => onSelect(node)} onClick={() => onSelect(node)}
> >
{/* 체크박스 */}
<Checkbox
checked={isChecked}
onCheckedChange={(checked) => {
onCheck(node.valueId, checked as boolean);
}}
onClick={(e) => e.stopPropagation()}
className="mr-1"
/>
{/* 확장 토글 */} {/* 확장 토글 */}
<button <button
type="button" type="button"
@ -251,13 +233,11 @@ const TreeNode: React.FC<TreeNodeProps> = ({
expandedNodes={expandedNodes} expandedNodes={expandedNodes}
selectedValueId={selectedValueId} selectedValueId={selectedValueId}
searchQuery={searchQuery} searchQuery={searchQuery}
checkedIds={checkedIds}
onToggle={onToggle} onToggle={onToggle}
onSelect={onSelect} onSelect={onSelect}
onAdd={onAdd} onAdd={onAdd}
onEdit={onEdit} onEdit={onEdit}
onDelete={onDelete} onDelete={onDelete}
onCheck={onCheck}
/> />
))} ))}
</div> </div>
@ -279,13 +259,11 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
const [selectedValue, setSelectedValue] = useState<CategoryValue | null>(null); const [selectedValue, setSelectedValue] = useState<CategoryValue | null>(null);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [showInactive, setShowInactive] = useState(false); const [showInactive, setShowInactive] = useState(false);
const [checkedIds, setCheckedIds] = useState<Set<number>>(new Set());
// 모달 상태 // 모달 상태
const [isAddModalOpen, setIsAddModalOpen] = useState(false); const [isAddModalOpen, setIsAddModalOpen] = useState(false);
const [isEditModalOpen, setIsEditModalOpen] = useState(false); const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
const [parentValue, setParentValue] = useState<CategoryValue | null>(null); const [parentValue, setParentValue] = useState<CategoryValue | null>(null);
const [editingValue, setEditingValue] = useState<CategoryValue | null>(null); const [editingValue, setEditingValue] = useState<CategoryValue | null>(null);
const [deletingValue, setDeletingValue] = useState<CategoryValue | null>(null); const [deletingValue, setDeletingValue] = useState<CategoryValue | null>(null);
@ -310,54 +288,6 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
return count; return count;
}, []); }, []);
// 하위 항목 개수만 계산 (자기 자신 제외)
const countAllDescendants = useCallback(
(node: CategoryValue): number => {
if (!node.children || node.children.length === 0) {
return 0;
}
return countAllValues(node.children);
},
[countAllValues],
);
// 노드와 모든 하위 항목의 ID 수집
const collectNodeAndDescendantIds = useCallback((node: CategoryValue): number[] => {
const ids: number[] = [node.valueId];
if (node.children) {
for (const child of node.children) {
ids.push(...collectNodeAndDescendantIds(child));
}
}
return ids;
}, []);
// 트리에서 valueId로 노드 찾기
const findNodeById = useCallback((nodes: CategoryValue[], valueId: number): CategoryValue | null => {
for (const node of nodes) {
if (node.valueId === valueId) {
return node;
}
if (node.children) {
const found = findNodeById(node.children, valueId);
if (found) return found;
}
}
return null;
}, []);
// 체크된 항목들의 총 삭제 대상 수 계산 (하위 포함)
const totalDeleteCount = useMemo(() => {
const allIds = new Set<number>();
checkedIds.forEach((id) => {
const node = findNodeById(tree, id);
if (node) {
collectNodeAndDescendantIds(node).forEach((descendantId) => allIds.add(descendantId));
}
});
return allIds.size;
}, [checkedIds, tree, findNodeById, collectNodeAndDescendantIds]);
// 활성 노드만 필터링 // 활성 노드만 필터링
const filterActiveNodes = useCallback((nodes: CategoryValue[]): CategoryValue[] => { const filterActiveNodes = useCallback((nodes: CategoryValue[]): CategoryValue[] => {
return nodes return nodes
@ -368,41 +298,37 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
})); }));
}, []); }, []);
// 데이터 로드 (keepExpanded: true면 기존 펼침 상태 유지) // 데이터 로드
const loadTree = useCallback( const loadTree = useCallback(async () => {
async (keepExpanded = false) => { if (!tableName || !columnName) return;
if (!tableName || !columnName) return;
setLoading(true); setLoading(true);
try { try {
const response = await getCategoryTree(tableName, columnName); const response = await getCategoryTree(tableName, columnName);
if (response.success && response.data) { if (response.success && response.data) {
let filteredTree = response.data; let filteredTree = response.data;
// 비활성 필터링 // 비활성 필터링
if (!showInactive) { if (!showInactive) {
filteredTree = filterActiveNodes(response.data); filteredTree = filterActiveNodes(response.data);
}
setTree(filteredTree);
// 기존 펼침 상태 유지하지 않으면 모두 접기 (대분류만 표시)
if (!keepExpanded) {
setExpandedNodes(new Set());
}
// 전체 개수 업데이트
const totalCount = countAllValues(response.data);
onValueCountChange?.(totalCount);
} }
} catch (error) {
console.error("카테고리 트리 로드 오류:", error); setTree(filteredTree);
} finally {
setLoading(false); // 1단계 노드는 기본 펼침
const rootIds = new Set(filteredTree.map((n) => n.valueId));
setExpandedNodes(rootIds);
// 전체 개수 업데이트
const totalCount = countAllValues(response.data);
onValueCountChange?.(totalCount);
} }
}, } catch (error) {
[tableName, columnName, showInactive, countAllValues, filterActiveNodes, onValueCountChange], console.error("카테고리 트리 로드 오류:", error);
); } finally {
setLoading(false);
}
}, [tableName, columnName, showInactive, countAllValues, filterActiveNodes, onValueCountChange]);
useEffect(() => { useEffect(() => {
loadTree(); loadTree();
@ -441,43 +367,6 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
}); });
}; };
// 체크박스 핸들러
const handleCheck = useCallback((valueId: number, checked: boolean) => {
setCheckedIds((prev) => {
const next = new Set(prev);
if (checked) {
next.add(valueId);
} else {
next.delete(valueId);
}
return next;
});
}, []);
// 전체 선택/해제
const handleSelectAll = useCallback(() => {
if (checkedIds.size === countAllValues(tree)) {
setCheckedIds(new Set());
} else {
const allIds = new Set<number>();
const collectAllIds = (nodes: CategoryValue[]) => {
for (const node of nodes) {
allIds.add(node.valueId);
if (node.children) {
collectAllIds(node.children);
}
}
};
collectAllIds(tree);
setCheckedIds(allIds);
}
}, [checkedIds.size, tree, countAllValues]);
// 선택 해제
const handleClearSelection = useCallback(() => {
setCheckedIds(new Set());
}, []);
// 추가 모달 열기 // 추가 모달 열기
const handleOpenAddModal = (parent: CategoryValue | null) => { const handleOpenAddModal = (parent: CategoryValue | null) => {
setParentValue(parent); setParentValue(parent);
@ -543,9 +432,7 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
if (response.success) { if (response.success) {
toast.success("카테고리가 추가되었습니다"); toast.success("카테고리가 추가되었습니다");
setIsAddModalOpen(false); setIsAddModalOpen(false);
// 기존 펼침 상태 유지하면서 데이터 새로고침 loadTree();
await loadTree(true);
// 부모 노드만 펼치기 (하위 추가 시)
if (parentValue) { if (parentValue) {
setExpandedNodes((prev) => new Set([...prev, parentValue.valueId])); setExpandedNodes((prev) => new Set([...prev, parentValue.valueId]));
} }
@ -574,7 +461,7 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
if (response.success) { if (response.success) {
toast.success("카테고리가 수정되었습니다"); toast.success("카테고리가 수정되었습니다");
setIsEditModalOpen(false); setIsEditModalOpen(false);
loadTree(true); // 기존 펼침 상태 유지 loadTree();
} else { } else {
toast.error(response.error || "수정 실패"); toast.error(response.error || "수정 실패");
} }
@ -594,12 +481,7 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
toast.success("카테고리가 삭제되었습니다"); toast.success("카테고리가 삭제되었습니다");
setIsDeleteDialogOpen(false); setIsDeleteDialogOpen(false);
setSelectedValue(null); setSelectedValue(null);
setCheckedIds((prev) => { loadTree();
const next = new Set(prev);
next.delete(deletingValue.valueId);
return next;
});
loadTree(true); // 기존 펼침 상태 유지
} else { } else {
toast.error(response.error || "삭제 실패"); toast.error(response.error || "삭제 실패");
} }
@ -609,85 +491,22 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
} }
}; };
// 다중 삭제 처리
const handleBulkDelete = async () => {
if (checkedIds.size === 0) return;
try {
let successCount = 0;
let failCount = 0;
// 체크된 항목들을 순차적으로 삭제 (하위는 백엔드에서 자동 삭제)
for (const valueId of Array.from(checkedIds)) {
try {
const response = await deleteCategoryValue(valueId);
if (response.success) {
successCount++;
} else {
failCount++;
}
} catch {
failCount++;
}
}
setIsBulkDeleteDialogOpen(false);
setCheckedIds(new Set());
setSelectedValue(null);
loadTree(true); // 기존 펼침 상태 유지
if (failCount === 0) {
toast.success(`${successCount}개 카테고리가 삭제되었습니다 (하위 항목 포함)`);
} else {
toast.warning(`${successCount}개 삭제 성공, ${failCount}개 삭제 실패`);
}
} catch (error) {
console.error("카테고리 일괄 삭제 오류:", error);
toast.error("카테고리 일괄 삭제 중 오류가 발생했습니다");
}
};
return ( return (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
{/* 헤더 */} {/* 헤더 */}
<div className="mb-3 flex items-center justify-between border-b pb-3"> <div className="mb-3 flex items-center justify-between border-b pb-3">
<div className="flex items-center gap-2"> <h3 className="text-base font-semibold">{columnLabel} </h3>
<h3 className="text-base font-semibold">{columnLabel} </h3> <Button variant="default" size="sm" className="h-8 gap-1.5 text-xs" onClick={() => handleOpenAddModal(null)}>
{checkedIds.size > 0 && ( <Plus className="h-3.5 w-3.5" />
<span className="bg-primary/10 text-primary rounded-full px-2 py-0.5 text-xs">
{checkedIds.size} </Button>
</span>
)}
</div>
<div className="flex items-center gap-2">
{checkedIds.size > 0 && (
<>
<Button variant="outline" size="sm" className="h-8 text-xs" onClick={handleClearSelection}>
</Button>
<Button
variant="destructive"
size="sm"
className="h-8 gap-1.5 text-xs"
onClick={() => setIsBulkDeleteDialogOpen(true)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</>
)}
<Button variant="default" size="sm" className="h-8 gap-1.5 text-xs" onClick={() => handleOpenAddModal(null)}>
<Plus className="h-3.5 w-3.5" />
</Button>
</div>
</div> </div>
{/* 툴바 */} {/* 툴바 */}
<div className="mb-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between"> <div className="mb-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
{/* 검색 */} {/* 검색 */}
<div className="relative max-w-xs flex-1"> <div className="relative max-w-xs flex-1">
<Search className="text-muted-foreground absolute top-1/2 left-2.5 h-4 w-4 -translate-y-1/2" /> <Search className="text-muted-foreground absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2" />
<Input <Input
placeholder="검색..." placeholder="검색..."
value={searchQuery} value={searchQuery}
@ -706,16 +525,13 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={handleSelectAll}>
{checkedIds.size === countAllValues(tree) && tree.length > 0 ? "전체 해제" : "전체 선택"}
</Button>
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={expandAll}> <Button variant="ghost" size="sm" className="h-7 text-xs" onClick={expandAll}>
</Button> </Button>
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={collapseAll}> <Button variant="ghost" size="sm" className="h-7 text-xs" onClick={collapseAll}>
</Button> </Button>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => loadTree()} title="새로고침"> <Button variant="ghost" size="icon" className="h-7 w-7" onClick={loadTree} title="새로고침">
<RefreshCw className="h-3.5 w-3.5" /> <RefreshCw className="h-3.5 w-3.5" />
</Button> </Button>
</div> </div>
@ -744,19 +560,18 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
expandedNodes={expandedNodes} expandedNodes={expandedNodes}
selectedValueId={selectedValue?.valueId} selectedValueId={selectedValue?.valueId}
searchQuery={searchQuery} searchQuery={searchQuery}
checkedIds={checkedIds}
onToggle={handleToggle} onToggle={handleToggle}
onSelect={setSelectedValue} onSelect={setSelectedValue}
onAdd={handleOpenAddModal} onAdd={handleOpenAddModal}
onEdit={handleOpenEditModal} onEdit={handleOpenEditModal}
onDelete={handleOpenDeleteDialog} onDelete={handleOpenDeleteDialog}
onCheck={handleCheck}
/> />
))} ))}
</div> </div>
)} )}
</div> </div>
{/* 추가 모달 */} {/* 추가 모달 */}
<Dialog open={isAddModalOpen} onOpenChange={setIsAddModalOpen}> <Dialog open={isAddModalOpen} onOpenChange={setIsAddModalOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[450px]"> <DialogContent className="max-w-[95vw] sm:max-w-[450px]">
@ -765,9 +580,7 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
{parentValue ? `"${parentValue.valueLabel}" 하위 추가` : "대분류 추가"} {parentValue ? `"${parentValue.valueLabel}" 하위 추가` : "대분류 추가"}
</DialogTitle> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm"> <DialogDescription className="text-xs sm:text-sm">
{parentValue {parentValue ? `${parentValue.depth + 1}단계 카테고리를 추가합니다` : "1단계 대분류 카테고리를 추가합니다"}
? `${parentValue.depth + 1}단계 카테고리를 추가합니다`
: "1단계 대분류 카테고리를 추가합니다"}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@ -810,11 +623,7 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
</div> </div>
<DialogFooter className="gap-2 sm:gap-0"> <DialogFooter className="gap-2 sm:gap-0">
<Button <Button variant="outline" onClick={() => setIsAddModalOpen(false)} className="h-9 flex-1 text-sm sm:flex-none">
variant="outline"
onClick={() => setIsAddModalOpen(false)}
className="h-9 flex-1 text-sm sm:flex-none"
>
</Button> </Button>
<Button onClick={handleAdd} className="h-9 flex-1 text-sm sm:flex-none"> <Button onClick={handleAdd} className="h-9 flex-1 text-sm sm:flex-none">
@ -868,11 +677,7 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
</div> </div>
<DialogFooter className="gap-2 sm:gap-0"> <DialogFooter className="gap-2 sm:gap-0">
<Button <Button variant="outline" onClick={() => setIsEditModalOpen(false)} className="h-9 flex-1 text-sm sm:flex-none">
variant="outline"
onClick={() => setIsEditModalOpen(false)}
className="h-9 flex-1 text-sm sm:flex-none"
>
</Button> </Button>
<Button onClick={handleEdit} className="h-9 flex-1 text-sm sm:flex-none"> <Button onClick={handleEdit} className="h-9 flex-1 text-sm sm:flex-none">
@ -889,11 +694,11 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
<AlertDialogTitle> </AlertDialogTitle> <AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
<strong>{deletingValue?.valueLabel}</strong>() ? <strong>{deletingValue?.valueLabel}</strong>() ?
{deletingValue && countAllDescendants(deletingValue) > 0 && ( {deletingValue?.children && deletingValue.children.length > 0 && (
<> <>
<br /> <br />
<span className="text-destructive"> <span className="text-destructive">
{countAllDescendants(deletingValue)} . {deletingValue.children.length} .
</span> </span>
</> </>
)} )}
@ -901,46 +706,15 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel> <AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction <AlertDialogAction onClick={handleDelete} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
onClick={handleDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
{/* 다중 삭제 확인 다이얼로그 */}
<AlertDialog open={isBulkDeleteDialogOpen} onOpenChange={setIsBulkDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
<strong>{checkedIds.size}</strong> ?
{totalDeleteCount > checkedIds.size && (
<>
<br />
<span className="text-destructive"> {totalDeleteCount} .</span>
</>
)}
<br />
<span className="text-muted-foreground text-xs"> .</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={handleBulkDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{totalDeleteCount}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div> </div>
); );
}; };
export default CategoryValueManagerTree; export default CategoryValueManagerTree;

View File

@ -618,19 +618,6 @@ export const UnifiedSelect = forwardRef<HTMLDivElement, UnifiedSelectProps>(
fetchedOptions = flattenTree(data.data); fetchedOptions = flattenTree(data.data);
} }
} }
} else if (source === "select" || source === "distinct") {
// 해당 테이블의 해당 컬럼에서 DISTINCT 값 조회
// tableName, columnName은 props에서 가져옴
if (tableName && columnName) {
const response = await apiClient.get(`/entity/${tableName}/distinct/${columnName}`);
const data = response.data;
if (data.success && data.data) {
fetchedOptions = data.data.map((item: { value: string; label: string }) => ({
value: String(item.value),
label: String(item.label),
}));
}
}
} }
setOptions(fetchedOptions); setOptions(fetchedOptions);

View File

@ -105,6 +105,18 @@ export const screenApi = {
return response.data; return response.data;
}, },
// 화면 수정 (이름, 설명 등)
updateScreen: async (
screenId: number,
data: {
screenName?: string;
description?: string;
tableName?: string;
}
): Promise<void> => {
await apiClient.put(`/screen-management/screens/${screenId}`, data);
},
// 화면 삭제 (휴지통으로 이동) // 화면 삭제 (휴지통으로 이동)
deleteScreen: async (screenId: number, deleteReason?: string, force?: boolean): Promise<void> => { deleteScreen: async (screenId: number, deleteReason?: string, force?: boolean): Promise<void> => {
await apiClient.delete(`/screen-management/screens/${screenId}`, { await apiClient.delete(`/screen-management/screens/${screenId}`, {
@ -183,36 +195,17 @@ export const screenApi = {
}, },
// 화면 레이아웃 저장 (ScreenDesigner_new.tsx용) // 화면 레이아웃 저장 (ScreenDesigner_new.tsx용)
saveScreenLayout: async (screenId: number, layoutData: LayoutData): Promise<{ success: boolean; message?: string }> => { saveScreenLayout: async (screenId: number, layoutData: LayoutData): Promise<ApiResponse<void>> => {
const response = await apiClient.post(`/screen-management/screens/${screenId}/layout`, layoutData); const response = await apiClient.post(`/screen-management/screens/${screenId}/layout`, layoutData);
return response.data; return response.data;
}, },
// 화면 레이아웃 조회 (기존) // 화면 레이아웃 조회
getLayout: async (screenId: number): Promise<LayoutData> => { getLayout: async (screenId: number): Promise<LayoutData> => {
const response = await apiClient.get(`/screen-management/screens/${screenId}/layout`); const response = await apiClient.get(`/screen-management/screens/${screenId}/layout`);
return response.data.data; return response.data.data;
}, },
// 화면 레이아웃 조회 V1 (component_url + custom_config 기반)
// 🔒 확정: component_url 필수, custom_config에 slot 포함, company_code 필터 적용
getLayoutV1: async (screenId: number): Promise<LayoutData> => {
const response = await apiClient.get(`/screen-management/screens/${screenId}/layout-v1`);
return response.data.data;
},
// 화면 레이아웃 조회 V2 (1 레코드 방식 - url + overrides)
// 🔒 확정: 화면당 1개 레코드, layout_data JSON에 모든 컴포넌트 포함
getLayoutV2: async (screenId: number): Promise<any> => {
const response = await apiClient.get(`/screen-management/screens/${screenId}/layout-v2`);
return response.data.data;
},
// 화면 레이아웃 저장 V2 (1 레코드 방식 - url + overrides)
saveLayoutV2: async (screenId: number, layoutData: any): Promise<void> => {
await apiClient.post(`/screen-management/screens/${screenId}/layout-v2`, layoutData);
},
// 연결된 모달 화면 감지 // 연결된 모달 화면 감지
detectLinkedModals: async ( detectLinkedModals: async (
screenId: number, screenId: number,

View File

@ -1,13 +1,12 @@
"use client"; "use client";
import React, { useState, useEffect, useMemo, useCallback, useRef } from "react"; import React, { useState, useEffect, useMemo, useCallback } from "react";
import { ComponentRendererProps } from "@/types/component"; import { ComponentRendererProps } from "@/types/component";
import { AggregationWidgetConfig, AggregationItem, AggregationResult, AggregationType, FilterCondition, DataSourceType } from "./types"; import { AggregationWidgetConfig, AggregationItem, AggregationResult, AggregationType, FilterCondition, DataSourceType } from "./types";
import { Calculator, TrendingUp, Hash, ArrowUp, ArrowDown, Loader2 } from "lucide-react"; import { Calculator, TrendingUp, Hash, ArrowUp, ArrowDown, Loader2 } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useScreenMultiLang } from "@/contexts/ScreenMultiLangContext"; import { useScreenMultiLang } from "@/contexts/ScreenMultiLangContext";
import { apiClient } from "@/lib/api/client"; import { apiClient } from "@/lib/api/client";
import { v2EventBus, V2_EVENTS, V2ErrorBoundary } from "@/lib/v2-core";
interface AggregationWidgetComponentProps extends ComponentRendererProps { interface AggregationWidgetComponentProps extends ComponentRendererProps {
config?: AggregationWidgetConfig; config?: AggregationWidgetConfig;
@ -17,14 +16,6 @@ interface AggregationWidgetComponentProps extends ComponentRendererProps {
formData?: Record<string, any>; formData?: Record<string, any>;
// 선택된 행 데이터 // 선택된 행 데이터
selectedRows?: any[]; selectedRows?: any[];
// 선택된 행 전체 데이터 (표준 Props)
selectedRowsData?: any[];
// 멀티테넌시용 회사 코드
companyCode?: string;
// 새로고침 트리거 키
refreshKey?: number;
// 새로고침 콜백
onRefresh?: () => void;
} }
/** /**
@ -116,16 +107,11 @@ export function AggregationWidgetComponent({
externalData, externalData,
formData = {}, formData = {},
selectedRows = [], selectedRows = [],
selectedRowsData = [],
companyCode,
refreshKey,
onRefresh,
}: AggregationWidgetComponentProps) { }: AggregationWidgetComponentProps) {
// 다국어 지원 // 다국어 지원
const { getText } = useScreenMultiLang(); const { getText } = useScreenMultiLang();
// useMemo로 config 병합 (매 렌더링마다 새 객체 생성 방지) const componentConfig: AggregationWidgetConfig = {
const componentConfig = useMemo<AggregationWidgetConfig>(() => ({
dataSourceType: "table", dataSourceType: "table",
items: [], items: [],
layout: "horizontal", layout: "horizontal",
@ -134,7 +120,7 @@ export function AggregationWidgetComponent({
gap: "16px", gap: "16px",
...propsConfig, ...propsConfig,
...component?.config, ...component?.config,
}), [propsConfig, component?.config]); };
// 다국어 라벨 가져오기 // 다국어 라벨 가져오기
const getItemLabel = (item: AggregationItem): string => { const getItemLabel = (item: AggregationItem): string => {
@ -244,13 +230,13 @@ export function AggregationWidgetComponent({
} }
}, [effectiveTableName, dataSourceType, isDesignMode, filterLogic]); }, [effectiveTableName, dataSourceType, isDesignMode, filterLogic]);
// 테이블 데이터 조회 (초기 로드 + refreshKey 변경 시) // 테이블 데이터 조회 (초기 로드)
useEffect(() => { useEffect(() => {
if (dataSourceType === "table" && effectiveTableName && !isDesignMode) { if (dataSourceType === "table" && effectiveTableName && !isDesignMode) {
fetchTableData(); fetchTableData();
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [dataSourceType, effectiveTableName, isDesignMode, refreshKey]); }, [dataSourceType, effectiveTableName, isDesignMode]);
// 폼 데이터 변경 시 재조회 (refreshOnFormChange가 true일 때) // 폼 데이터 변경 시 재조회 (refreshOnFormChange가 true일 때)
const formDataKey = JSON.stringify(formData); const formDataKey = JSON.stringify(formData);
@ -274,114 +260,16 @@ export function AggregationWidgetComponent({
}, [dataSourceType, autoRefresh, refreshInterval, isDesignMode, fetchTableData]); }, [dataSourceType, autoRefresh, refreshInterval, isDesignMode, fetchTableData]);
// 선택된 행 집계 (dataSourceType === "selection"일 때) // 선택된 행 집계 (dataSourceType === "selection"일 때)
// props로 전달된 selectedRows 또는 selectedRowsData 사용 // props로 전달된 selectedRows 사용
// 길이 정보를 포함하여 전체 데이터 변경 감지 개선 const selectedRowsKey = JSON.stringify(selectedRows);
const selectedRowsKey = `${selectedRows?.length || 0}:${JSON.stringify(selectedRows?.slice(0, 5))}`;
const selectedRowsDataKey = `${selectedRowsData?.length || 0}:${JSON.stringify(selectedRowsData?.slice(0, 5))}`;
useEffect(() => { useEffect(() => {
// selectedRowsData가 있으면 우선 사용 (표준 Props) if (dataSourceType === "selection" && Array.isArray(selectedRows) && selectedRows.length > 0) {
const rowsToUse = selectedRowsData?.length > 0 ? selectedRowsData : selectedRows; setData(selectedRows);
if (dataSourceType === "selection") {
if (Array.isArray(rowsToUse) && rowsToUse.length > 0) {
const filteredData = applyFilters(
rowsToUse,
filtersRef.current || [],
filterLogic,
formDataRef.current,
selectedRowsRef.current
);
setData(filteredData);
} else {
// 선택 해제 시 빈 배열로 초기화
setData([]);
}
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [dataSourceType, selectedRowsKey, selectedRowsDataKey, filterLogic]); }, [dataSourceType, selectedRowsKey]);
// V2 이벤트 버스 구독 (selection 또는 component 타입일 때) // 전역 선택 이벤트 수신 (dataSourceType === "selection"일 때)
useEffect(() => {
if (isDesignMode) return;
if (dataSourceType !== "selection" && dataSourceType !== "component") return;
// 핸들러 함수 정의
const handleV2TableDataChange = (payload: any) => {
// component 타입: source가 dataSourceComponentId와 일치할 때만
// selection 타입: 모든 테이블 데이터 변경 수신
if (dataSourceType === "component" && payload.source !== dataSourceComponentId) {
return;
}
if (Array.isArray(payload.data)) {
const filteredData = applyFilters(
payload.data,
filtersRef.current || [],
filterLogic,
formDataRef.current,
selectedRowsRef.current
);
setData(filteredData);
}
};
const handleV2TableSelectionChange = (payload: any) => {
// component 타입: source가 dataSourceComponentId와 일치할 때만
// selection 타입: 모든 선택 변경 수신
if (dataSourceType === "component" && payload.source !== dataSourceComponentId) {
return;
}
if (Array.isArray(payload.selectedRows)) {
const filteredData = applyFilters(
payload.selectedRows,
filtersRef.current || [],
filterLogic,
formDataRef.current,
selectedRowsRef.current
);
setData(filteredData);
}
};
const handleV2RepeaterDataChange = (payload: any) => {
if (dataSourceType === "component" && payload.repeaterId !== dataSourceComponentId) {
return;
}
if (Array.isArray(payload.data)) {
const filteredData = applyFilters(
payload.data,
filtersRef.current || [],
filterLogic,
formDataRef.current,
selectedRowsRef.current
);
setData(filteredData);
}
};
// V2 이벤트 버스 구독
const unsubscribeTableData = v2EventBus.subscribe(
V2_EVENTS.TABLE_DATA_CHANGE,
handleV2TableDataChange
);
const unsubscribeTableSelection = v2EventBus.subscribe(
V2_EVENTS.TABLE_SELECTION_CHANGE,
handleV2TableSelectionChange
);
const unsubscribeRepeaterData = v2EventBus.subscribe(
V2_EVENTS.REPEATER_DATA_CHANGE,
handleV2RepeaterDataChange
);
return () => {
unsubscribeTableData();
unsubscribeTableSelection();
unsubscribeRepeaterData();
};
}, [dataSourceType, dataSourceComponentId, isDesignMode, filterLogic]);
// 전역 선택 이벤트 수신 - 레거시 지원 (dataSourceType === "selection"일 때)
useEffect(() => { useEffect(() => {
if (dataSourceType !== "selection" || isDesignMode) return; if (dataSourceType !== "selection" || isDesignMode) return;
@ -458,10 +346,7 @@ export function AggregationWidgetComponent({
}, [dataSourceType, isDesignMode, filterLogic]); }, [dataSourceType, isDesignMode, filterLogic]);
// 외부 데이터가 있으면 사용 // 외부 데이터가 있으면 사용
// 길이 정보를 포함하여 전체 데이터 변경 감지 개선 const externalDataKey = externalData ? JSON.stringify(externalData.slice(0, 5)) : null; // 첫 5개만 비교
const externalDataKey = externalData
? `${externalData.length}:${JSON.stringify(externalData.slice(0, 5))}`
: null;
useEffect(() => { useEffect(() => {
if (externalData && Array.isArray(externalData)) { if (externalData && Array.isArray(externalData)) {
// 필터 적용 // 필터 적용
@ -590,61 +475,6 @@ export function AggregationWidgetComponent({
}); });
}, [data, items, getText]); }, [data, items, getText]);
// aggregationResults를 ref로 유지 (이벤트 핸들러에서 최신 값 참조)
const aggregationResultsRef = useRef(aggregationResults);
aggregationResultsRef.current = aggregationResults;
// beforeFormSave 이벤트 리스너 (저장 시 집계 결과를 폼 데이터에 포함)
useEffect(() => {
if (isDesignMode) return;
const handleBeforeFormSave = (event: CustomEvent) => {
const componentKey = component?.id || "aggregation_data";
if (event.detail) {
// 집계 결과를 객체 형태로 저장
const aggregationData: Record<string, any> = {};
aggregationResultsRef.current.forEach((result) => {
aggregationData[result.id] = {
label: result.label,
value: result.value,
formattedValue: result.formattedValue,
type: result.type,
};
});
event.detail.formData[componentKey] = aggregationData;
}
};
// V2 이벤트 버스 구독
const unsubscribe = v2EventBus.subscribe(
V2_EVENTS.FORM_SAVE_COLLECT,
(payload) => {
const componentKey = component?.id || "aggregation_data";
const aggregationData: Record<string, any> = {};
aggregationResultsRef.current.forEach((result) => {
aggregationData[result.id] = {
label: result.label,
value: result.value,
formattedValue: result.formattedValue,
type: result.type,
};
});
// V2 이벤트로 응답
if (payload.formData) {
payload.formData[componentKey] = aggregationData;
}
}
);
// 레거시 이벤트도 지원
window.addEventListener("beforeFormSave", handleBeforeFormSave as EventListener);
return () => {
unsubscribe();
window.removeEventListener("beforeFormSave", handleBeforeFormSave as EventListener);
};
}, [isDesignMode, component?.id]);
// 집계 타입에 따른 아이콘 // 집계 타입에 따른 아이콘
const getIcon = (type: AggregationType) => { const getIcon = (type: AggregationType) => {
switch (type) { switch (type) {
@ -797,52 +627,47 @@ export function AggregationWidgetComponent({
} }
return ( return (
<V2ErrorBoundary <div
componentId={component?.id || "aggregation-widget"} className={cn(
componentType="v2-aggregation-widget" "flex items-center rounded-md border bg-slate-50 p-3",
layout === "vertical" ? "flex-col items-stretch" : "flex-row flex-wrap"
)}
style={{
gap: gap || "12px",
backgroundColor: backgroundColor || undefined,
borderRadius: borderRadius || undefined,
padding: padding || undefined,
fontSize: fontSize || undefined,
}}
> >
<div {aggregationResults.map((result, index) => (
className={cn( <div
"flex items-center rounded-md border bg-slate-50 p-3", key={result.id || index}
layout === "vertical" ? "flex-col items-stretch" : "flex-row flex-wrap" className={cn(
)} "flex items-center gap-2 rounded-md border bg-white px-3 py-2 shadow-sm",
style={{ layout === "vertical" ? "w-full justify-between" : ""
gap: gap || "12px", )}
backgroundColor: backgroundColor || undefined, >
borderRadius: borderRadius || undefined, {showIcons && (
padding: padding || undefined, <span className="text-muted-foreground">{getIcon(result.type)}</span>
fontSize: fontSize || undefined, )}
}} {showLabels && (
>
{aggregationResults.map((result, index) => (
<div
key={result.id || index}
className={cn(
"flex items-center gap-2 rounded-md border bg-white px-3 py-2 shadow-sm",
layout === "vertical" ? "w-full justify-between" : ""
)}
>
{showIcons && (
<span className="text-muted-foreground">{getIcon(result.type)}</span>
)}
{showLabels && (
<span
className="text-muted-foreground text-xs"
style={{ fontSize: labelFontSize, color: labelColor }}
>
{result.label} ({getTypeLabel(result.type)}):
</span>
)}
<span <span
className="font-semibold" className="text-muted-foreground text-xs"
style={{ fontSize: valueFontSize, color: valueColor }} style={{ fontSize: labelFontSize, color: labelColor }}
> >
{result.formattedValue} {result.label} ({getTypeLabel(result.type)}):
</span> </span>
</div> )}
))} <span
</div> className="font-semibold"
</V2ErrorBoundary> style={{ fontSize: valueFontSize, color: valueColor }}
>
{result.formattedValue}
</span>
</div>
))}
</div>
); );
} }

View File

@ -27,7 +27,7 @@ interface AggregationWidgetConfigPanelProps {
onChange: (config: Partial<AggregationWidgetConfig>) => void; onChange: (config: Partial<AggregationWidgetConfig>) => void;
screenTableName?: string; screenTableName?: string;
// 화면 내 컴포넌트 목록 (컴포넌트 연결용) // 화면 내 컴포넌트 목록 (컴포넌트 연결용)
screenComponents?: Array<{ id: string; componentType: string; label?: string; tableName?: string; columnName?: string }>; screenComponents?: Array<{ id: string; componentType: string; label?: string; tableName?: string }>;
} }
/** /**
@ -172,14 +172,13 @@ export function AggregationWidgetConfigPanel({
} }
try { try {
const response = await tableManagementApi.getColumnList(sourceComp.tableName); const response = await tableManagementApi.getColumns(sourceComp.tableName);
const rawCols = response.data?.columns || (Array.isArray(response.data) ? response.data : []); const cols = (response.data?.columns || response.data || []).map((col: any) => ({
const cols = rawCols.map((col: any) => ({
columnName: col.column_name || col.columnName, columnName: col.column_name || col.columnName,
label: col.column_label || col.columnLabel || col.display_name || col.column_name || col.columnName, label: col.column_label || col.columnLabel || col.display_name || col.column_name || col.columnName,
})); }));
setSourceComponentColumnsCache((prev) => ({ setSourceComponentColumnsCache(prev => ({
...prev, ...prev,
[componentId]: cols, [componentId]: cols,
})); }));
@ -291,20 +290,19 @@ export function AggregationWidgetConfigPanel({
try { try {
// 카테고리 API 호출 // 카테고리 API 호출
const result = await getCategoryValues(targetTableName, col.columnName, false); const result = await getCategoryValues(targetTableName, col.columnName, false);
if (result.success && "data" in result && Array.isArray(result.data)) { if (result.success && Array.isArray(result.data)) {
// 중복 제거 (valueCode 기준) // 중복 제거 (valueCode 기준)
const seenCodes = new Set<string>(); const seenCodes = new Set<string>();
const uniqueOptions: Array<{ value: string; label: string }> = []; const uniqueOptions: Array<{ value: string; label: string }> = [];
for (const item of result.data) { for (const item of result.data) {
const itemAny = item as any; const code = item.valueCode || item.code || item.value || item.id;
const code = item.valueCode || itemAny.code || itemAny.value || itemAny.id;
if (!seenCodes.has(code)) { if (!seenCodes.has(code)) {
seenCodes.add(code); seenCodes.add(code);
uniqueOptions.push({ uniqueOptions.push({
value: code, value: code,
// valueLabel이 실제 표시명 // valueLabel이 실제 표시명
label: item.valueLabel || itemAny.valueName || itemAny.name || itemAny.label || itemAny.displayName || code, label: item.valueLabel || item.valueName || item.name || item.label || item.displayName || code,
}); });
} }
} }
@ -420,52 +418,6 @@ export function AggregationWidgetConfigPanel({
c.componentType === "table-list" c.componentType === "table-list"
); );
// 폼 필드로 사용 가능한 컴포넌트 (입력 위젯들만)
const formFieldComponents = useMemo(() => {
// 제외할 컴포넌트 타입 (표시 전용, 레이아웃, 컨테이너 등)
const excludeTypes = [
"aggregation", "widget", "button", "label", "display", "table-list",
"repeat", "container", "layout", "section", "card", "tabs", "modal",
"flow", "rack", "map", "chart", "image", "file", "media"
];
const filtered = screenComponents.filter((comp) => {
const type = comp.componentType?.toLowerCase() || "";
// 제외 대상인지 먼저 체크
const isExcluded = excludeTypes.some(exclude => type.includes(exclude));
if (isExcluded) return false;
// 입력 가능한 컴포넌트 타입들
const isInputType = (
type.includes("input") ||
type.includes("select") ||
type.includes("date") ||
type.includes("checkbox") ||
type.includes("radio") ||
type.includes("textarea") ||
type.includes("number") ||
// unified-input, unified-select, unified-date 등 (unified-repeater 등은 제외)
type === "unified-input" ||
type === "unified-select" ||
type === "unified-date" ||
type === "unified-hierarchy"
);
// columnName이 있으면 입력 필드로 간주 (드래그로 배치된 필드)
const hasColumnName = !!comp.columnName;
return isInputType || hasColumnName;
});
return filtered.map((comp) => ({
id: comp.id,
label: comp.label || comp.columnName || comp.id,
columnName: comp.columnName || comp.id,
componentType: comp.componentType,
}));
}, [screenComponents]);
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="text-sm font-medium"> </div> <div className="text-sm font-medium"> </div>
@ -492,14 +444,7 @@ export function AggregationWidgetConfigPanel({
variant={dataSourceType === "component" ? "default" : "outline"} variant={dataSourceType === "component" ? "default" : "outline"}
size="sm" size="sm"
className="h-auto flex-col gap-1 py-2 text-xs" className="h-auto flex-col gap-1 py-2 text-xs"
onClick={() => { onClick={() => onChange({ dataSourceType: "component" })}
// 컴포넌트 모드로 변경 시 화면의 메인 테이블로 자동 설정
onChange({
dataSourceType: "component",
tableName: screenTableName || config.tableName,
useCustomTable: false,
});
}}
> >
<Link2 className="h-4 w-4" /> <Link2 className="h-4 w-4" />
<span></span> <span></span>
@ -508,14 +453,7 @@ export function AggregationWidgetConfigPanel({
variant={dataSourceType === "selection" ? "default" : "outline"} variant={dataSourceType === "selection" ? "default" : "outline"}
size="sm" size="sm"
className="h-auto flex-col gap-1 py-2 text-xs" className="h-auto flex-col gap-1 py-2 text-xs"
onClick={() => { onClick={() => onChange({ dataSourceType: "selection" })}
// 선택 데이터 모드로 변경 시 화면의 메인 테이블로 자동 설정
onChange({
dataSourceType: "selection",
tableName: screenTableName || config.tableName,
useCustomTable: false,
});
}}
> >
<MousePointer className="h-4 w-4" /> <MousePointer className="h-4 w-4" />
<span> </span> <span> </span>
@ -859,32 +797,12 @@ export function AggregationWidgetConfigPanel({
) )
)} )}
{filter.valueSourceType === "formField" && ( {filter.valueSourceType === "formField" && (
formFieldComponents.length > 0 ? ( <Input
<Select value={filter.formFieldName || ""}
value={filter.formFieldName || ""} onChange={(e) => updateFilter(filter.id, { formFieldName: e.target.value })}
onValueChange={(value) => updateFilter(filter.id, { formFieldName: value })} placeholder="필드명 입력"
> className="h-7 text-xs"
<SelectTrigger className="h-7 text-xs"> />
<SelectValue placeholder="폼 필드 선택" />
</SelectTrigger>
<SelectContent>
{formFieldComponents.map((field) => (
<SelectItem key={field.id} value={field.columnName}>
{field.label}
{field.columnName !== field.label && (
<span className="ml-1 text-muted-foreground text-[10px]">
({field.columnName})
</span>
)}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<div className="flex items-center gap-1 text-xs text-muted-foreground h-7 px-2 border rounded-md bg-slate-50">
<span> </span>
</div>
)
)} )}
{filter.valueSourceType === "selection" && ( {filter.valueSourceType === "selection" && (
<div className="space-y-2 col-span-2"> <div className="space-y-2 col-span-2">

View File

@ -240,6 +240,7 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
// 카테고리 코드로 라벨 일괄 조회 // 카테고리 코드로 라벨 일괄 조회
const response = await getCategoryLabelsByCodes(valuesToLookup); const response = await getCategoryLabelsByCodes(valuesToLookup);
if (response.success && response.data) { if (response.success && response.data) {
console.log("✅ 카테고리 라벨 조회 완료:", response.data);
setCategoryLabels((prev) => ({ ...prev, ...response.data })); setCategoryLabels((prev) => ({ ...prev, ...response.data }));
} }
} catch (error) { } catch (error) {
@ -285,6 +286,12 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
status: getCategoryLabel(rawStatus), status: getCategoryLabel(rawStatus),
}; };
console.log("🏗️ [RackStructure] context 생성:", {
fieldMapping,
rawValues: { rawFloor, rawZone, rawLocationType, rawStatus },
context: ctx,
});
return ctx; return ctx;
}, [propContext, formData, fieldMapping, getCategoryLabel]); }, [propContext, formData, fieldMapping, getCategoryLabel]);
@ -377,9 +384,16 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
// 기존 데이터 조회 (창고/층/구역이 변경될 때마다) // 기존 데이터 조회 (창고/층/구역이 변경될 때마다)
useEffect(() => { useEffect(() => {
const loadExistingLocations = async () => { const loadExistingLocations = async () => {
console.log("🏗️ [RackStructure] 기존 데이터 조회 체크:", {
warehouseCode: warehouseCodeForQuery,
floor: floorForQuery,
zone: zoneForQuery,
});
// 필수 조건이 충족되지 않으면 기존 데이터 초기화 // 필수 조건이 충족되지 않으면 기존 데이터 초기화
// DB에는 라벨 값(예: "1층", "A구역")으로 저장되어 있으므로 라벨 값 사용 // DB에는 라벨 값(예: "1층", "A구역")으로 저장되어 있으므로 라벨 값 사용
if (!warehouseCodeForQuery || !floorForQuery || !zoneForQuery) { if (!warehouseCodeForQuery || !floorForQuery || !zoneForQuery) {
console.log("⚠️ [RackStructure] 필수 조건 미충족 - 조회 스킵");
setExistingLocations([]); setExistingLocations([]);
setDuplicateErrors([]); setDuplicateErrors([]);
return; return;
@ -395,6 +409,7 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
floor: { value: floorForQuery, operator: "equals" }, floor: { value: floorForQuery, operator: "equals" },
zone: { value: zoneForQuery, operator: "equals" }, zone: { value: zoneForQuery, operator: "equals" },
}; };
console.log("🔍 기존 위치 데이터 조회 시작 (정확한 일치):", searchParams);
// 직접 apiClient 사용하여 정확한 형식으로 요청 // 직접 apiClient 사용하여 정확한 형식으로 요청
// 백엔드는 search를 객체로 받아서 각 필드를 WHERE 조건으로 처리 // 백엔드는 search를 객체로 받아서 각 필드를 WHERE 조건으로 처리
@ -406,6 +421,8 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
autoFilter: true, // 회사별 데이터 필터링 (멀티테넌시) autoFilter: true, // 회사별 데이터 필터링 (멀티테넌시)
}); });
console.log("🔍 기존 위치 데이터 응답:", response.data);
// API 응답 구조: { success: true, data: { data: [...], total, ... } } // API 응답 구조: { success: true, data: { data: [...], total, ... } }
const responseData = response.data?.data || response.data; const responseData = response.data?.data || response.data;
const dataArray = Array.isArray(responseData) ? responseData : responseData?.data || []; const dataArray = Array.isArray(responseData) ? responseData : responseData?.data || [];
@ -417,7 +434,9 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
location_code: item.location_code, location_code: item.location_code,
})); }));
setExistingLocations(existing); setExistingLocations(existing);
console.log("✅ 기존 위치 데이터 조회 완료:", existing.length, "개", existing);
} else { } else {
console.log("⚠️ 기존 위치 데이터 없음 또는 조회 실패");
setExistingLocations([]); setExistingLocations([]);
} }
} catch (error) { } catch (error) {
@ -514,6 +533,14 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
// 미리보기 생성 // 미리보기 생성
const generatePreview = useCallback(() => { const generatePreview = useCallback(() => {
console.log("🔍 [generatePreview] 검증 시작:", {
missingFields,
hasRowOverlap,
hasDuplicateWithExisting,
duplicateErrorsCount: duplicateErrors.length,
existingLocationsCount: existingLocations.length,
});
// 필수 필드 검증 // 필수 필드 검증
if (missingFields.length > 0) { if (missingFields.length > 0) {
alert(`다음 필드를 먼저 입력해주세요: ${missingFields.join(", ")}`); alert(`다음 필드를 먼저 입력해주세요: ${missingFields.join(", ")}`);
@ -580,6 +607,17 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
setPreviewData(locations); setPreviewData(locations);
setIsPreviewGenerated(true); setIsPreviewGenerated(true);
console.log("🏗️ [RackStructure] 생성된 위치 데이터:", {
locationsCount: locations.length,
firstLocation: locations[0],
context: {
warehouseCode: context?.warehouseCode,
warehouseName: context?.warehouseName,
floor: context?.floor,
zone: context?.zone,
},
});
onChange?.(locations); onChange?.(locations);
}, [ }, [
conditions, conditions,

View File

@ -45,6 +45,7 @@ export class RackStructureRenderer extends AutoRegisteringComponentRenderer {
// formData에도 저장하여 저장 액션에서 감지할 수 있도록 함 // formData에도 저장하여 저장 액션에서 감지할 수 있도록 함
if (onFormDataChange) { if (onFormDataChange) {
console.log("📦 [RackStructure] 미리보기 데이터를 formData에 저장:", locations.length, "개");
onFormDataChange("_rackStructureLocations", locations); onFormDataChange("_rackStructureLocations", locations);
} }
}; };

View File

@ -261,7 +261,9 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// 객체인 경우 tableName 속성 추출 시도 // 객체인 경우 tableName 속성 추출 시도
if (typeof finalSelectedTable === "object" && finalSelectedTable !== null) { if (typeof finalSelectedTable === "object" && finalSelectedTable !== null) {
console.warn("⚠️ selectedTable이 객체입니다:", finalSelectedTable);
finalSelectedTable = (finalSelectedTable as any).tableName || (finalSelectedTable as any).name || tableName; finalSelectedTable = (finalSelectedTable as any).tableName || (finalSelectedTable as any).name || tableName;
console.log("✅ 객체에서 추출한 테이블명:", finalSelectedTable);
} }
tableConfig.selectedTable = finalSelectedTable; tableConfig.selectedTable = finalSelectedTable;
@ -739,6 +741,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
}); });
if (hasChanges) { if (hasChanges) {
console.log("🔗 [TableList] 연결된 필터 값 변경:", newFilterValues);
setLinkedFilterValues(newFilterValues); setLinkedFilterValues(newFilterValues);
// searchValues에 연결된 필터 값 병합 // searchValues에 연결된 필터 값 병합
@ -794,6 +797,13 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
componentType: "table", componentType: "table",
receiveData: async (receivedData: any[], config: DataReceiverConfig) => { receiveData: async (receivedData: any[], config: DataReceiverConfig) => {
console.log("📥 TableList 데이터 수신:", {
componentId: component.id,
receivedDataCount: receivedData.length,
mode: config.mode,
currentDataCount: data.length,
});
try { try {
let newData: any[] = []; let newData: any[] = [];
@ -801,11 +811,13 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
case "append": case "append":
// 기존 데이터에 추가 // 기존 데이터에 추가
newData = [...data, ...receivedData]; newData = [...data, ...receivedData];
console.log("✅ Append 모드: 기존 데이터에 추가", { newDataCount: newData.length });
break; break;
case "replace": case "replace":
// 기존 데이터를 완전히 교체 // 기존 데이터를 완전히 교체
newData = receivedData; newData = receivedData;
console.log("✅ Replace 모드: 데이터 교체", { newDataCount: newData.length });
break; break;
case "merge": case "merge":
@ -821,6 +833,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
} }
}); });
newData = Array.from(existingMap.values()); newData = Array.from(existingMap.values());
console.log("✅ Merge 모드: 데이터 병합", { newDataCount: newData.length });
break; break;
} }
@ -829,8 +842,10 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// 총 아이템 수 업데이트 // 총 아이템 수 업데이트
setTotalItems(newData.length); setTotalItems(newData.length);
console.log("✅ 데이터 수신 완료:", { finalDataCount: newData.length });
} catch (error) { } catch (error) {
console.error("데이터 수신 실패:", error); console.error("데이터 수신 실패:", error);
throw error; throw error;
} }
}, },
@ -864,6 +879,12 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
componentId: component.id, componentId: component.id,
componentType: "table-list", componentType: "table-list",
receiveData: async (incomingData: any[], mode: "append" | "replace" | "merge") => { receiveData: async (incomingData: any[], mode: "append" | "replace" | "merge") => {
console.log("📥 [TableListComponent] 분할 패널에서 데이터 수신:", {
count: incomingData.length,
mode,
position: currentSplitPosition,
});
await dataReceiver.receiveData(incomingData, { await dataReceiver.receiveData(incomingData, {
targetComponentId: component.id, targetComponentId: component.id,
targetComponentType: "table-list", targetComponentType: "table-list",
@ -896,12 +917,24 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// 컬럼의 고유 값 조회 함수 // 컬럼의 고유 값 조회 함수
const getColumnUniqueValues = async (columnName: string) => { const getColumnUniqueValues = async (columnName: string) => {
console.log("🔍 [getColumnUniqueValues] 호출됨:", {
columnName,
dataLength: data.length,
columnMeta: columnMeta[columnName],
sampleData: data[0],
});
const meta = columnMeta[columnName]; const meta = columnMeta[columnName];
const inputType = meta?.inputType || "text"; const inputType = meta?.inputType || "text";
// 카테고리 타입인 경우 전체 정의된 값 조회 (백엔드 API) // 카테고리 타입인 경우 전체 정의된 값 조회 (백엔드 API)
if (inputType === "category") { if (inputType === "category") {
try { try {
console.log("🔍 [getColumnUniqueValues] 카테고리 전체 값 조회:", {
tableName: tableConfig.selectedTable,
columnName,
});
// API 클라이언트 사용 (쿠키 인증 자동 처리) // API 클라이언트 사용 (쿠키 인증 자동 처리)
const { apiClient } = await import("@/lib/api/client"); const { apiClient } = await import("@/lib/api/client");
const response = await apiClient.get(`/table-categories/${tableConfig.selectedTable}/${columnName}/values`); const response = await apiClient.get(`/table-categories/${tableConfig.selectedTable}/${columnName}/values`);
@ -912,9 +945,24 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
label: item.valueLabel, // 카멜케이스 label: item.valueLabel, // 카멜케이스
})); }));
console.log("✅ [getColumnUniqueValues] 카테고리 전체 값:", {
columnName,
count: categoryOptions.length,
options: categoryOptions,
});
return categoryOptions; return categoryOptions;
} else {
console.warn("⚠️ [getColumnUniqueValues] 응답 형식 오류:", response.data);
} }
} catch (error: any) { } catch (error: any) {
console.error("❌ [getColumnUniqueValues] 카테고리 조회 실패:", {
error: error.message,
response: error.response?.data,
status: error.response?.status,
columnName,
tableName: tableConfig.selectedTable,
});
// 에러 시 현재 데이터 기반으로 fallback // 에러 시 현재 데이터 기반으로 fallback
} }
} }
@ -923,6 +971,15 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const isLabelType = ["category", "entity", "code"].includes(inputType); const isLabelType = ["category", "entity", "code"].includes(inputType);
const labelField = isLabelType ? `${columnName}_name` : columnName; const labelField = isLabelType ? `${columnName}_name` : columnName;
console.log("🔍 [getColumnUniqueValues] 데이터 기반 조회:", {
columnName,
inputType,
isLabelType,
labelField,
hasLabelField: data[0] && labelField in data[0],
sampleLabelValue: data[0] ? data[0][labelField] : undefined,
});
// 현재 로드된 데이터에서 고유 값 추출 // 현재 로드된 데이터에서 고유 값 추출
const uniqueValuesMap = new Map<string, string>(); // value -> label const uniqueValuesMap = new Map<string, string>(); // value -> label
@ -943,6 +1000,15 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
})) }))
.sort((a, b) => a.label.localeCompare(b.label)); .sort((a, b) => a.label.localeCompare(b.label));
console.log("✅ [getColumnUniqueValues] 데이터 기반 결과:", {
columnName,
inputType,
isLabelType,
labelField,
uniqueCount: result.length,
values: result,
});
return result; return result;
}; };
@ -1019,9 +1085,10 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
setSortColumn(column); setSortColumn(column);
setSortDirection(direction); setSortDirection(direction);
hasInitializedSort.current = true; hasInitializedSort.current = true;
console.log("📂 localStorage에서 정렬 상태 복원:", { column, direction });
} }
} catch (error) { } catch (error) {
// 정렬 상태 복원 실패 console.error("❌ 정렬 상태 복원 실패:", error);
} }
} }
}, [tableConfig.selectedTable, userId]); }, [tableConfig.selectedTable, userId]);
@ -1037,10 +1104,12 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
if (savedOrder) { if (savedOrder) {
try { try {
const parsedOrder = JSON.parse(savedOrder); const parsedOrder = JSON.parse(savedOrder);
console.log("📂 localStorage에서 컬럼 순서 불러오기:", { storageKey, columnOrder: parsedOrder });
setColumnOrder(parsedOrder); setColumnOrder(parsedOrder);
// 부모 컴포넌트에 초기 컬럼 순서 전달 // 부모 컴포넌트에 초기 컬럼 순서 전달
if (onSelectedRowsChange && parsedOrder.length > 0) { if (onSelectedRowsChange && parsedOrder.length > 0) {
console.log("✅ 초기 컬럼 순서 전달:", parsedOrder);
// 초기 데이터도 함께 전달 (컬럼 순서대로 재정렬) // 초기 데이터도 함께 전달 (컬럼 순서대로 재정렬)
const initialData = data.map((row: any) => { const initialData = data.map((row: any) => {
@ -1529,36 +1598,48 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// 자동 컬럼 매칭도 equals 연산자 사용 // 자동 컬럼 매칭도 equals 연산자 사용
linkedFilterValues[colName] = { value: colValue, operator: "equals" }; linkedFilterValues[colName] = { value: colValue, operator: "equals" };
hasLinkedFiltersConfigured = true; hasLinkedFiltersConfigured = true;
console.log(`🔗 [TableList] 자동 컬럼 매칭: ${colName} = ${colValue}`);
} }
} }
if (Object.keys(linkedFilterValues).length > 0) {
console.log("🔗 [TableList] 자동 컬럼 매칭 필터 적용:", linkedFilterValues);
}
}
if (Object.keys(linkedFilterValues).length > 0) {
console.log("🔗 [TableList] 연결 필터 적용:", linkedFilterValues);
} }
} }
// 연결 필터가 설정되어 있지만 좌측에서 데이터가 선택되지 않은 경우 // 🆕 연결 필터가 설정되어 있지만 좌측에서 데이터가 선택되지 않은 경우
// → 빈 데이터 표시 (모든 데이터를 보여주지 않음) // → 빈 데이터 표시 (모든 데이터를 보여주지 않음)
if (hasLinkedFiltersConfigured && !hasSelectedLeftData) { if (hasLinkedFiltersConfigured && !hasSelectedLeftData) {
console.log("⚠️ [TableList] 연결 필터 설정됨 but 좌측 데이터 미선택 → 빈 데이터 표시");
setData([]); setData([]);
setTotalItems(0); setTotalItems(0);
setLoading(false); setLoading(false);
return; return;
} }
// RelatedDataButtons 대상이지만 아직 버튼이 선택되지 않은 경우 // 🆕 RelatedDataButtons 대상이지만 아직 버튼이 선택되지 않은 경우
// → 빈 데이터 표시 (모든 데이터를 보여주지 않음) // → 빈 데이터 표시 (모든 데이터를 보여주지 않음)
if (isRelatedButtonTarget && !relatedButtonFilter) { if (isRelatedButtonTarget && !relatedButtonFilter) {
console.log("⚠️ [TableList] RelatedDataButtons 대상이지만 버튼 미선택 → 빈 데이터 표시");
setData([]); setData([]);
setTotalItems(0); setTotalItems(0);
setLoading(false); setLoading(false);
return; return;
} }
// RelatedDataButtons 필터 값 준비 // 🆕 RelatedDataButtons 필터 값 준비
const relatedButtonFilterValues: Record<string, any> = {}; const relatedButtonFilterValues: Record<string, any> = {};
if (relatedButtonFilter) { if (relatedButtonFilter) {
relatedButtonFilterValues[relatedButtonFilter.filterColumn] = { relatedButtonFilterValues[relatedButtonFilter.filterColumn] = {
value: relatedButtonFilter.filterValue, value: relatedButtonFilter.filterValue,
operator: "equals", operator: "equals",
}; };
console.log("🔗 [TableList] RelatedDataButtons 필터 적용:", relatedButtonFilterValues);
} }
// 검색 필터, 연결 필터, RelatedDataButtons 필터 병합 // 검색 필터, 연결 필터, RelatedDataButtons 필터 병합
@ -1581,6 +1662,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const connectionId = connectionIdMatch ? parseInt(connectionIdMatch[1]) : null; const connectionId = connectionIdMatch ? parseInt(connectionIdMatch[1]) : null;
if (connectionId) { if (connectionId) {
console.log("🌐 [TableList] REST API 데이터 소스 호출", { connectionId });
// REST API 연결 정보 가져오기 및 데이터 조회 // REST API 연결 정보 가져오기 및 데이터 조회
const { ExternalRestApiConnectionAPI } = await import("@/lib/api/externalRestApiConnection"); const { ExternalRestApiConnectionAPI } = await import("@/lib/api/externalRestApiConnection");
const restApiData = await ExternalRestApiConnectionAPI.fetchData( const restApiData = await ExternalRestApiConnectionAPI.fetchData(
@ -1594,6 +1677,11 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
total: restApiData.total || restApiData.rows?.length || 0, total: restApiData.total || restApiData.rows?.length || 0,
totalPages: Math.ceil((restApiData.total || restApiData.rows?.length || 0) / pageSize), totalPages: Math.ceil((restApiData.total || restApiData.rows?.length || 0) / pageSize),
}; };
console.log("✅ [TableList] REST API 응답:", {
dataLength: response.data.length,
total: response.total,
});
} else { } else {
throw new Error("REST API 연결 ID를 찾을 수 없습니다."); throw new Error("REST API 연결 ID를 찾을 수 없습니다.");
} }
@ -1634,15 +1722,31 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// 1순위: props로 전달받은 formData에서 값 가져오기 (모달에서 사용) // 1순위: props로 전달받은 formData에서 값 가져오기 (모달에서 사용)
if (propFormData && propFormData[fieldName]) { if (propFormData && propFormData[fieldName]) {
filterValue = propFormData[fieldName]; filterValue = propFormData[fieldName];
console.log("🔗 [TableList] formData에서 excludeFilter 값 가져오기:", {
field: fieldName,
value: filterValue,
});
} }
// 2순위: URL 파라미터에서 값 가져오기 // 2순위: URL 파라미터에서 값 가져오기
else if (typeof window !== "undefined") { else if (typeof window !== "undefined") {
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
filterValue = urlParams.get(fieldName); filterValue = urlParams.get(fieldName);
if (filterValue) {
console.log("🔗 [TableList] URL에서 excludeFilter 값 가져오기:", {
field: fieldName,
value: filterValue,
});
}
} }
// 3순위: 분할 패널 부모 데이터에서 값 가져오기 // 3순위: 분할 패널 부모 데이터에서 값 가져오기
if (!filterValue && splitPanelContext?.selectedLeftData) { if (!filterValue && splitPanelContext?.selectedLeftData) {
filterValue = splitPanelContext.selectedLeftData[fieldName]; filterValue = splitPanelContext.selectedLeftData[fieldName];
if (filterValue) {
console.log("🔗 [TableList] 분할패널에서 excludeFilter 값 가져오기:", {
field: fieldName,
value: filterValue,
});
}
} }
} }
@ -1655,6 +1759,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
filterColumn: excludeConfig.filterColumn, filterColumn: excludeConfig.filterColumn,
filterValue: filterValue, filterValue: filterValue,
}; };
console.log("🚫 [TableList] 제외 필터 적용:", excludeFilterParam);
} }
} }
@ -1769,6 +1874,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
}; };
const handleSort = (column: string) => { const handleSort = (column: string) => {
console.log("🔄 정렬 클릭:", { column, currentSortColumn: sortColumn, currentSortDirection: sortDirection });
let newSortColumn = column; let newSortColumn = column;
let newSortDirection: "asc" | "desc" = "asc"; let newSortDirection: "asc" | "desc" = "asc";
@ -1782,7 +1889,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
newSortDirection = "asc"; newSortDirection = "asc";
} }
// 정렬 상태를 localStorage에 저장 (사용자별) // 🎯 정렬 상태를 localStorage에 저장 (사용자별)
if (tableConfig.selectedTable && userId) { if (tableConfig.selectedTable && userId) {
const storageKey = `table_sort_state_${tableConfig.selectedTable}_${userId}`; const storageKey = `table_sort_state_${tableConfig.selectedTable}_${userId}`;
try { try {
@ -1793,11 +1900,15 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
direction: newSortDirection, direction: newSortDirection,
}), }),
); );
console.log("💾 정렬 상태 저장:", { column: newSortColumn, direction: newSortDirection });
} catch (error) { } catch (error) {
// 정렬 상태 저장 실패 console.error("❌ 정렬 상태 저장 실패:", error);
} }
} }
console.log("📊 새로운 정렬 정보:", { newSortColumn, newSortDirection });
console.log("🔍 onSelectedRowsChange 존재 여부:", !!onSelectedRowsChange);
// 정렬 변경 시 선택 정보와 함께 정렬 정보도 전달 // 정렬 변경 시 선택 정보와 함께 정렬 정보도 전달
if (onSelectedRowsChange) { if (onSelectedRowsChange) {
const selectedRowsData = data.filter((row, index) => selectedRows.has(getRowKey(row, index))); const selectedRowsData = data.filter((row, index) => selectedRows.has(getRowKey(row, index)));
@ -1849,6 +1960,16 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
return reordered; return reordered;
}); });
console.log("✅ 정렬 정보 전달:", {
selectedRowsCount: selectedRows.size,
selectedRowsDataCount: selectedRowsData.length,
sortBy: newSortColumn,
sortOrder: newSortDirection,
columnOrder: columnOrder.length > 0 ? columnOrder : undefined,
tableDisplayDataCount: reorderedData.length,
firstRowAfterSort: reorderedData[0]?.[newSortColumn],
lastRowAfterSort: reorderedData[reorderedData.length - 1]?.[newSortColumn],
});
onSelectedRowsChange( onSelectedRowsChange(
Array.from(selectedRows), Array.from(selectedRows),
selectedRowsData, selectedRowsData,
@ -1902,6 +2023,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
}; };
const handleClearAdvancedFilters = useCallback(() => { const handleClearAdvancedFilters = useCallback(() => {
console.log("🔄 필터 초기화 시작", { 이전searchValues: searchValues });
// 상태를 초기화하고 useEffect로 데이터 새로고침 // 상태를 초기화하고 useEffect로 데이터 새로고침
setSearchValues({}); setSearchValues({});
setCurrentPage(1); setCurrentPage(1);
@ -2050,15 +2173,30 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// currentSplitPosition을 사용하여 정확한 위치 확인 (splitPanelPosition이 없을 수 있음) // currentSplitPosition을 사용하여 정확한 위치 확인 (splitPanelPosition이 없을 수 있음)
const effectiveSplitPosition = splitPanelPosition || currentSplitPosition; const effectiveSplitPosition = splitPanelPosition || currentSplitPosition;
console.log("🔗 [TableList] 행 클릭 - 분할 패널 위치 확인:", {
splitPanelPosition,
currentSplitPosition,
effectiveSplitPosition,
hasSplitPanelContext: !!splitPanelContext,
disableAutoDataTransfer: splitPanelContext?.disableAutoDataTransfer,
});
if (splitPanelContext && effectiveSplitPosition === "left" && !splitPanelContext.disableAutoDataTransfer) { if (splitPanelContext && effectiveSplitPosition === "left" && !splitPanelContext.disableAutoDataTransfer) {
if (!isCurrentlySelected) { if (!isCurrentlySelected) {
// 선택된 경우: 데이터 저장 // 선택된 경우: 데이터 저장
splitPanelContext.setSelectedLeftData(row); splitPanelContext.setSelectedLeftData(row);
console.log("🔗 [TableList] 분할 패널 좌측 데이터 저장:", {
row,
parentDataMapping: splitPanelContext.parentDataMapping,
});
} else { } else {
// 선택 해제된 경우: 데이터 초기화 // 선택 해제된 경우: 데이터 초기화
splitPanelContext.setSelectedLeftData(null); splitPanelContext.setSelectedLeftData(null);
console.log("🔗 [TableList] 분할 패널 좌측 데이터 초기화");
} }
} }
console.log("행 클릭:", { row, index, isSelected: !isCurrentlySelected });
}; };
// 🆕 셀 클릭 핸들러 (포커스 설정 + 행 선택) // 🆕 셀 클릭 핸들러 (포커스 설정 + 행 선택)
@ -2319,11 +2457,12 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
}, },
})); }));
console.log("📝 배치 편집 추가:", { columnName, newValue, pendingCount: pendingChanges.size + 1 });
cancelEditing(); cancelEditing();
return; return;
} }
// 즉시 모드: 바로 저장 // 🆕 즉시 모드: 바로 저장
try { try {
const { apiClient } = await import("@/lib/api/client"); const { apiClient } = await import("@/lib/api/client");
@ -2337,8 +2476,10 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// 데이터 새로고침 트리거 // 데이터 새로고침 트리거
setRefreshTrigger((prev) => prev + 1); setRefreshTrigger((prev) => prev + 1);
console.log("✅ 셀 편집 저장 완료:", { columnName, newValue });
} catch (error) { } catch (error) {
// 셀 편집 저장 실패 console.error("❌ 셀 편집 저장 실패:", error);
} }
cancelEditing(); cancelEditing();
@ -2383,18 +2524,21 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
setRefreshTrigger((prev) => prev + 1); setRefreshTrigger((prev) => prev + 1);
toast.success(`${pendingChanges.size}개의 변경사항이 저장되었습니다.`); toast.success(`${pendingChanges.size}개의 변경사항이 저장되었습니다.`);
console.log("✅ 배치 저장 완료:", pendingChanges.size, "개");
} catch (error) { } catch (error) {
console.error("❌ 배치 저장 실패:", error);
toast.error("저장 중 오류가 발생했습니다."); toast.error("저장 중 오류가 발생했습니다.");
} }
}, [pendingChanges, tableConfig.selectedTable, tableConfig.primaryKey]); }, [pendingChanges, tableConfig.selectedTable, tableConfig.primaryKey]);
// 배치 취소: 모든 변경사항 롤백 // 🆕 배치 취소: 모든 변경사항 롤백
const cancelBatchChanges = useCallback(() => { const cancelBatchChanges = useCallback(() => {
if (pendingChanges.size === 0) return; if (pendingChanges.size === 0) return;
setPendingChanges(new Map()); setPendingChanges(new Map());
setLocalEditedData({}); setLocalEditedData({});
toast.info("변경사항이 취소되었습니다."); toast.info("변경사항이 취소되었습니다.");
console.log("🔄 배치 편집 취소");
}, [pendingChanges.size]); }, [pendingChanges.size]);
// 🆕 특정 셀이 수정되었는지 확인 // 🆕 특정 셀이 수정되었는지 확인
@ -2571,7 +2715,9 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
XLSX.writeFile(wb, fileName); XLSX.writeFile(wb, fileName);
toast.success(`${exportData.length}개 행이 Excel로 내보내기 되었습니다.`); toast.success(`${exportData.length}개 행이 Excel로 내보내기 되었습니다.`);
console.log("✅ Excel 내보내기 완료:", fileName);
} catch (error) { } catch (error) {
console.error("❌ Excel 내보내기 실패:", error);
toast.error("Excel 내보내기 중 오류가 발생했습니다."); toast.error("Excel 내보내기 중 오류가 발생했습니다.");
} }
}, },
@ -2637,7 +2783,10 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
...prev, ...prev,
[rowKey]: details, [rowKey]: details,
})); }));
console.log("✅ 상세 데이터 로딩 완료:", { rowKey, count: details.length });
} catch (error) { } catch (error) {
console.error("❌ 상세 데이터 로딩 실패:", error);
setDetailData((prev) => ({ setDetailData((prev) => ({
...prev, ...prev,
[rowKey]: [], [rowKey]: [],
@ -2734,7 +2883,10 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
...prev, ...prev,
[cacheKey]: options, [cacheKey]: options,
})); }));
console.log("✅ Cascading options 로딩 완료:", { columnName, parentValue, count: options.length });
} catch (error) { } catch (error) {
console.error("❌ Cascading options 로딩 실패:", error);
setCascadingOptions((prev) => ({ setCascadingOptions((prev) => ({
...prev, ...prev,
[cacheKey]: [], [cacheKey]: [],
@ -2888,11 +3040,13 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
wsRef.current.onopen = () => { wsRef.current.onopen = () => {
setWsConnectionStatus("connected"); setWsConnectionStatus("connected");
console.log("✅ WebSocket 연결됨:", tableConfig.selectedTable);
}; };
wsRef.current.onmessage = (event) => { wsRef.current.onmessage = (event) => {
try { try {
const message = JSON.parse(event.data); const message = JSON.parse(event.data);
console.log("📨 WebSocket 메시지 수신:", message);
switch (message.type) { switch (message.type) {
case "insert": case "insert":
@ -2915,29 +3069,32 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
setRefreshTrigger((prev) => prev + 1); setRefreshTrigger((prev) => prev + 1);
break; break;
default: default:
// 알 수 없는 메시지 타입 console.log("알 수 없는 메시지 타입:", message.type);
break;
} }
} catch (error) { } catch (error) {
// WebSocket 메시지 파싱 오류 console.error("WebSocket 메시지 파싱 오류:", error);
} }
}; };
wsRef.current.onclose = () => { wsRef.current.onclose = () => {
setWsConnectionStatus("disconnected"); setWsConnectionStatus("disconnected");
console.log("🔌 WebSocket 연결 종료");
// 자동 재연결 (5초 후) // 자동 재연결 (5초 후)
if (isRealTimeEnabled) { if (isRealTimeEnabled) {
reconnectTimeoutRef.current = setTimeout(() => { reconnectTimeoutRef.current = setTimeout(() => {
console.log("🔄 WebSocket 재연결 시도...");
connectWebSocket(); connectWebSocket();
}, 5000); }, 5000);
} }
}; };
wsRef.current.onerror = () => { wsRef.current.onerror = (error) => {
console.error("❌ WebSocket 오류:", error);
setWsConnectionStatus("disconnected"); setWsConnectionStatus("disconnected");
}; };
} catch (error) { } catch (error) {
console.error("WebSocket 연결 실패:", error);
setWsConnectionStatus("disconnected"); setWsConnectionStatus("disconnected");
} }
}, [isRealTimeEnabled, tableConfig.selectedTable]); }, [isRealTimeEnabled, tableConfig.selectedTable]);
@ -3022,7 +3179,9 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
await navigator.clipboard.writeText(tsvContent); await navigator.clipboard.writeText(tsvContent);
toast.success(`${copyData.length}행 복사됨`); toast.success(`${copyData.length}행 복사됨`);
console.log("✅ 클립보드 복사:", copyData.length, "행");
} catch (error) { } catch (error) {
console.error("❌ 클립보드 복사 실패:", error);
toast.error("복사 실패"); toast.error("복사 실패");
} }
}, [selectedRows, filteredData, focusedCell, visibleColumns, columnLabels, getRowKey]); }, [selectedRows, filteredData, focusedCell, visibleColumns, columnLabels, getRowKey]);
@ -3373,6 +3532,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
setColumnOrder(newOrder); setColumnOrder(newOrder);
toast.info("컬럼 순서가 변경되었습니다."); toast.info("컬럼 순서가 변경되었습니다.");
console.log("✅ 컬럼 순서 변경:", { from: draggedColumnIndex, to: targetIndex });
handleColumnDragEnd(); handleColumnDragEnd();
}, },
@ -3463,7 +3623,10 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// 로컬에서만 순서 변경 (저장 안함) // 로컬에서만 순서 변경 (저장 안함)
toast.info("순서가 변경되었습니다. (로컬만)"); toast.info("순서가 변경되었습니다. (로컬만)");
} }
console.log("✅ 행 순서 변경:", { from: draggedRowIndex, to: targetIndex });
} catch (error) { } catch (error) {
console.error("❌ 행 순서 변경 실패:", error);
toast.error("순서 변경 중 오류가 발생했습니다."); toast.error("순서 변경 중 오류가 발생했습니다.");
} }
@ -4549,6 +4712,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
return filteredData; return filteredData;
} }
console.log("🔍 [테이블리스트] 그룹합산 적용:", groupSumConfig);
const groupByColumn = groupSumConfig.groupByColumn; const groupByColumn = groupSumConfig.groupByColumn;
const groupMap = new Map<string, any>(); const groupMap = new Map<string, any>();
@ -4601,6 +4766,11 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
}); });
const result = Array.from(groupMap.values()); const result = Array.from(groupMap.values());
console.log("🔗 [테이블리스트] 그룹별 합산 결과:", {
원본개수: filteredData.length,
그룹개수: result.length,
그룹기준: groupByColumn,
});
return result; return result;
}, [filteredData, groupSumConfig]); }, [filteredData, groupSumConfig]);
@ -4708,6 +4878,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
useEffect(() => { useEffect(() => {
const handleRefreshTable = () => { const handleRefreshTable = () => {
if (tableConfig.selectedTable && !isDesignMode) { if (tableConfig.selectedTable && !isDesignMode) {
console.log("🔄 [TableList] refreshTable 이벤트 수신 - 데이터 새로고침");
setRefreshTrigger((prev) => prev + 1); setRefreshTrigger((prev) => prev + 1);
} }
}; };
@ -4733,21 +4904,23 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
}; };
}, [tableConfig.selectedTable, isDesignMode, component.id]); }, [tableConfig.selectedTable, isDesignMode, component.id]);
// 테이블명 변경 시 전역 레지스트리에서 확인 // 🆕 테이블명 변경 시 전역 레지스트리에서 확인
useEffect(() => { useEffect(() => {
if (typeof window !== "undefined" && window.__relatedButtonsTargetTables && tableConfig.selectedTable) { if (typeof window !== "undefined" && window.__relatedButtonsTargetTables && tableConfig.selectedTable) {
const isTarget = window.__relatedButtonsTargetTables.has(tableConfig.selectedTable); const isTarget = window.__relatedButtonsTargetTables.has(tableConfig.selectedTable);
if (isTarget) { if (isTarget) {
console.log("📝 [TableList] 전역 레지스트리에서 RelatedDataButtons 대상 확인:", tableConfig.selectedTable);
setIsRelatedButtonTarget(true); setIsRelatedButtonTarget(true);
} }
} }
}, [tableConfig.selectedTable]); }, [tableConfig.selectedTable]);
// RelatedDataButtons 등록/해제 이벤트 리스너 // 🆕 RelatedDataButtons 등록/해제 이벤트 리스너
useEffect(() => { useEffect(() => {
const handleRelatedButtonRegister = (event: CustomEvent) => { const handleRelatedButtonRegister = (event: CustomEvent) => {
const { targetTable } = event.detail || {}; const { targetTable } = event.detail || {};
if (targetTable === tableConfig.selectedTable) { if (targetTable === tableConfig.selectedTable) {
console.log("📝 [TableList] RelatedDataButtons 대상으로 등록됨:", tableConfig.selectedTable);
setIsRelatedButtonTarget(true); setIsRelatedButtonTarget(true);
} }
}; };
@ -4755,6 +4928,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const handleRelatedButtonUnregister = (event: CustomEvent) => { const handleRelatedButtonUnregister = (event: CustomEvent) => {
const { targetTable } = event.detail || {}; const { targetTable } = event.detail || {};
if (targetTable === tableConfig.selectedTable) { if (targetTable === tableConfig.selectedTable) {
console.log("📝 [TableList] RelatedDataButtons 대상에서 해제됨:", tableConfig.selectedTable);
setIsRelatedButtonTarget(false); setIsRelatedButtonTarget(false);
setRelatedButtonFilter(null); setRelatedButtonFilter(null);
} }
@ -4765,6 +4939,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
V2_EVENTS.RELATED_BUTTON_REGISTER, V2_EVENTS.RELATED_BUTTON_REGISTER,
(payload) => { (payload) => {
if (payload.targetTables.includes(tableConfig.selectedTable || "")) { if (payload.targetTables.includes(tableConfig.selectedTable || "")) {
console.log("📝 [TableList] RelatedDataButtons 대상으로 등록됨:", tableConfig.selectedTable);
setIsRelatedButtonTarget(true); setIsRelatedButtonTarget(true);
} }
}, },
@ -4775,6 +4950,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
V2_EVENTS.RELATED_BUTTON_UNREGISTER, V2_EVENTS.RELATED_BUTTON_UNREGISTER,
(payload) => { (payload) => {
if (payload.buttonId) { if (payload.buttonId) {
console.log("📝 [TableList] RelatedDataButtons 대상에서 해제됨:", tableConfig.selectedTable);
setIsRelatedButtonTarget(false); setIsRelatedButtonTarget(false);
setRelatedButtonFilter(null); setRelatedButtonFilter(null);
} }
@ -4794,7 +4970,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
}; };
}, [tableConfig.selectedTable, component.id]); }, [tableConfig.selectedTable, component.id]);
// RelatedDataButtons 선택 이벤트 리스너 (버튼 선택 시 테이블 필터링) // 🆕 RelatedDataButtons 선택 이벤트 리스너 (버튼 선택 시 테이블 필터링)
useEffect(() => { useEffect(() => {
const handleRelatedButtonSelect = (event: CustomEvent) => { const handleRelatedButtonSelect = (event: CustomEvent) => {
const { targetTable, filterColumn, filterValue } = event.detail || {}; const { targetTable, filterColumn, filterValue } = event.detail || {};
@ -4803,9 +4979,15 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
if (targetTable === tableConfig.selectedTable) { if (targetTable === tableConfig.selectedTable) {
// filterValue가 null이면 선택 해제 (빈 상태) // filterValue가 null이면 선택 해제 (빈 상태)
if (filterValue === null || filterValue === undefined) { if (filterValue === null || filterValue === undefined) {
console.log("📌 [TableList] RelatedDataButtons 선택 해제 (빈 상태):", tableConfig.selectedTable);
setRelatedButtonFilter(null); setRelatedButtonFilter(null);
setIsRelatedButtonTarget(true); // 대상으로 등록은 유지 setIsRelatedButtonTarget(true); // 대상으로 등록은 유지
} else { } else {
console.log("📌 [TableList] RelatedDataButtons 필터 적용:", {
tableName: tableConfig.selectedTable,
filterColumn,
filterValue,
});
setRelatedButtonFilter({ filterColumn, filterValue }); setRelatedButtonFilter({ filterColumn, filterValue });
setIsRelatedButtonTarget(true); setIsRelatedButtonTarget(true);
} }
@ -4818,9 +5000,14 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
(payload) => { (payload) => {
if (payload.tableName === tableConfig.selectedTable) { if (payload.tableName === tableConfig.selectedTable) {
if (!payload.selectedData || payload.selectedData.length === 0) { if (!payload.selectedData || payload.selectedData.length === 0) {
console.log("📌 [TableList] RelatedDataButtons 선택 해제 (빈 상태):", tableConfig.selectedTable);
setRelatedButtonFilter(null); setRelatedButtonFilter(null);
setIsRelatedButtonTarget(true); setIsRelatedButtonTarget(true);
} else { } else {
console.log("📌 [TableList] RelatedDataButtons 필터 적용:", {
tableName: tableConfig.selectedTable,
selectedData: payload.selectedData,
});
// 첫 번째 선택된 데이터의 ID를 필터로 사용 // 첫 번째 선택된 데이터의 ID를 필터로 사용
const firstItem = payload.selectedData[0]; const firstItem = payload.selectedData[0];
if (firstItem?.id) { if (firstItem?.id) {

View File

@ -3,11 +3,13 @@
import React, { useState, useEffect, useRef, useMemo } from "react"; import React, { useState, useEffect, useRef, useMemo } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Settings, X, ChevronsUpDown } from "lucide-react"; import { Settings, Filter, Layers, X, Check, ChevronsUpDown } from "lucide-react";
import { useTableOptions } from "@/contexts/TableOptionsContext"; import { useTableOptions } from "@/contexts/TableOptionsContext";
import { useTableSearchWidgetHeight } from "@/contexts/TableSearchWidgetHeightContext"; import { useTableSearchWidgetHeight } from "@/contexts/TableSearchWidgetHeightContext";
import { useActiveTab } from "@/contexts/ActiveTabContext"; import { useActiveTab } from "@/contexts/ActiveTabContext";
import { TableSettingsModal } from "@/components/screen/table-options/TableSettingsModal"; import { ColumnVisibilityPanel } from "@/components/screen/table-options/ColumnVisibilityPanel";
import { FilterPanel } from "@/components/screen/table-options/FilterPanel";
import { GroupingPanel } from "@/components/screen/table-options/GroupingPanel";
import { TableFilter } from "@/types/table-options"; import { TableFilter } from "@/types/table-options";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { ModernDatePicker } from "@/components/screen/filters/ModernDatePicker"; import { ModernDatePicker } from "@/components/screen/filters/ModernDatePicker";
@ -48,8 +50,24 @@ interface TableSearchWidgetProps {
} }
export function TableSearchWidget({ component, screenId, onHeightChange }: TableSearchWidgetProps) { export function TableSearchWidget({ component, screenId, onHeightChange }: TableSearchWidgetProps) {
console.log("🎯🎯🎯 [TableSearchWidget] 함수 시작!", { componentId: component?.id, screenId });
// 🔧 직접 useTableOptions 호출 (에러 발생 시 catch하지 않고 그대로 throw)
const tableOptionsContext = useTableOptions(); const tableOptionsContext = useTableOptions();
console.log("✅ [TableSearchWidget] useTableOptions 성공", { hasContext: !!tableOptionsContext });
const { registeredTables, selectedTableId, setSelectedTableId, getTable, getActiveTabTables } = tableOptionsContext; const { registeredTables, selectedTableId, setSelectedTableId, getTable, getActiveTabTables } = tableOptionsContext;
// 등록된 테이블 확인 로그
console.log("🔍 [TableSearchWidget] 등록된 테이블:", {
count: registeredTables.size,
tables: Array.from(registeredTables.entries()).map(([id, t]) => ({
id,
tableName: t.tableName,
hasOnFilterChange: typeof t.onFilterChange === "function",
})),
selectedTableId,
});
const { isPreviewMode } = useScreenPreview(); // 미리보기 모드 확인 const { isPreviewMode } = useScreenPreview(); // 미리보기 모드 확인
const { getAllActiveTabIds, activeTabs } = useActiveTab(); // 활성 탭 정보 const { getAllActiveTabIds, activeTabs } = useActiveTab(); // 활성 탭 정보
@ -68,7 +86,9 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
// 탭별 필터 값 저장 (탭 ID -> 필터 값) // 탭별 필터 값 저장 (탭 ID -> 필터 값)
const [tabFilterValues, setTabFilterValues] = useState<Record<string, Record<string, any>>>({}); const [tabFilterValues, setTabFilterValues] = useState<Record<string, Record<string, any>>>({});
const [settingsOpen, setSettingsOpen] = useState(false); const [columnVisibilityOpen, setColumnVisibilityOpen] = useState(false);
const [filterOpen, setFilterOpen] = useState(false);
const [groupingOpen, setGroupingOpen] = useState(false);
// 활성화된 필터 목록 // 활성화된 필터 목록
const [activeFilters, setActiveFilters] = useState<TableFilter[]>([]); const [activeFilters, setActiveFilters] = useState<TableFilter[]>([]);
@ -133,16 +153,24 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
// currentTable은 tableList(필터링된 목록)에서 가져와야 함 // currentTable은 tableList(필터링된 목록)에서 가져와야 함
const currentTable = useMemo(() => { const currentTable = useMemo(() => {
console.log("🔍 [TableSearchWidget] currentTable 계산:", {
selectedTableId,
tableListLength: tableList.length,
tableList: tableList.map((t) => ({ id: t.tableId, name: t.tableName, parentTabId: t.parentTabId })),
});
if (!selectedTableId) return undefined; if (!selectedTableId) return undefined;
// 먼저 tableList(필터링된 목록)에서 찾기 // 먼저 tableList(필터링된 목록)에서 찾기
const tableFromList = tableList.find((t) => t.tableId === selectedTableId); const tableFromList = tableList.find((t) => t.tableId === selectedTableId);
if (tableFromList) { if (tableFromList) {
console.log("✅ [TableSearchWidget] 테이블 찾음 (tableList):", tableFromList.tableName);
return tableFromList; return tableFromList;
} }
// tableList에 없으면 전체에서 찾기 (폴백) // tableList에 없으면 전체에서 찾기 (폴백)
const tableFromAll = getTable(selectedTableId); const tableFromAll = getTable(selectedTableId);
console.log("🔄 [TableSearchWidget] 테이블 찾음 (전체):", tableFromAll?.tableName);
return tableFromAll; return tableFromAll;
}, [selectedTableId, tableList, getTable]); }, [selectedTableId, tableList, getTable]);
@ -158,16 +186,28 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
return; return;
} }
// 탭 전환 감지: 활성 탭이 변경되었는지 확인 // 🆕 탭 전환 감지: 활성 탭이 변경되었는지 확인
const tabChanged = prevActiveTabIdsRef.current !== activeTabIdsStr; const tabChanged = prevActiveTabIdsRef.current !== activeTabIdsStr;
if (tabChanged) { if (tabChanged) {
console.log("🔄 [TableSearchWidget] 탭 전환 감지:", {
이전탭: prevActiveTabIdsRef.current,
현재탭: activeTabIdsStr,
가용테이블: tableList.map((t) => ({ id: t.tableId, tableName: t.tableName, parentTabId: t.parentTabId })),
현재선택테이블: selectedTableId,
});
prevActiveTabIdsRef.current = activeTabIdsStr; prevActiveTabIdsRef.current = activeTabIdsStr;
// 탭 전환 시: 해당 탭에 속한 테이블 중 첫 번째 강제 선택 // 🆕 탭 전환 시: 해당 탭에 속한 테이블 중 첫 번째 강제 선택
const activeTabTable = tableList.find((t) => t.parentTabId && activeTabIds.includes(t.parentTabId)); const activeTabTable = tableList.find((t) => t.parentTabId && activeTabIds.includes(t.parentTabId));
const targetTable = activeTabTable || tableList[0]; const targetTable = activeTabTable || tableList[0];
if (targetTable) { if (targetTable) {
console.log("✅ [TableSearchWidget] 탭 전환으로 테이블 강제 선택:", {
테이블ID: targetTable.tableId,
테이블명: targetTable.tableName,
탭ID: targetTable.parentTabId,
이전테이블: selectedTableId,
});
setSelectedTableId(targetTable.tableId); setSelectedTableId(targetTable.tableId);
} }
return; // 탭 전환 시에는 여기서 종료 return; // 탭 전환 시에는 여기서 종료
@ -182,6 +222,11 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
const targetTable = activeTabTable || tableList[0]; const targetTable = activeTabTable || tableList[0];
if (targetTable && targetTable.tableId !== selectedTableId) { if (targetTable && targetTable.tableId !== selectedTableId) {
console.log("✅ [TableSearchWidget] 테이블 자동 선택 (초기):", {
테이블ID: targetTable.tableId,
테이블명: targetTable.tableName,
탭ID: targetTable.parentTabId,
});
setSelectedTableId(targetTable.tableId); setSelectedTableId(targetTable.tableId);
} }
} }
@ -225,6 +270,13 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
// 현재 테이블의 저장된 필터 불러오기 (동적 모드) 또는 고정 필터 적용 (고정 모드) // 현재 테이블의 저장된 필터 불러오기 (동적 모드) 또는 고정 필터 적용 (고정 모드)
useEffect(() => { useEffect(() => {
console.log("📋 [TableSearchWidget] 필터 설정 useEffect 실행:", {
currentTable: currentTable?.tableName,
currentTableTabId,
filterMode,
selectedTableId,
컬럼수: currentTable?.columns?.length,
});
if (!currentTable?.tableName) return; if (!currentTable?.tableName) return;
// 고정 모드: presetFilters를 activeFilters로 설정 // 고정 모드: presetFilters를 activeFilters로 설정
@ -265,6 +317,13 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
: `table_filters_${currentTable.tableName}`; : `table_filters_${currentTable.tableName}`;
const savedFilters = localStorage.getItem(filterConfigKey); const savedFilters = localStorage.getItem(filterConfigKey);
console.log("🔑 [TableSearchWidget] 필터 설정 키 확인:", {
filterConfigKey,
savedFilters: savedFilters ? `${savedFilters.substring(0, 100)}...` : null,
screenId,
tableName: currentTable.tableName,
});
if (savedFilters) { if (savedFilters) {
try { try {
const parsed = JSON.parse(savedFilters) as Array<{ const parsed = JSON.parse(savedFilters) as Array<{
@ -287,6 +346,13 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
width: f.width || 200, width: f.width || 200,
})); }));
console.log("📌 [TableSearchWidget] 필터 설정 로드:", {
filterConfigKey,
총필터수: parsed.length,
활성화필터수: activeFiltersList.length,
활성화필터: activeFiltersList.map((f) => f.columnName),
});
setActiveFilters(activeFiltersList); setActiveFilters(activeFiltersList);
// 탭별 저장된 필터 값 복원 // 탭별 저장된 필터 값 복원
@ -316,6 +382,10 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
} }
} else { } else {
// 필터 설정이 없으면 activeFilters와 filterValues 모두 초기화 // 필터 설정이 없으면 activeFilters와 filterValues 모두 초기화
console.log("⚠️ [TableSearchWidget] 저장된 필터 설정 없음 - 필터 초기화:", {
tableName: currentTable.tableName,
filterConfigKey,
});
setActiveFilters([]); setActiveFilters([]);
setFilterValues({}); setFilterValues({});
setSelectOptions({}); setSelectOptions({});
@ -470,8 +540,7 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
} }
// 다중선택 배열을 처리 (파이프로 연결된 문자열로 변환) // 다중선택 배열을 처리 (파이프로 연결된 문자열로 변환)
// filterType에 관계없이 배열이면 파이프로 연결 if (filter.filterType === "select" && Array.isArray(filterValue)) {
if (Array.isArray(filterValue)) {
filterValue = filterValue.join("|"); filterValue = filterValue.join("|");
} }
@ -484,11 +553,26 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
// 빈 값 체크 // 빈 값 체크
if (!f.value) return false; if (!f.value) return false;
if (typeof f.value === "string" && f.value === "") return false; if (typeof f.value === "string" && f.value === "") return false;
if (Array.isArray(f.value) && f.value.length === 0) return false;
return true; return true;
}); });
console.log("🔍 [TableSearchWidget] applyFilters 호출:", {
currentTableId: currentTable?.tableId,
currentTableName: currentTable?.tableName,
hasOnFilterChange: !!currentTable?.onFilterChange,
filtersCount: filtersWithValues.length,
filters: filtersWithValues.map((f) => ({
col: f.columnName,
op: f.operator,
val: f.value,
})),
});
if (currentTable?.onFilterChange) { if (currentTable?.onFilterChange) {
currentTable.onFilterChange(filtersWithValues); currentTable.onFilterChange(filtersWithValues);
} else {
console.warn("⚠️ [TableSearchWidget] onFilterChange가 없음!", { currentTable });
} }
}; };
@ -687,28 +771,54 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
</div> </div>
)} )}
{/* 동적 모드일 때만 설정 버튼 표시 (미리보기에서는 비활성화) */} {/* 동적 모드일 때만 설정 버튼 표시 (미리보기에서는 비활성화) */}
{filterMode === "dynamic" && ( {filterMode === "dynamic" && (
<Button <>
variant="outline" <Button
size="sm" variant="outline"
onClick={() => !isPreviewMode && setSettingsOpen(true)} size="sm"
disabled={!selectedTableId || isPreviewMode} onClick={() => !isPreviewMode && setColumnVisibilityOpen(true)}
className="h-8 text-xs sm:h-9 sm:text-sm" disabled={!selectedTableId || isPreviewMode}
> className="h-8 text-xs sm:h-9 sm:text-sm"
<Settings className="mr-1 h-3 w-3 sm:h-4 sm:w-4" /> >
<Settings className="mr-1 h-3 w-3 sm:h-4 sm:w-4" />
</Button>
</Button>
<Button
variant="outline"
size="sm"
onClick={() => !isPreviewMode && setFilterOpen(true)}
disabled={!selectedTableId || isPreviewMode}
className="h-8 text-xs sm:h-9 sm:text-sm"
>
<Filter className="mr-1 h-3 w-3 sm:h-4 sm:w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => !isPreviewMode && setGroupingOpen(true)}
disabled={!selectedTableId || isPreviewMode}
className="h-8 text-xs sm:h-9 sm:text-sm"
>
<Layers className="mr-1 h-3 w-3 sm:h-4 sm:w-4" />
</Button>
</>
)} )}
</div> </div>
{/* 통합 설정 모달 */} {/* 패널들 */}
<TableSettingsModal <ColumnVisibilityPanel isOpen={columnVisibilityOpen} onClose={() => setColumnVisibilityOpen(false)} />
isOpen={settingsOpen} <FilterPanel
onClose={() => setSettingsOpen(false)} isOpen={filterOpen}
onClose={() => setFilterOpen(false)}
onFiltersApplied={(filters) => setActiveFilters(filters)} onFiltersApplied={(filters) => setActiveFilters(filters)}
screenId={screenId} screenId={screenId}
/> />
<GroupingPanel isOpen={groupingOpen} onClose={() => setGroupingOpen(false)} />
</div> </div>
); );
} }

View File

@ -1,308 +0,0 @@
/**
*
*
*
* - 기본값: defaultConfig에서
* - 커스텀: DB custom_config에서
* - = + ( )
*/
import { z } from "zod";
// ============================================
// 공통 스키마 (모든 구조 허용)
// ============================================
export const customConfigSchema = z.record(z.any());
export type CustomConfig = z.infer<typeof customConfigSchema>;
// ============================================
// 깊은 병합 함수
// ============================================
export function deepMerge<T extends Record<string, any>>(
target: T,
source: Record<string, any>
): T {
const result = { ...target };
for (const key of Object.keys(source)) {
const sourceValue = source[key];
const targetValue = result[key as keyof T];
// 둘 다 객체이고 배열이 아니면 깊은 병합
if (
isPlainObject(sourceValue) &&
isPlainObject(targetValue)
) {
result[key as keyof T] = deepMerge(targetValue, sourceValue);
} else if (sourceValue !== undefined) {
// source 값이 있으면 덮어쓰기
result[key as keyof T] = sourceValue;
}
}
return result;
}
function isPlainObject(value: unknown): value is Record<string, any> {
return (
typeof value === "object" &&
value !== null &&
!Array.isArray(value) &&
Object.prototype.toString.call(value) === "[object Object]"
);
}
// ============================================
// 설정 병합 함수 (렌더링 시 사용)
// ============================================
export function mergeComponentConfig(
defaultConfig: Record<string, any>,
customConfig: Record<string, any> | null | undefined
): Record<string, any> {
if (!customConfig || Object.keys(customConfig).length === 0) {
return { ...defaultConfig };
}
return deepMerge(defaultConfig, customConfig);
}
// ============================================
// 커스텀 설정 추출 함수 (저장 시 사용)
// ============================================
export function extractCustomConfig(
fullConfig: Record<string, any>,
defaultConfig: Record<string, any>
): Record<string, any> {
const customConfig: Record<string, any> = {};
for (const key of Object.keys(fullConfig)) {
const fullValue = fullConfig[key];
const defaultValue = defaultConfig[key];
// 기본값과 다른 경우만 커스텀으로 추출
if (!isDeepEqual(fullValue, defaultValue)) {
customConfig[key] = fullValue;
}
}
return customConfig;
}
// ============================================
// 깊은 비교 함수
// ============================================
export function isDeepEqual(a: unknown, b: unknown): boolean {
if (a === b) return true;
if (a == null || b == null) return a === b;
if (typeof a !== typeof b) return false;
if (typeof a !== "object") return a === b;
if (Array.isArray(a) !== Array.isArray(b)) return false;
if (Array.isArray(a) && Array.isArray(b)) {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (!isDeepEqual(a[i], b[i])) return false;
}
return true;
}
const objA = a as Record<string, unknown>;
const objB = b as Record<string, unknown>;
const keysA = Object.keys(objA);
const keysB = Object.keys(objB);
if (keysA.length !== keysB.length) return false;
for (const key of keysA) {
if (!keysB.includes(key)) return false;
if (!isDeepEqual(objA[key], objB[key])) return false;
}
return true;
}
// ============================================
// 컴포넌트 URL 생성 함수
// ============================================
export function getComponentUrl(componentType: string): string {
return `@/lib/registry/components/${componentType}`;
}
// ============================================
// 컴포넌트 타입 추출 함수 (URL에서)
// ============================================
export function getComponentTypeFromUrl(componentUrl: string): string {
// "@/lib/registry/components/split-panel-layout" → "split-panel-layout"
const parts = componentUrl.split("/");
return parts[parts.length - 1];
}
// ============================================
// V2 레이아웃 스키마
// ============================================
export const componentV2Schema = z.object({
id: z.string(),
url: z.string(),
position: z.object({
x: z.number().default(0),
y: z.number().default(0),
}),
size: z.object({
width: z.number().default(100),
height: z.number().default(100),
}),
displayOrder: z.number().default(0),
overrides: z.record(z.any()).default({}),
});
export const layoutV2Schema = z.object({
version: z.string().default("2.0"),
components: z.array(componentV2Schema).default([]),
updatedAt: z.string().optional(),
});
export type ComponentV2 = z.infer<typeof componentV2Schema>;
export type LayoutV2 = z.infer<typeof layoutV2Schema>;
// ============================================
// 컴포넌트별 기본값 레지스트리
// ============================================
const componentDefaultsRegistry: Record<string, Record<string, any>> = {
"table-list": {
pagination: true,
pageSize: 20,
selectable: true,
showHeader: true,
},
"button-primary": {
label: "버튼",
variant: "default",
size: "default",
},
"text-input": {
placeholder: "",
multiline: false,
},
"select-basic": {
placeholder: "선택하세요",
options: [],
},
"date-input": {
format: "YYYY-MM-DD",
},
"split-panel-layout": {
splitRatio: 50,
direction: "horizontal",
resizable: true,
},
"tabs-widget": {
tabs: [],
defaultTab: 0,
},
"card-display": {
title: "",
bordered: true,
},
"flow-widget": {
flowId: null,
},
"category-management": {
categoryType: "",
},
"pivot-table": {
rows: [],
columns: [],
values: [],
},
"unified-grid": {
columns: [],
},
"checkbox-basic": {
label: "",
defaultChecked: false,
},
"radio-basic": {
options: [],
},
"file-upload": {
accept: "*",
multiple: false,
},
"repeat-container": {
children: [],
},
};
// ============================================
// 컴포넌트 기본값 조회
// ============================================
export function getComponentDefaults(componentType: string): Record<string, any> {
return componentDefaultsRegistry[componentType] || {};
}
// ============================================
// URL에서 기본값 조회
// ============================================
export function getDefaultsByUrl(url: string): Record<string, any> {
const componentType = getComponentTypeFromUrl(url);
return getComponentDefaults(componentType);
}
// ============================================
// V2 컴포넌트 로드 (기본값 + overrides 병합)
// ============================================
export function loadComponentV2(component: ComponentV2): ComponentV2 & { config: Record<string, any> } {
const defaults = getDefaultsByUrl(component.url);
const config = mergeComponentConfig(defaults, component.overrides);
return {
...component,
config,
};
}
// ============================================
// V2 컴포넌트 저장 (차이값 추출)
// ============================================
export function saveComponentV2(
component: ComponentV2 & { config?: Record<string, any> }
): ComponentV2 {
const defaults = getDefaultsByUrl(component.url);
const overrides = component.config
? extractCustomConfig(component.config, defaults)
: component.overrides;
return {
id: component.id,
url: component.url,
position: component.position,
size: component.size,
displayOrder: component.displayOrder,
overrides,
};
}
// ============================================
// V2 레이아웃 로드 (전체 컴포넌트 기본값 병합)
// ============================================
export function loadLayoutV2(layoutData: any): LayoutV2 & { components: Array<ComponentV2 & { config: Record<string, any> }> } {
const parsed = layoutV2Schema.parse(layoutData || { version: "2.0", components: [] });
return {
...parsed,
components: parsed.components.map(loadComponentV2),
};
}
// ============================================
// V2 레이아웃 저장 (전체 컴포넌트 차이값 추출)
// ============================================
export function saveLayoutV2(
components: Array<ComponentV2 & { config?: Record<string, any> }>
): LayoutV2 {
return {
version: "2.0",
components: components.map(saveComponentV2),
updatedAt: new Date().toISOString(),
};
}

View File

@ -1,39 +0,0 @@
/**
* button-primary Zod
*/
import { z } from "zod";
// 버튼 액션 스키마
export const buttonActionSchema = z.object({
type: z.string().default("save"),
targetScreenId: z.number().optional(),
successMessage: z.string().optional(),
errorMessage: z.string().optional(),
modalSize: z.string().optional(),
modalTitle: z.string().optional(),
modalDescription: z.string().optional(),
modalTitleBlocks: z.array(z.any()).optional(),
});
// button-primary 설정 스키마
export const buttonPrimaryConfigSchema = z.object({
type: z.literal("button-primary").default("button-primary"),
text: z.string().default("저장"),
actionType: z.enum(["button", "submit", "reset"]).default("button"),
variant: z.enum(["primary", "secondary", "danger", "outline", "destructive"]).default("primary"),
webType: z.literal("button").default("button"),
action: buttonActionSchema.optional(),
// 추가 속성들
label: z.string().optional(),
langKey: z.string().optional(),
langKeyId: z.number().optional(),
size: z.string().optional(),
backgroundColor: z.string().optional(),
textColor: z.string().optional(),
borderRadius: z.string().optional(),
});
export type ButtonPrimaryConfig = z.infer<typeof buttonPrimaryConfigSchema>;
// 기본값 (스키마에서 자동 생성)
export const buttonPrimaryDefaults: ButtonPrimaryConfig = buttonPrimaryConfigSchema.parse({});

View File

@ -529,6 +529,8 @@ export class ButtonActionExecutor {
// 약간의 대기 시간을 주어 이벤트 핸들러가 formData를 업데이트할 수 있도록 함 // 약간의 대기 시간을 주어 이벤트 핸들러가 formData를 업데이트할 수 있도록 함
await new Promise((resolve) => setTimeout(resolve, 100)); await new Promise((resolve) => setTimeout(resolve, 100));
console.log("📦 [handleSave] beforeFormSave 이벤트 후 formData keys:", Object.keys(context.formData || {}));
// 검증 실패 시 저장 중단 // 검증 실패 시 저장 중단
if (beforeSaveEventDetail.validationFailed) { if (beforeSaveEventDetail.validationFailed) {
console.log("❌ [handleSave] 검증 실패로 저장 중단:", beforeSaveEventDetail.validationErrors); console.log("❌ [handleSave] 검증 실패로 저장 중단:", beforeSaveEventDetail.validationErrors);
@ -547,11 +549,13 @@ export class ButtonActionExecutor {
); );
if (hasTableSectionData) { if (hasTableSectionData) {
console.log("📋 [handleSave] _tableSection_ 데이터 감지 - onSave 콜백 건너뛰고 테이블 섹션 저장 로직 사용");
} }
// 🆕 EditModal 등에서 전달된 onSave 콜백이 있으면 우선 사용 // 🆕 EditModal 등에서 전달된 onSave 콜백이 있으면 우선 사용
// 단, _tableSection_ 데이터가 있으면 건너뛰기 (handleUniversalFormModalTableSectionSave가 처리) // 단, _tableSection_ 데이터가 있으면 건너뛰기 (handleUniversalFormModalTableSectionSave가 처리)
if (onSave && !hasTableSectionData) { if (onSave && !hasTableSectionData) {
console.log("✅ [handleSave] onSave 콜백 발견 - 콜백 실행 (테이블 섹션 데이터 없음)");
try { try {
await onSave(); await onSave();
return true; return true;
@ -592,12 +596,10 @@ export class ButtonActionExecutor {
} }
} else if (value.length === 0 && key.startsWith("comp_")) { } else if (value.length === 0 && key.startsWith("comp_")) {
// comp_로 시작하는 빈 배열은 렉 구조 컴포넌트일 가능성 있음 // comp_로 시작하는 빈 배열은 렉 구조 컴포넌트일 가능성 있음
// allComponents에서 확인 (v1, v2 모두 지원) // allComponents에서 확인
const rackStructureComponentInLayout = context.allComponents?.find( const rackStructureComponentInLayout = context.allComponents?.find(
(comp: any) => (comp: any) =>
comp.type === "component" && comp.type === "component" && comp.componentId === "rack-structure" && comp.columnName === key,
(comp.componentId === "rack-structure" || comp.componentId === "v2-rack-structure") &&
comp.columnName === key,
); );
if (rackStructureComponentInLayout) { if (rackStructureComponentInLayout) {
hasEmptyRackStructureField = true; hasEmptyRackStructureField = true;
@ -2212,6 +2214,14 @@ export class ButtonActionExecutor {
// 섹션별 원본 데이터가 있으면 사용, 없으면 전역 originalGroupedData 사용 // 섹션별 원본 데이터가 있으면 사용, 없으면 전역 originalGroupedData 사용
const originalDataForDelete = sectionOriginalData.length > 0 ? sectionOriginalData : originalGroupedData; const originalDataForDelete = sectionOriginalData.length > 0 ? sectionOriginalData : originalGroupedData;
console.log(`🔍 [DELETE 비교] 섹션 ${sectionId}:`, {
sectionOriginalKey,
sectionOriginalCount: sectionOriginalData.length,
globalOriginalCount: originalGroupedData.length,
usingData: sectionOriginalData.length > 0 ? "섹션별 원본" : "전역 원본",
currentCount: currentItems.length,
});
// ⚠️ id 타입 통일: 문자열로 변환하여 비교 (숫자 vs 문자열 불일치 방지) // ⚠️ id 타입 통일: 문자열로 변환하여 비교 (숫자 vs 문자열 불일치 방지)
const currentIds = new Set(currentItems.map((item) => String(item.id)).filter(Boolean)); const currentIds = new Set(currentItems.map((item) => String(item.id)).filter(Boolean));
const deletedItems = originalDataForDelete.filter((orig) => orig.id && !currentIds.has(String(orig.id))); const deletedItems = originalDataForDelete.filter((orig) => orig.id && !currentIds.has(String(orig.id)));

View File

@ -370,25 +370,13 @@ export const DynamicComponentConfigPanel: React.FC<ComponentConfigPanelProps> =
// 🆕 allComponents를 screenComponents 형태로 변환 (집계 위젯 등에서 사용) // 🆕 allComponents를 screenComponents 형태로 변환 (집계 위젯 등에서 사용)
// Hooks 규칙: 조건부 return 전에 선언해야 함 // Hooks 규칙: 조건부 return 전에 선언해야 함
const screenComponents = React.useMemo(() => { const screenComponents = React.useMemo(() => {
if (!allComponents) { if (!allComponents) return [];
console.log("[getComponentConfigPanel] allComponents is undefined or null"); return allComponents.map((comp: any) => ({
return []; id: comp.id,
} componentType: comp.componentType || comp.type,
console.log("[getComponentConfigPanel] allComponents 변환 시작:", allComponents.length, "개"); label: comp.label || comp.name || comp.id,
const result = allComponents.map((comp: any) => { tableName: comp.componentConfig?.tableName || comp.tableName,
const columnName = comp.columnName || comp.componentConfig?.columnName || comp.componentConfig?.fieldName; }));
console.log(`[getComponentConfigPanel] comp: ${comp.id}, type: ${comp.componentType || comp.type}, columnName: ${columnName}`);
return {
id: comp.id,
componentType: comp.componentType || comp.type,
label: comp.label || comp.name || comp.id,
tableName: comp.componentConfig?.tableName || comp.tableName,
// 🆕 폼 필드 인식용 columnName 추가
columnName,
};
});
console.log("[getComponentConfigPanel] screenComponents 변환 완료:", result);
return result;
}, [allComponents]); }, [allComponents]);
if (loading) { if (loading) {

View File

@ -1,140 +0,0 @@
/**
* V2
*
* LayoutData V2 LayoutData
*/
import {
ComponentV2,
LayoutV2,
getComponentUrl,
getComponentTypeFromUrl,
getDefaultsByUrl,
mergeComponentConfig,
extractCustomConfig
} from "@/lib/schemas/componentConfig";
// 기존 ComponentData 타입 (간략화)
interface LegacyComponentData {
id: string;
componentType?: string;
widgetType?: string;
type?: string;
position?: { x: number; y: number };
size?: { width: number; height: number };
componentConfig?: Record<string, any>;
[key: string]: any;
}
interface LegacyLayoutData {
components: LegacyComponentData[];
gridSettings?: any;
screenResolution?: any;
metadata?: any;
}
// ============================================
// V2 → Legacy 변환 (로드 시)
// ============================================
export function convertV2ToLegacy(v2Layout: LayoutV2 | null): LegacyLayoutData | null {
if (!v2Layout || !v2Layout.components) {
return null;
}
const components: LegacyComponentData[] = v2Layout.components.map((comp) => {
const componentType = getComponentTypeFromUrl(comp.url);
const defaults = getDefaultsByUrl(comp.url);
const mergedConfig = mergeComponentConfig(defaults, comp.overrides);
return {
id: comp.id,
componentType: componentType,
widgetType: componentType,
type: "component",
position: comp.position,
size: comp.size,
componentConfig: mergedConfig,
// 기존 구조 호환을 위한 추가 필드
label: mergedConfig.label || componentType,
style: {},
parentId: null,
gridColumns: 12,
gridRowIndex: 0,
};
});
return {
components,
gridSettings: {
enabled: true,
size: 20,
color: "#d1d5db",
opacity: 0.5,
snapToGrid: true,
columns: 12,
gap: 16,
padding: 16,
},
screenResolution: {
width: 1920,
height: 1080,
},
};
}
// ============================================
// Legacy → V2 변환 (저장 시)
// ============================================
export function convertLegacyToV2(legacyLayout: LegacyLayoutData): LayoutV2 {
const components: ComponentV2[] = legacyLayout.components.map((comp, index) => {
// 컴포넌트 타입 결정
const componentType = comp.componentType || comp.widgetType || comp.type || "unknown";
const url = getComponentUrl(componentType);
// 기본값 가져오기
const defaults = getDefaultsByUrl(url);
// 현재 설정에서 차이값만 추출
const fullConfig = comp.componentConfig || {};
const overrides = extractCustomConfig(fullConfig, defaults);
return {
id: comp.id,
url: url,
position: comp.position || { x: 0, y: 0 },
size: comp.size || { width: 100, height: 100 },
displayOrder: index,
overrides: overrides,
};
});
return {
version: "2.0",
components,
updatedAt: new Date().toISOString(),
};
}
// ============================================
// V2 레이아웃 유효성 검사
// ============================================
export function isValidV2Layout(data: any): data is LayoutV2 {
return (
data &&
typeof data === "object" &&
data.version === "2.0" &&
Array.isArray(data.components)
);
}
// ============================================
// 기존 레이아웃인지 확인
// ============================================
export function isLegacyLayout(data: any): boolean {
return (
data &&
typeof data === "object" &&
Array.isArray(data.components) &&
data.version !== "2.0"
);
}

View File

@ -307,12 +307,6 @@ export function createUnifiedConfigFromColumn(column: {
componentConfig.searchable = true; componentConfig.searchable = true;
} }
// select 타입인 경우: 해당 테이블의 해당 컬럼에서 DISTINCT 값 조회
if (column.widgetType === "select" || column.inputType === "select") {
componentConfig.source = "select"; // DISTINCT 조회 모드
componentConfig.searchable = true;
}
return { return {
componentType: mapping.componentType, componentType: mapping.componentType,
componentConfig, componentConfig,

View File

@ -319,6 +319,8 @@ class LegacyEventAdapter {
this.config = { ...this.config, ...options }; this.config = { ...this.config, ...options };
} }
console.log("[LegacyEventAdapter] 초기화 시작", this.config);
EVENT_MAPPINGS.forEach((mapping) => { EVENT_MAPPINGS.forEach((mapping) => {
// 레거시 → V2 브릿지 // 레거시 → V2 브릿지
if (this.config.legacyToV2) { if (this.config.legacyToV2) {
@ -332,6 +334,9 @@ class LegacyEventAdapter {
}); });
this.isActive = true; this.isActive = true;
console.log(
`[LegacyEventAdapter] 초기화 완료 (${EVENT_MAPPINGS.length}개 매핑)`
);
} }
private setupLegacyToV2Bridge(mapping: EventMapping): void { private setupLegacyToV2Bridge(mapping: EventMapping): void {
@ -406,6 +411,8 @@ class LegacyEventAdapter {
this.bridgedEvents.clear(); this.bridgedEvents.clear();
this.isActive = false; this.isActive = false;
console.log("[LegacyEventAdapter] 정리 완료");
} }
/** /**

View File

@ -55,6 +55,8 @@ export function initV2Core(options?: V2CoreOptions): void {
legacyBridge = { legacyToV2: true, v2ToLegacy: true }, legacyBridge = { legacyToV2: true, v2ToLegacy: true },
} = options ?? {}; } = options ?? {};
console.log("[V2Core] 초기화 시작...");
// 디버그 모드 설정 // 디버그 모드 설정
v2EventBus.debug = debug; v2EventBus.debug = debug;
@ -62,6 +64,11 @@ export function initV2Core(options?: V2CoreOptions): void {
legacyEventAdapter.init(legacyBridge); legacyEventAdapter.init(legacyBridge);
isInitialized = true; isInitialized = true;
console.log("[V2Core] 초기화 완료", {
debug,
legacyBridge: legacyEventAdapter.active,
});
} }
/** /**

View File

@ -1,60 +0,0 @@
const fs = require('fs');
const path = require('path');
const filePath = path.join(__dirname, '../frontend/lib/utils/buttonActions.ts');
let content = fs.readFileSync(filePath, 'utf8');
// 디버깅 console.log 제거 (전체 줄)
// console.log로 시작하는 줄만 제거 (이모지 포함)
const patterns = [
// 디버깅 로그 (이모지 포함)
/^\s*console\.log\s*\([^)]*["'`]🔍[^]*?\);\s*$/gm,
/^\s*console\.log\s*\([^)]*["'`]📦[^]*?\);\s*$/gm,
/^\s*console\.log\s*\([^)]*["'`]📋[^]*?\);\s*$/gm,
/^\s*console\.log\s*\([^)]*["'`]🔗[^]*?\);\s*$/gm,
/^\s*console\.log\s*\([^)]*["'`]🔄[^]*?\);\s*$/gm,
/^\s*console\.log\s*\([^)]*["'`]🎯[^]*?\);\s*$/gm,
/^\s*console\.log\s*\([^)]*["'`]✅[^]*?\);\s*$/gm,
/^\s*console\.log\s*\([^)]*["'`]⏭️[^]*?\);\s*$/gm,
/^\s*console\.log\s*\([^)]*["'`]📊[^]*?\);\s*$/gm,
/^\s*console\.log\s*\([^)]*["'`]🏗️[^]*?\);\s*$/gm,
/^\s*console\.log\s*\([^)]*["'`]📝[^]*?\);\s*$/gm,
/^\s*console\.log\s*\([^)]*["'`]💾[^]*?\);\s*$/gm,
/^\s*console\.log\s*\([^)]*["'`]🔐[^]*?\);\s*$/gm,
/^\s*console\.log\s*\([^)]*["'`]🔑[^]*?\);\s*$/gm,
/^\s*console\.log\s*\([^)]*["'`]🔒[^]*?\);\s*$/gm,
/^\s*console\.log\s*\([^)]*["'`]🧹[^]*?\);\s*$/gm,
/^\s*console\.log\s*\([^)]*["'`]🗑️[^]*?\);\s*$/gm,
/^\s*console\.log\s*\([^)]*["'`]📂[^]*?\);\s*$/gm,
/^\s*console\.log\s*\([^)]*["'`]📤[^]*?\);\s*$/gm,
/^\s*console\.log\s*\([^)]*["'`]📥[^]*?\);\s*$/gm,
/^\s*console\.log\s*\([^)]*["'`]🔎[^]*?\);\s*$/gm,
/^\s*console\.log\s*\([^)]*["'`]🆕[^]*?\);\s*$/gm,
/^\s*console\.log\s*\([^)]*["'`]📌[^]*?\);\s*$/gm,
/^\s*console\.log\s*\([^)]*["'`]🔥[^]*?\);\s*$/gm,
/^\s*console\.log\s*\([^)]*["'`]⚡[^]*?\);\s*$/gm,
/^\s*console\.log\s*\([^)]*["'`]🎉[^]*?\);\s*$/gm,
/^\s*console\.log\s*\([^)]*["'`]🚀[^]*?\);\s*$/gm,
/^\s*console\.log\s*\([^)]*["'`]📡[^]*?\);\s*$/gm,
/^\s*console\.log\s*\([^)]*["'`]🌐[^]*?\);\s*$/gm,
/^\s*console\.log\s*\([^)]*["'`]👤[^]*?\);\s*$/gm,
/^\s*console\.log\s*\([^)]*["'`]🚫[^]*?\);\s*$/gm,
/^\s*console\.log\s*\([^)]*["'`]🔧[^]*?\);\s*$/gm,
];
let totalRemoved = 0;
patterns.forEach(pattern => {
const matches = content.match(pattern);
if (matches) {
totalRemoved += matches.length;
content = content.replace(pattern, '');
}
});
// 연속된 빈 줄 제거 (3개 이상의 빈 줄을 2개로)
content = content.replace(/\n\n\n+/g, '\n\n');
fs.writeFileSync(filePath, content, 'utf8');
console.log(`Removed ${totalRemoved} console.log statements`);